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.

content.ex 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  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 IndieWeb.Micropub.Content do
  20. require Logger
  21. @doc "Handles the prescribed action for the content with the provided arguments."
  22. @callback invoke(action :: String.t(), arguments :: Keyword.t()) :: :ok | {:ok, any()} | {:error, any()}
  23. @doc "Determines if the content action is supported."
  24. @callback supports?(action :: String.t()) :: true | false
  25. @doc "Dispatches the management of the provided data to its particular post type."
  26. @spec handle(action :: String.t(), options :: Keyword.t()) :: {:error, any()} | {:ok, any()} | :ok
  27. def handle(action, type: type, scope: scope, params: params) do
  28. case do_find_module_for_type(type) do
  29. nil ->
  30. {:error, :no_micropub_support}
  31. module ->
  32. if module.supports?(action) do
  33. module.invoke(action, scope: scope, params: params)
  34. else
  35. {:error, :action_unsupported}
  36. end
  37. end
  38. end
  39. @doc """
  40. Processes a property's value for more normalized use.
  41. This does the work of processing and handling a property for better support
  42. internally.
  43. """
  44. @spec process_property(name :: String.t(), value: any()) :: {:error, any()} | {:ok, any()}
  45. def process_property(name, value)
  46. # NOTE: Add support for detecting venue information.
  47. # NOTE: Add support for piping information to atlas.p3k.io
  48. def process_property("location", value) when is_binary(value) do
  49. cond do
  50. String.starts_with?(value, "geo:") ->
  51. %{parsed_path: geo_data} = URL.parse(value)
  52. {:ok,
  53. %{
  54. lat: geo_data.lat,
  55. lng: geo_data.lng
  56. }}
  57. true ->
  58. {:ok, value}
  59. end
  60. end
  61. def process_property("location", [value]) when is_binary(value) do
  62. process_property("location", value)
  63. end
  64. def process_property("category", categories) when is_list(categories) do
  65. Enum.reduce_while(categories |> Enum.reject(&is_nil/1), {:ok, []}, fn category, {:ok, results} ->
  66. case process_property("category", category) do
  67. {:error, _} = error ->
  68. {:halt, error}
  69. {:ok, record} ->
  70. {:cont, {:ok, results ++ [record]}}
  71. end
  72. end)
  73. end
  74. def process_property("category", ""), do: {:ok, nil}
  75. def process_property("category", category) when is_binary(category) do
  76. Koype.Repo.fetch_or_create(Koype.Repo.Category, category, %{name: category})
  77. end
  78. def process_property("category", %Koype.Repo.Category{} = category) when is_binary(category) do
  79. {:ok, category}
  80. end
  81. def process_property("name", name) when is_list(name) do
  82. {:ok, Enum.join(name, " ")}
  83. end
  84. def process_property("content", content) when is_binary(content) do
  85. html = Earmark.as_html!(content, %Earmark.Options{gfm: true, breaks: true, smartypants: true})
  86. plain = HtmlSanitizeEx.strip_tags(html)
  87. Logger.info("Rendered content as #{plain} and #{html}")
  88. {:ok,
  89. %{
  90. "value" => [content],
  91. "html" => html,
  92. "plain" => plain
  93. }}
  94. end
  95. def process_property("content", %{"html" => html} = content) when is_binary(html) do
  96. plain = HtmlSanitizeEx.strip_tags(html)
  97. {:ok,
  98. %{
  99. "value" => Map.get(content, "value", [plain]),
  100. "html" => html,
  101. "plain" => plain
  102. }}
  103. end
  104. def process_property("content", [content]) when is_map(content), do: process_property("content", content)
  105. def process_property("content", content) when is_list(content) do
  106. Logger.info("Merging content from list into a string...")
  107. process_property("content", Enum.join(content, "\n"))
  108. end
  109. def process_property(_name, value), do: {:ok, value}
  110. @doc """
  111. Expand a property's value to be used within Koype.
  112. This does conversion work to provide more relevant information about the
  113. values of a property to Koype. This could involve templating rendering,
  114. uploaded values conversion and more.
  115. """
  116. @spec expand_property(name :: String.t(), value :: list(), model :: any()) :: {:error, any()} | {:ok, any()}
  117. def expand_property(name, value, model)
  118. # TODO: Add custom properties for different media types
  119. def expand_property(name, value, model) when is_list(value) and name in ~w(photo video audio) do
  120. values =
  121. Enum.map(value, fn
  122. data when is_map(data) ->
  123. storage_path = data["path"]
  124. paths =
  125. if Koype.Storage.uri_from_object_store?(URI.parse(storage_path)) do
  126. Koype.Storage.extract_paths_from_uri(storage_path)
  127. else
  128. module = Koype.Storage.module_for_type(name)
  129. module.paths({storage_path, model})
  130. end
  131. %{
  132. "uri" => paths,
  133. "alt" => Map.get(data, "alt", nil)
  134. }
  135. |> Enum.reject(fn {_, v} -> is_nil(v) end)
  136. |> Map.new()
  137. end)
  138. {:ok, values}
  139. end
  140. def expand_property(name, value, model) when is_map(value) and name in ~w(photo video audio) do
  141. Logger.debug(
  142. "The property '#{name}' in #{model.id} should be converted from a single map into a list with one map."
  143. )
  144. expand_property(name, [value], model)
  145. end
  146. def expand_property(name, value, _model) when is_binary(value) and name in ~w(start end)s do
  147. options = [
  148. &Calendar.DateTime.Parse.rfc3339_utc/1,
  149. &Calendar.DateTime.Parse.rfc2822_utc/1,
  150. &Calendar.DateTime.Parse.httpdate/1
  151. ]
  152. Enum.find_value(options, fn option ->
  153. try do
  154. case option.(value) do
  155. %DateTime{} = result -> {:ok, result}
  156. _ -> false
  157. end
  158. rescue
  159. _ -> {:ok, value}
  160. catch
  161. _ -> {:ok, value}
  162. end
  163. end)
  164. end
  165. def expand_property(name, values, _model) when is_list(values) and name in ~w(start end)s,
  166. do: expand_property(name, List.first(values), nil)
  167. def expand_property(_name, value, _model) do
  168. Logger.debug("Thunking on #{inspect(value)}...")
  169. {:ok, value}
  170. end
  171. @doc "Replaces values of properties with normalized ones that are defined for use in Kopye."
  172. @spec process_properties(props :: map()) :: map() | {:error, any()}
  173. def process_properties(props) do
  174. Enum.reduce_while(props, {:ok, props}, fn {key, value}, {:ok, acc} ->
  175. Logger.debug("Processing property #{key} with #{inspect(value)}...")
  176. case process_property(key, value) do
  177. {:error, error} -> {:halt, {:error, error}}
  178. {:ok, nil} -> {:cont, {:ok, Map.drop(acc, [key])}}
  179. {:ok, processed_value} when not is_nil(processed_value) -> {:cont, {:ok, Map.put(acc, key, processed_value)}}
  180. end
  181. end)
  182. end
  183. @doc "Replaces internalized values of properties to system-relevant values."
  184. @spec expand_properties(properties :: map(), model :: any()) :: map() | {:error, any()}
  185. def expand_properties(properties, model) do
  186. Enum.reduce_while(properties, properties, fn {key, value}, acc ->
  187. Logger.debug("Expanding property #{key} with #{inspect(value)} for #{inspect(model)}..")
  188. case expand_property(key, value, model) do
  189. {:error, error} -> {:halt, {:error, error}}
  190. {:ok, expanded_value} -> {:cont, Map.put(acc, key, expanded_value)}
  191. _ -> {:halt, {:error, error: :expansion_issue, key: key}}
  192. end
  193. end)
  194. end
  195. def parse_extensions(model, properties) do
  196. properties
  197. |> IndieWeb.Micropub.reserved()
  198. |> Enum.reduce_while({:ok, model}, fn
  199. {"mp-" <> action, value}, {:ok, new_model} ->
  200. Logger.info("Invoking MicroPub action #{action}...")
  201. case do_parse_extension(action, value, new_model) do
  202. {:ok, updated_model} -> {:cont, {:ok, updated_model}}
  203. {:error, _} = error -> {:halt, error}
  204. end
  205. {property, value}, {:ok, new_model} when property in ~w(post-status published) ->
  206. property_to_column = %{
  207. "published" => :published_at,
  208. "post-status" => :post_status
  209. }
  210. column_to_transform = %{
  211. "published" => fn v -> Calendar.DateTime.Parse.rfc2822_utc(v) end,
  212. "post-status" => fn v -> v end
  213. }
  214. column = property_to_column[property]
  215. Logger.info("Applying expected property #{property} of #{value} to #{model.id}...")
  216. result =
  217. new_model
  218. |> Ecto.Changeset.change(Map.put(%{}, column, column_to_transform[column].(value)))
  219. |> Koype.Repo.update()
  220. case result do
  221. {:error, _} = error -> {:halt, error}
  222. {:ok, updated_model} -> {:cont, {:ok, updated_model}}
  223. end
  224. {property, _}, {:ok, new_model} ->
  225. Logger.info("Skipping over property #{property}...")
  226. {:cont, {:ok, new_model}}
  227. end)
  228. end
  229. defp do_parse_extension(key, value, model)
  230. defp do_parse_extension("slug", "", model) do
  231. model
  232. |> Ecto.Changeset.change(%{slug: nil})
  233. |> Koype.Repo.update()
  234. end
  235. defp do_parse_extension("slug", [slug], model) when is_binary(slug) do
  236. do_parse_extension("slug", slug, model)
  237. end
  238. defp do_parse_extension("slug", slug, model) when is_binary(slug) do
  239. model
  240. |> Ecto.Changeset.change(%{slug: slug})
  241. |> Koype.Repo.update()
  242. end
  243. defp do_parse_extension("syndicate-to", syndication_id, model) when is_binary(syndication_id) do
  244. do_parse_extension("syndicate-to", [syndication_id], model)
  245. end
  246. defp do_parse_extension("syndicate-to", syndication_ids, model) when is_list(syndication_ids) do
  247. Enum.reduce_while(syndication_ids, {:ok, model}, fn id, {:ok, new_model} ->
  248. with(
  249. target when not is_nil(target) <- Koype.Repo.get(Koype.Repo.Syndication.Target, id),
  250. %Koype.Job{} <- IndieWeb.Syndication.syndicate(target, new_model)
  251. ) do
  252. {:cont, {:ok, new_model}}
  253. else
  254. nil -> {:cont, {:ok, new_model}}
  255. {:error, _} = error -> {:halt, error}
  256. end
  257. end)
  258. end
  259. defp do_parse_extension(extension_name, _, model) do
  260. Logger.debug("Encountered unknown Micropub extension #{extension_name}; skipping.")
  261. {:ok, model}
  262. end
  263. defp do_find_module_for_type(type) do
  264. Map.get(
  265. %{
  266. "entry" => IndieWeb.Micropub.Entry
  267. },
  268. type,
  269. nil
  270. )
  271. end
  272. end