# Pleroma: A lightweight social networking server # Copyright © 2017-2022 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.WebFinger do alias Pleroma.HTTP alias Pleroma.User alias Pleroma.Web.Endpoint alias Pleroma.Web.Federator.Publisher alias Pleroma.Web.XML alias Pleroma.XmlBuilder require Jason require Logger def host_meta do base_url = Endpoint.url() { :XRD, %{xmlns: "http://docs.oasis-open.org/ns/xri/xrd-1.0"}, { :Link, %{ rel: "lrdd", type: "application/xrd+xml", template: "#{base_url}/.well-known/webfinger?resource={uri}" } } } |> XmlBuilder.to_doc() end def webfinger(resource, fmt) when fmt in ["XML", "JSON"] do host = Pleroma.Web.Endpoint.host() regex = if webfinger_domain = Pleroma.Config.get([__MODULE__, :domain]) do ~r/(acct:)?(?[a-z0-9A-Z_\.-]+)@(#{host}|#{webfinger_domain})/ else ~r/(acct:)?(?[a-z0-9A-Z_\.-]+)@#{host}/ end with %{"username" => username} <- Regex.named_captures(regex, resource), %User{} = user <- User.get_cached_by_nickname(username) do {:ok, represent_user(user, fmt)} else _e -> with %User{} = user <- User.get_cached_by_ap_id(resource) do {:ok, represent_user(user, fmt)} else _e -> {:error, "Couldn't find user"} end end end defp gather_links(%User{} = user) do [ %{ "rel" => "http://webfinger.net/rel/profile-page", "type" => "text/html", "href" => user.ap_id } ] ++ Publisher.gather_webfinger_links(user) end defp gather_aliases(%User{} = user) do [user.ap_id | user.also_known_as] end def represent_user(user, "JSON") do %{ "subject" => "acct:#{user.nickname}@#{domain()}", "aliases" => gather_aliases(user), "links" => gather_links(user) } end def represent_user(user, "XML") do aliases = user |> gather_aliases() |> Enum.map(&{:Alias, &1}) links = gather_links(user) |> Enum.map(fn link -> {:Link, link} end) { :XRD, %{xmlns: "http://docs.oasis-open.org/ns/xri/xrd-1.0"}, [ {:Subject, "acct:#{user.nickname}@#{domain()}"} ] ++ aliases ++ links } |> XmlBuilder.to_doc() end defp domain do Pleroma.Config.get([__MODULE__, :domain]) || Pleroma.Web.Endpoint.host() end defp webfinger_from_xml(body) do with {:ok, doc} <- XML.parse_document(body) do subject = XML.string_from_xpath("//Subject", doc) subscribe_address = ~s{//Link[@rel="http://ostatus.org/schema/1.0/subscribe"]/@template} |> XML.string_from_xpath(doc) ap_id = ~s{//Link[@rel="self" and @type="application/activity+json"]/@href} |> XML.string_from_xpath(doc) data = %{ "subject" => subject, "subscribe_address" => subscribe_address, "ap_id" => ap_id } {:ok, data} end end defp webfinger_from_json(body) do with {:ok, doc} <- Jason.decode(body) do data = Enum.reduce(doc["links"], %{"subject" => doc["subject"]}, fn link, data -> case {link["type"], link["rel"]} do {"application/activity+json", "self"} -> Map.put(data, "ap_id", link["href"]) {"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"", "self"} -> Map.put(data, "ap_id", link["href"]) {nil, "http://ostatus.org/schema/1.0/subscribe"} -> Map.put(data, "subscribe_address", link["template"]) _ -> Logger.debug("Unhandled type: #{inspect(link["type"])}") data end end) {:ok, data} end end def get_template_from_xml(body) do xpath = "//Link[@rel='lrdd']/@template" with {:ok, doc} <- XML.parse_document(body), template when template != nil <- XML.string_from_xpath(xpath, doc) do {:ok, template} end end @cachex Pleroma.Config.get([:cachex, :provider], Cachex) def find_lrdd_template(domain) do @cachex.fetch!(:host_meta_cache, domain, fn _ -> {:commit, fetch_lrdd_template(domain)} end) rescue e -> {:error, "Cachex error: #{inspect(e)}"} end defp fetch_lrdd_template(domain) do # WebFinger is restricted to HTTPS - https://tools.ietf.org/html/rfc7033#section-9.1 meta_url = "https://#{domain}/.well-known/host-meta" with {:ok, %{status: status, body: body}} when status in 200..299 <- HTTP.get(meta_url) do get_template_from_xml(body) else error -> Logger.warn("Can't find LRDD template in #{inspect(meta_url)}: #{inspect(error)}") {:error, :lrdd_not_found} end end defp get_address_from_domain(domain, "acct:" <> _ = encoded_account) when is_binary(domain) do case find_lrdd_template(domain) do {:ok, template} -> String.replace(template, "{uri}", encoded_account) _ -> "https://#{domain}/.well-known/webfinger?resource=#{encoded_account}" end end defp get_address_from_domain(domain, account) when is_binary(domain) do encoded_account = URI.encode("acct:#{account}") get_address_from_domain(domain, encoded_account) end defp get_address_from_domain(_, _), do: {:error, :webfinger_no_domain} @spec finger(String.t()) :: {:ok, map()} | {:error, any()} def finger(account) do account = String.trim_leading(account, "@") domain = with [_name, domain] <- String.split(account, "@") do domain else _e -> URI.parse(account).host end with address when is_binary(address) <- get_address_from_domain(domain, account), {:ok, %{status: status, body: body, headers: headers}} when status in 200..299 <- HTTP.get( address, [{"accept", "application/xrd+xml,application/jrd+json"}] ) do case List.keyfind(headers, "content-type", 0) do {_, content_type} -> case Plug.Conn.Utils.media_type(content_type) do {:ok, "application", subtype, _} when subtype in ~w(xrd+xml xml) -> webfinger_from_xml(body) {:ok, "application", subtype, _} when subtype in ~w(jrd+json json) -> webfinger_from_json(body) _ -> {:error, {:content_type, content_type}} end _ -> {:error, {:content_type, nil}} end |> case do {:ok, data} -> validate_webfinger(address, data) error -> error end else error -> Logger.debug("Couldn't finger #{account}: #{inspect(error)}") error end end defp validate_webfinger(request_url, %{"subject" => "acct:" <> acct = subject} = data) do with [_name, acct_host] <- String.split(acct, "@"), {_, url} <- {:address, get_address_from_domain(acct_host, subject)}, %URI{host: request_host} <- URI.parse(request_url), %URI{host: acct_host} <- URI.parse(url), {_, true} <- {:hosts_match, acct_host == request_host} do {:ok, data} else _ -> {:error, {:webfinger_invalid, request_url, data}} end end defp validate_webfinger(url, data), do: {:error, {:webfinger_invalid, url, data}} end