An IndieWeb engine for a self-hostable website. https://koype.net/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

313 lines
9.5KB

  1. # Koype: a IndieWeb-focused, single-tenant website engine for people.
  2. #
  3. # Copyright © 2019 Jacky Alciné <jacky.is@black.af>
  4. #
  5. # This file belongs to the Koype project.
  6. #
  7. # This program is free software: you can redistribute it and/or modify
  8. # it under the terms of the GNU Affero General Public License as published by
  9. # the Free Software Foundation, either version 3 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # This program is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU Affero General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU Affero General Public License
  18. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  19. defmodule Koype.Webmention do
  20. @moduledoc false
  21. require Koype.Http
  22. require Ecto.Query
  23. require Logger
  24. defmodule URIAdapter do
  25. @moduledoc "The adapter used by Koype for the `IndieWeb.Http` handler."
  26. @behaviour IndieWeb.Webmention.URIAdapter
  27. @resolvable_modules [Koype.Repo.Entry, Koype.Repo.Category]
  28. @impl true
  29. def to_source_url(target_url)
  30. def to_source_url(target_url) when is_binary(target_url), do: URI.parse(target_url)
  31. def to_source_url(%Koype.Repo.Entry{} = entry),
  32. do: to_source_url(Koype.Repo.Entry.get_uri(entry))
  33. def to_source_url(%URI{} = target_url), do: target_url
  34. def to_source_url(_), do: nil
  35. @impl true
  36. def from_source_url(%URI{} = target_url), do: from_source_url(URI.to_string(target_url))
  37. @impl true
  38. def from_source_url(target_url) when not is_nil(target_url) do
  39. Logger.debug("Attempting to determine raw value.", target_url: target_url)
  40. Enum.reduce_while(
  41. [
  42. &do_resolve_homepage_for_target/1,
  43. &do_resolve_model_for_target/1,
  44. &do_resolve_if_on_site/1
  45. ],
  46. nil,
  47. fn approach, acc ->
  48. case approach.(target_url) do
  49. {:error, _} -> {:cont, acc}
  50. {:ok, result} -> {:halt, result}
  51. end
  52. end
  53. )
  54. end
  55. def from_source_url(nil), do: from_source_url("/")
  56. defp do_resolve_if_on_site(target_url) when is_binary(target_url) do
  57. current_host = URI.parse(Koype.host()).host
  58. target_host = URI.parse(target_url).host
  59. if target_host == current_host || String.starts_with?(target_url, "/") do
  60. {:ok, target_url}
  61. else
  62. {:error, :not_on_site}
  63. end
  64. end
  65. defp do_resolve_homepage_for_target(target_url) when is_binary(target_url) do
  66. current_host = Koype.host()
  67. cond do
  68. "/" == target_url || current_host == target_url ->
  69. Logger.debug("URI matches homepage.", url: target_url)
  70. {:ok, :homepage}
  71. true ->
  72. Logger.debug("The URI is not the expected homepage.", url: target_url)
  73. {:error, :not_homepage}
  74. end
  75. end
  76. defp do_resolve_model_for_target(target_url) when is_binary(target_url) do
  77. Enum.reduce_while(
  78. @resolvable_modules,
  79. {:error, :no_mentionable_models},
  80. fn module, acc ->
  81. Logger.debug("Looking up model from URL.", module: module, uri: target_url)
  82. case module.resolve_from_uri(target_url) do
  83. {:error, _} -> {:cont, acc}
  84. {:ok, model} -> {:halt, {:ok, model}}
  85. end
  86. end
  87. )
  88. end
  89. end
  90. @doc "Provides the URI of the endpoint that Koype responds to for incoming Webmentions."
  91. def endpoint(), do: Koype.Web.Router.Helpers.indie_webmention_url(Koype.Web.Endpoint, :incoming)
  92. @doc "Queues an attempt to send out a Webmention from Koype to the target provided."
  93. @spec send(args :: Koype.Job.Webmention.Send.Arguments) :: none()
  94. def send(args), do: Koype.Job.create(Koype.Job.Webmention.Send, args)
  95. @doc "Invokes an attempt to send a Webmention to the URI specified at `target`."
  96. @spec send!(args :: Koype.Job.Webmention.Send.Arguments) ::
  97. {:ok, Koype.Http.Response.t()} | {:error, any()}
  98. def send!(args) do
  99. source = args[:source]
  100. target = args[:target]
  101. case IndieWeb.Webmention.send(target, source) do
  102. {:ok, %Koype.Http.Response{code: code} = resp} when code == 429 ->
  103. retry_after =
  104. case Koype.Http.extract_retry_time(resp) do
  105. 0 -> 10
  106. seconds -> seconds
  107. end
  108. :timer.apply_after(retry_after * 1_000, __MODULE__, :send!, args)
  109. Logger.warn("Failed to send Webmention. Attempting retry.",
  110. retry_in_seconds_from_now: retry_after,
  111. source: source,
  112. target: target
  113. )
  114. {:ok, resp}
  115. {:ok, resp} ->
  116. Logger.info("Webmention sent!", source: source, target: target)
  117. {:ok, resp}
  118. error ->
  119. Logger.error("Failed to send Webmention.",
  120. source: source,
  121. target: target,
  122. error: inspect(error)
  123. )
  124. error
  125. end
  126. end
  127. def receive(args), do: Koype.Job.create(Koype.Job.Webmention.Receive, args)
  128. def receive!(args) do
  129. case IndieWeb.Webmention.receive(args) do
  130. {:ok, args} -> do_build_webmention(args)
  131. error -> error
  132. end
  133. end
  134. defp do_build_webmention(args) do
  135. target_path =
  136. case args[:target_url] |> URI.parse() |> Map.get(:path) do
  137. "" -> "/"
  138. path -> path
  139. end
  140. args = Keyword.put(args, :target_url, target_path)
  141. case Koype.Http.get(args[:source]) do
  142. {:ok, %Koype.Http.Response{code: code}} when code in [404, 410] ->
  143. do_deletion_of_webmention(args)
  144. {:ok, %Koype.Http.Response{code: code}} when code >= 200 and code < 300 ->
  145. do_upsertion_of_webmention(args)
  146. {:ok, %Koype.Http.Response{code: code} = resp} when code >= 400 and code < 599 ->
  147. Logger.info("The remote page is not usable for MF2 fetching.",
  148. code: code,
  149. target_url: args[:target_url]
  150. )
  151. {:error, reason: :page_unavailable, raw: resp}
  152. {:error, error} ->
  153. Logger.error("The remote page is not usable for MF2 fetching.",
  154. error: inspect(error),
  155. target_url: args[:target_url]
  156. )
  157. {:error, reason: :page_down, raw: error}
  158. end
  159. end
  160. defp do_deletion_of_webmention(args) do
  161. mentions =
  162. Koype.Repo.Webmention
  163. |> Ecto.Query.where(source: ^args[:source], target: ^args[:target_url])
  164. |> Koype.Repo.all()
  165. |> Enum.map(&Koype.Repo.Webmention.delete(&1))
  166. Logger.warn("Marked all relating Webmentions as deleted.",
  167. source: args[:source],
  168. target: args[:target_url],
  169. deleted_count: Enum.count(mentions)
  170. )
  171. {:ok, :gone}
  172. end
  173. defp do_upsertion_of_webmention(args) do
  174. target_url = args[:target_url]
  175. source = args[:source]
  176. id =
  177. case Koype.Repo.get_by(Koype.Repo.Webmention, source: source) do
  178. nil -> nil
  179. %{id: id} -> id
  180. end
  181. with(
  182. {:ok, mf2} when is_map(mf2) <- IndieWeb.MF2.Remote.fetch(source),
  183. entry_mf2 when is_map(entry_mf2) <- IndieWeb.MF2.get_format(mf2, "entry"),
  184. {:ok, author} <- IndieWeb.HCard.resolve(source)
  185. ) do
  186. params = %{
  187. id: id,
  188. source: source,
  189. target: target_url,
  190. type: args[:type] || "note",
  191. mf2: entry_mf2
  192. }
  193. Logger.info("Saving Webmention from #{source} to #{target_url}...")
  194. case Koype.Repo.upsert(Koype.Repo.Webmention, params) do
  195. {:ok, record} ->
  196. # NOTE: Should we make these actions async.
  197. final_record =
  198. record
  199. |> Koype.Repo.Webmention.update_author!(author)
  200. |> Koype.Repo.Webmention.update_type!()
  201. |> Koype.Repo.Webmention.attempt_auto_moderation()
  202. {:ok, final_record}
  203. {:error, error} = err ->
  204. Logger.error(
  205. "Failed to create Webmention from #{source} to #{target_url}: #{inspect(error)}."
  206. )
  207. IndieWeb.MF2.Remote.flush(source)
  208. err
  209. end
  210. else
  211. nil ->
  212. Logger.warn("Failed to fetch MF2 of #{source}; no entry .")
  213. IndieWeb.MF2.Remote.flush(inspect(source))
  214. {:error, :mf2_not_found}
  215. {:ok, nil} ->
  216. Logger.warn("No MF2 found for #{source}.")
  217. {:error, :mf2_not_found}
  218. {:error, error} = err ->
  219. Logger.warn("Failed to fetch MF2 of #{source}: #{inspect(error)}.")
  220. IndieWeb.MF2.Remote.flush(inspect(source))
  221. err
  222. end
  223. end
  224. def fetch_for(entry, type \\ :all)
  225. def fetch_for(%Koype.Repo.Entry{} = entry, type) do
  226. entry
  227. |> Koype.Repo.Entry.get_uri()
  228. |> fetch_for(type)
  229. end
  230. def fetch_for(path, type) do
  231. path
  232. |> Koype.Repo.Webmention.all(type)
  233. |> Enum.map(fn model ->
  234. with({:ok, json} <- Koype.Repo.Webmention.json_find(model)) do
  235. json
  236. |> Map.put("id", model.id)
  237. |> Map.put("url", model.source)
  238. |> Map.put("type", model.type)
  239. else
  240. {:error, _} -> nil
  241. end
  242. end)
  243. |> Enum.filter(&is_map/1)
  244. end
  245. def reprocess(), do: Koype.Job.create(Koype.Job.Webmention.Process, [])
  246. def default_moderation_status(),
  247. do: Koype.Setting.get("webmention:moderation_status", "pending") |> String.to_existing_atom()
  248. def set_default_moderation_status(status \\ "pending")
  249. def set_default_moderation_status(status) when status in ~w(pending approved rejected),
  250. do: Koype.Setting.set("webmention:moderation_status", status)
  251. def set_default_moderation_status(status) when is_binary(status),
  252. do: set_default_moderation_status(Atom.to_string(status))
  253. def set_default_moderation_status(_), do: set_default_moderation_status("pending")
  254. end