Send Webmentions to linked URLs, excluding mentions

This commit is contained in:
Shadowfacts 2020-05-25 18:18:21 -04:00
parent 7e38b460c6
commit de7a1e617d
Signed by: shadowfacts
GPG Key ID: 94A5AB95422746E5
7 changed files with 162 additions and 42 deletions

View File

@ -38,10 +38,12 @@ config :http_signatures, adapter: Clacks.SignatureAdapter
config :clacks, Oban, config :clacks, Oban,
repo: Clacks.Repo, repo: Clacks.Repo,
prune: {:maxlen, 10_000}, prune: {:maxlen, 10_000},
queues: [federate: 10] queues: [federate: 5, send_webmention: 1]
config :floki, :html_parser, Floki.HTMLParser.FastHtml config :floki, :html_parser, Floki.HTMLParser.FastHtml
config :clacks, :send_webmentions, true
# Import environment specific config. This must remain at the bottom # Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above. # of this file so it overrides the configuration defined above.
import_config "#{Mix.env()}.exs" import_config "#{Mix.env()}.exs"

View File

@ -13,18 +13,30 @@ defmodule Clacks.ActivityPub.Helper do
:error :error
{:ok, activity} -> {:ok, activity} ->
worker = if Application.get_env(:clacks, :send_webmentions, true) do
%{id: activity.id, actor_id: actor.id} Clacks.Worker.SendWebmention.enqueue_for_activity(activity)
|> Clacks.Worker.Federate.new() end
case Oban.insert(worker) do case enqueue_federate_job(activity, actor) do
{:ok, _} -> :ok -> {:ok, activity}
{:ok, activity} :error -> :error
{:error, changeset} ->
Logger.error("Couldn't save federate job: #{inspect(changeset)}")
:error
end end
end end
end end
@spec enqueue_federate_job(Activity.t(), Actor.t()) :: :ok | :error
defp enqueue_federate_job(activity, actor) do
worker =
%{id: activity.id, actor_id: actor.id}
|> Clacks.Worker.Federate.new()
case Oban.insert(worker) do
{:ok, _} ->
:ok
{:error, changeset} ->
Logger.error("Couldn't save federate job: #{inspect(changeset)}")
:error
end
end
end end

View File

@ -1,13 +1,13 @@
defmodule Clacks.HTTP do defmodule Clacks.HTTP do
require Logger require Logger
@spec get(url :: String.t(), headers :: [{String.t(), String.t()}]) :: @spec get(url :: String.t(), headers :: HTTPoison.headers()) ::
{:ok, HTTPoison.Response.t()} | {:error, String.t()} {:ok, HTTPoison.Response.t()} | {:error, String.t()}
def get(url, headers \\ []) do def get(url, headers \\ []) do
fetch(:get, url, headers) fetch(:get, url, headers)
end end
@spec head(url :: String.t(), headers :: [{String.t(), String.t()}]) :: @spec head(url :: String.t(), headers :: HTTPoison.headers()) ::
{:ok, HTTPoison.Response.t()} | {:error, String.t()} {:ok, HTTPoison.Response.t()} | {:error, String.t()}
def head(url, headers \\ []) do def head(url, headers \\ []) do
fetch(:head, url, headers) fetch(:head, url, headers)
@ -27,7 +27,9 @@ defmodule Clacks.HTTP do
|> Enum.find(fn {name, _value} -> String.downcase(name) == "location" end) |> Enum.find(fn {name, _value} -> String.downcase(name) == "location" end)
|> case do |> case do
{_, new_url} -> {_, new_url} ->
new_url = URI.merge(URI.parse(url), URI.parse(new_url)) new_url =
URI.merge(URI.parse(url), URI.parse(new_url))
|> URI.to_string()
Logger.debug("Got 301 redirect from #{url} to #{new_url}") Logger.debug("Got 301 redirect from #{url} to #{new_url}")
fetch(method, new_url, headers) fetch(method, new_url, headers)
@ -49,4 +51,10 @@ defmodule Clacks.HTTP do
{:error, inspect(reason)} {:error, inspect(reason)}
end end
end end
@spec post(String.t(), any(), HTTPoison.headers()) ::
{:ok, HTTPoison.Response.t()} | {:error, HTTPoison.Error.t()}
def post(url, body, headers \\ []) do
HTTPoison.post(url, body, headers)
end
end end

View File

@ -56,7 +56,7 @@ defmodule Clacks.Webmention.Endpoint do
uri_reference = uri_reference =
value value
|> String.trim() |> String.trim()
|> String.slice(1..-1) |> String.slice(1..-2)
{_, rel} = {_, rel} =
params params

View File

