Current section

Files

Jump to
indieweb lib indieweb webmention.ex
Raw

lib/indieweb/webmention.ex

defmodule IndieWeb.Webmention do
@moduledoc """
General facilities around Webmentions.
This module provides methods around handling receiving and sending Webmentions.
"""
require Logger
alias IndieWeb.{Http, LinkRel}
defmodule URIAdapter do
@moduledoc """
Facility for handling URI generation of Webmention logic.
"""
@doc "Defines a means of generating a URI from the provided value."
@callback to_source_url(object :: any()) :: nil | URI.t()
@doc "Defines a means of obtaining a target from a URI."
@callback from_source_url(uri :: URI.t()) :: nil | any()
end
defmodule SendResponse do
@moduledoc false
@enforce_keys ~w(target source code)a
defstruct ~w(target source code body headers)a
@type t() :: %__MODULE__{
target: binary(),
source: binary(),
code: non_neg_integer(),
body: binary(),
headers: map()
}
end
defmodule ReceiveResponse do
@moduledoc false
@enforce_keys ~w(target source resource arguments)a
defstruct ~w(target source resource arguments)a
@type t() :: %__MODULE__{
target: binary(),
source: binary(),
resource: any(),
arguments: keyword()
}
end
@doc "Defines the adpater to use to resolve URI and source content."
@spec url_adapter() :: __MODULE__.URIAdapter.t()
def url_adapter, do: Application.get_env(:indieweb, :webmention_url_adapter)
@doc "Determines the resource from the provided target URI."
@spec resolve_target_from_url(binary()) ::
{:ok, any()} | {:error, :no_target} | {:error, :no_adapter}
def resolve_target_from_url(target_url) do
if url_adapter() != nil do
case url_adapter().from_source_url(target_url) do
nil -> {:error, :no_target}
target -> {:ok, target}
end
else
{:error, :no_adapter}
end
end
@doc "Generates a source URL for the provided resource."
@spec resolve_source_url(binary()) ::
{:ok, URI.t()}
| {:error, :no_source}
| {:error, :no_adapter}
def resolve_source_url(source) do
if url_adapter() != nil do
case url_adapter().to_source_url(source) do
nil -> {:error, :no_source}
%URI{} = url -> {:ok, url}
end
else
{:error, :no_adapter}
end
end
@doc """
Finds the Webmention endpoint of the provided URI.
This employs the [Webmention endpoint discovery algorithm][1] to find
the proper endpoint to send Webmentions for the URI in question.
[1]: https://www.w3.org/TR/webmention/#sender-discovers-receiver-webmention-endpoint
TODO: Add User-Agent information (by allowing to pass in header options)
TODO: This hits every endpoint with a HEAD when it's called - need to cache the value.
NOTE: This makes an assumption that the endpoint is self-hosted by default.
NOTE: This assumes that a site can only have one Webmention endpoint.
"""
@spec discover_endpoint(binary) ::
{:ok, binary()} | {:error, reason: :no_endpoint_found}
def discover_endpoint(page_url) do
endpoint =
page_url
|> LinkRel.find("webmention")
|> resolve_endpoint_urls(page_url)
|> Enum.reject(&is_nil/1)
|> Enum.reject(&(&1 == ""))
|> List.first()
if is_nil(endpoint) do
{:error, reason: :no_endpoint_found}
else
{:ok, endpoint}
end
end
@doc """
Sends a Webmention to the provided URI.
This determines the endpoint to send [Webmentions][1] to (using `discover_endpoint/1`) and
sends the request using the HTTP client provided.
[1]: https://www.w3.org/TR/webmention
"""
@spec send(binary(), binary(), map()) ::
{:ok, SendResponse.t()}
| {:error, :webmention_send_failure, reason: any()}
def send(target_url, source, opts \\ %{}) do
with(
{:ok, endpoint_url} <- discover_endpoint(target_url),
{:ok, resp} <- direct_send!(endpoint_url, target_url, source, opts)
) do
{:ok, resp}
else
{:error, error} -> {:error, :webmention_send_failure, error}
end
end
@doc """
Explicitly sends a Webmention.
Sends a Webmention to the provided `endpoint` for `target` from `source`.
"""
@spec direct_send!(binary(), binary(), binary(), keyword()) ::
{:ok, SendResponse.t()} | {:error, atom(), keyword()}
def direct_send!(endpoint, target_url, source, opts \\ %{}) do
Logger.debug("Attempting to send Webmention.",
endpoint: endpoint,
target_url: target_url,
source: source,
options: inspect(opts)
)
with(
{:ok, source_url} <- resolve_source_url(source),
{:ok, %IndieWeb.Http.Response{code: code, body: body, headers: headers}}
when code >= 200 and code < 400 <-
Http.post_encoded(endpoint,
body:
Map.merge(
%{"source" => URI.to_string(source_url), "target" => target_url},
opts
)
)
) do
send_resp = %SendResponse{
target: target_url,
source: source_url,
code: code,
body: body,
headers: headers
}
{:ok, send_resp}
else
{:error, %IndieWeb.Http.Response{} = resp} ->
{:error, reason: :unexpected_response, response: resp}
{:ok, %IndieWeb.Http.Response{} = resp} ->
{:error, reason: :unexpected_response, response: resp}
{:error, :no_source} ->
{:error, reason: :invalid_source}
{:error, :no_adapter} ->
{:error, reason: :no_parser_available}
{:error, error} ->
{:error, reason: :unexpected, result: error}
{:ok, result} ->
{:error, reason: :unexpected, result: result}
end
end
@doc """
Parses properties of an incoming Webmention.
This aims to resolve the target of an incoming Webmention and determine if there's
a valid action to take from it.
"""
@spec receive(map()) ::
{:ok, ReceiveResponse.t()}
| {:error, :webmention_receive_failure, reason: any()}
def receive(args) do
source_url = args[:source]
target_url = args[:target]
Logger.debug("Attempting to receive and resolve Webmention.",
target: target_url,
source: source_url
)
case resolve_target_from_url(target_url) do
{:ok, target} ->
{:ok,
%ReceiveResponse{
source: source_url,
target: target_url,
resource: target,
arguments: Map.drop(args, ~w(source target)a)
}}
{:error, error} ->
{:error, :webmention_receive_failure, reason: error}
end
end
defp resolve_endpoint_urls(rel_urls, base_uri) do
rel_urls
|> Enum.map(&IndieWeb.Http.make_absolute_uri(&1, base_uri))
|> Enum.map(&IndieWeb.URL.canonalize/1)
|> Enum.uniq()
|> Enum.reject(&is_nil/1)
end
end
# SPDX-License-Identifier: AGPL-3.0-only