Current section

Files

Jump to
indieweb lib indieweb hcard.ex
Raw

lib/indieweb/hcard.ex

defmodule IndieWeb.HCard do
@moduledoc """
Extracts the representative [h-card](http://indieweb.org/h-card) of the provided URI.
"""
require Logger
alias IndieWeb.{Http, MF2}
@doc """
Obtains a remote page and determines the h-card of the MF2.
This takes a few approaches to find a h-card for the page in question.
It'll first search for a top-level h-card on the page.
If there isn't an obvious one, it'll search for a representative h-card.
"""
@spec resolve(binary()) :: {:ok, map()} | {:error, any()}
def resolve(uri) when is_binary(uri) do
approaches = [
extract_from_canonical_item: fn url ->
with(
{:ok, mf2} <- MF2.Remote.fetch(url),
{:ok, item_mf2} <- MF2.get_item_for_url(mf2, url)
) do
fetch_representative(item_mf2, url)
else
{:error, _error} -> nil
end
end,
fetch_representative: &__MODULE__.fetch_representative/1
]
result =
Enum.find_value(approaches, fn {approach, method} ->
Logger.info("Attempting to resolve h-card.",
approach: approach,
url: uri
)
case method.(uri) do
{:ok, _} = resp -> resp
_ -> false
end
end)
case result do
{:ok, hcard} -> do_cleanup_hcard(hcard)
_ -> {:error, :no_hcard_found}
end
end
@doc """
Obtains the representative h-card of the provided URI.
The [steps for parsing a representative h-card][1] are as follows:
* If the page contains an `h-card` with `uid` and `url` properties
both matching the page URL, the first such `h-card` is the
**representative h-card**.
* If no representative h-card was found, if the page contains an
`h-card` with a url property value which also has a rel=me
relation, the first such `h-card` is the **representative h-card**
* If no representative `h-card` was found, if the page contains one
single `h-card`, and the `h-card` has a url property matching the
page URL, that `h-card` is the representative `h-card`
* <thunk>
[1]: http://microformats.org/wiki/representative-h-card-parsing
"""
def fetch_representative(uri) when is_binary(uri) do
Logger.debug("Fetching page MF2.", uri: uri)
case MF2.Remote.fetch(uri) do
{:ok, mf2} -> fetch_representative(mf2, uri)
{:error, _} = error -> error
end
end
@doc """
Obtains the representative h-card of the provided URI from the provided MF2.
See fetch_representative/1 for more information.
"""
def fetch_representative(mf2, uri) do
%{scheme: scheme, authority: authority} = URI.parse(uri)
hostname = "#{scheme}://#{authority}"
Logger.debug("Extracted hostname for this uri.", hostname: hostname, uri: uri)
approaches = [
{:uid_url_of_page, &do_find_uid_url/2},
{:matching_rel_me_to_hcard, &do_find_with_matching_relme/2},
{:resolve_one_hcard, &do_find_solo/2}
]
Enum.find_value(approaches, {:error, reason: :no_representative_hcard_found}, fn {identifier,
approach} ->
Logger.debug("Attempting to find h-card.", approach: identifier, url: uri)
case approach.(mf2, {hostname, uri}) do
nil ->
Logger.debug("Couldn't find h-card with this approach.", approach: identifier, url: uri)
false
hcard ->
Logger.info("Found h-card.", approach: identifier, url: uri)
do_cleanup_hcard(hcard)
end
end)
end
# `rel=author` and `rel=home` are both used here in hopes of expanding those
# for whose have might MicroFormats2 support but not fully. This is not spec-compliant
# but I know those values are used and thus expanding to it only increases adoption.
defp do_find_with_matching_relme(mf2, {_, url}) do
author_rel_urls = extract_author_identity_urls(mf2, url)
cards = MF2.extract_all(mf2, "card")
Logger.debug("Found hCards and rel-me links.",
rel_mes: Enum.count(author_rel_urls),
hcards: Enum.count(cards)
)
if Enum.empty?(author_rel_urls) or Enum.empty?(cards) do
nil
else
cards
|> Enum.map(fn card ->
card
|> MF2.get_value!("url", [])
|> Enum.map(&{card, &1})
end)
|> List.flatten()
|> Enum.find_value(nil, fn {card, card_url} ->
card_uri = Http.make_absolute_uri(card_url, url)
if Enum.member?(author_rel_urls, card_uri) do
card
else
nil
end
end)
end
end
defp do_find_uid_url(mf2, {host, uri}) do
mf2
|> MF2.extract_all("card")
|> Enum.find(nil, fn hcard ->
hcard_url_matches? =
hcard
|> MF2.get_value!("url", ["/"])
|> Enum.map(&Http.make_absolute_uri(&1, host))
|> Enum.member?(uri)
hcard_uid_matches? =
hcard
|> MF2.get_value!("uid", [nil])
|> Enum.map(&Http.make_absolute_uri(&1, host))
|> Enum.member?(uri)
hcard_url_matches? && hcard_uid_matches?
end)
end
defp do_find_solo(mf2, _) do
cards = MF2.extract_all(mf2, "card")
if Enum.count(cards) == 1 do
List.first(cards)
end
end
defp do_cleanup_hcard(hcard) do
properties = hcard["properties"]
values = properties |> Map.values() |> List.flatten() |> Enum.uniq() |> Enum.reject(&(&1 == ""))
if values == hcard["properties"]["url"] do
Enum.find_value(hcard["properties"]["url"], fn url ->
fetch_representative(url)
end)
else
{:ok, hcard}
end
end
defp extract_author_identity_urls(%{"rels" => rels}, url) do
~w(home me author)
|> Enum.map(fn key -> Map.get(rels, key, []) end)
|> List.flatten()
|> Enum.map(&Http.make_absolute_uri(&1, url))
|> Enum.map(&URI.parse/1)
|> Enum.map(&URI.to_string/1)
|> List.wrap()
end
defp extract_author_identity_urls(_, _) do
[]
end
end
# SPDX-License-Identifier: AGPL-3.0-only