@ -0,0 +1,110 @@
defmodule Clacks.Worker.SendWebmention do
use Oban.Worker, queue: :send_webmention
alias Clacks.Activity
alias ClacksWeb.Router.Helpers, as: Routes
alias ClacksWeb.Endpoint
require Logger
@spec enqueue_for_activity(Activity.t()) :: :ok
def enqueue_for_activity(
%Activity{data: %{"type" => "Create", "object" => %{"content" => content} = note}} =
activity
) do
tags = Map.get(note, "tag", [])
tag_hrefs = Enum.map(tags, fn %{"href" => href} -> href end)
case Floki.parse_fragment(content) do
{:ok, html_tree} ->
# todo: should this also skip anchors with the .mention class?
Floki.find(html_tree, "a")
|> Enum.each(fn {"a", attrs, _} ->
case Enum.find(attrs, fn {name, _} -> name == "href" end) do
{"href", link} ->
IO.inspect("Found link: #{link}")
maybe_enqueue_for_candidate(activity, tag_hrefs, link)
nil ->
:ok
end
end)
{:error, reason} ->
Logger.warn("Unable to parse HTML for sending Webmentions: #{reason}")
end
end
@spec maybe_enqueue_for_candidate(Activity.t(), [String.t()], String.t()) :: boolean()
defp maybe_enqueue_for_candidate(
%Activity{data: %{"object" => %{"in_reply_to" => in_reply_to}}} = activity,
activity_tag_hrefs,
link
) do
# todo: checking in_reply_to != link won't be adequate after we support replying via Webmention to non-AP posts
with false <- in_reply_to == link,
false <- Enum.member?(activity_tag_hrefs, link),
%URI{scheme: scheme} when scheme in ["http", "https"] <- URI.parse(link) do
enqueue(activity, link) == :ok
else
_ ->
false
end
end
defp maybe_enqueue_for_candidate(activity, activity_tag_hrefs, link) do
with false <- Enum.member?(activity_tag_hrefs, link),
%URI{scheme: scheme} when scheme in ["http", "https"] <- URI.parse(link) do
enqueue(activity, link) == :ok
else
_ ->
false
end
end
@spec enqueue(Activity.t(), String.t()) :: :ok | :error
defp enqueue(activity, link) do
worker = __MODULE__.new(%{activity: activity.id, link: link})
case Oban.insert(worker) do
{:ok, _} ->
:ok
{:error, reason} ->
Logger.error("Couldn't save send Webmention job: #{inspect(reason)}")
:error
end
end
@impl Oban.Worker
def perform(%{"activity" => activity_id, "link" => link}, _job) do
case Clacks.Webmention.Endpoint.find_endpoint(link) do
nil ->
:ok
endpoint ->
endpoint = URI.to_string(endpoint)
source = Routes.activities_url(Endpoint, :get, activity_id)
target = URI.encode_www_form(link)
body = "source=#{source}&target=#{target}"
case Clacks.HTTP.post(endpoint, body, [
{"Content-Type", "application/x-www-form-urlencoded"}
]) do
{:ok, %HTTPoison.Response{status_code: status_code}} when status_code in 200..299 ->
Logger.debug("Successfully sent Webmention to '#{link}'")
:ok
{:ok, %HTTPoison.Response{status_code: status_code}} ->
Logger.error(
"Unhandled status code #{status_code} for sending Webmention to '#{link}'"
)
{:error, "Unhandled status code for Webmention '#{link}': #{status_code}"}
{:error, error} ->
Logger.error("Failed sending Webmention to '#{link}': #{inspect(error)}")
{:error, error}
end
end
end
end

View File

@ -4,11 +4,9 @@ defmodule ClacksWeb.ActivitiesController do
alias ClacksWeb.Router.Helpers, as: Routes alias ClacksWeb.Router.Helpers, as: Routes
alias ClacksWeb.Endpoint alias ClacksWeb.Endpoint
def get(conn, _params) do def get(conn, %{"id" => id}) do
ap_id = current_url(conn, %{}) case Activity.get(id) do
%Activity{local: true, data: data} ->
case Activity.get_by_ap_id(ap_id) do
%Activity{local: true, id: id, data: data} ->
case conn.assigns[:format] do case conn.assigns[:format] do
"activity+json" -> "activity+json" ->
json(conn, data) json(conn, data)
@ -29,16 +27,4 @@ defmodule ClacksWeb.ActivitiesController do
end end
end end
end end
def get_status(conn, %{"id" => status_id}) do
case Activity.get(status_id) do
%Activity{local: true, data: data} ->
json(conn, data)
_ ->
conn
|> put_status(404)
|> json(%{error: "Not Found"})
end
end
end end

View File

@ -1,15 +1,17 @@
<div class="<%= assigns[:class] || "status h-entry" %>"> <div class="<%= assigns[:class] || "status h-entry" %>">
<div class="status-meta"> <div class="status-meta">
<h2 class="status-author-nickname"> <div class="p-author h-card">
<a href="<%= local_actor_link(@author) %>" class="p-author"> <h2 class="status-author-nickname">
<%= @author.data["preferredUsername"] %> <a href="<%= local_actor_link(@author) %>">
</a> <%= @author.data["preferredUsername"] %>
</h2> </a>
<h3 class="status-author-username"> </h2>
<a href="<%= @author.ap_id %>"> <h3 class="status-author-username">
<%= display_username(@author) %> <a href="<%= @author.ap_id %>" class="p-name u-url">
</a> <%= display_username(@author) %>
</h3> </a>
</h3>
</div>
<p class="status-meta-right"> <p class="status-meta-right">
<time datetime="<%= @note["published"] %>" class="dt-published"><%= display_timestamp(@note["published"]) %></time> <time datetime="<%= @note["published"] %>" class="dt-published"><%= display_timestamp(@note["published"]) %></time>
<a href="<%= @note["url"] || @note["id"] %>" class="status-permalink u-url">Permalink</a> <a href="<%= @note["url"] || @note["id"] %>" class="status-permalink u-url">Permalink</a>