From b59449de26db01cb1b9b7e568b19b18d1cf2b31f Mon Sep 17 00:00:00 2001 From: Inhji Date: Mon, 21 Aug 2023 19:42:16 +0200 Subject: [PATCH] integrate plug_micropub --- lib/chiya_web/indie/micropub_handler.ex | 2 +- lib/chiya_web/plugs/plug_micropub.ex | 322 ++++++++++++++++++ .../plugs/plug_micropub/handler_behaviour.ex | 65 ++++ lib/chiya_web/router.ex | 2 +- mix.exs | 1 - 5 files changed, 389 insertions(+), 3 deletions(-) create mode 100644 lib/chiya_web/plugs/plug_micropub.ex create mode 100644 lib/chiya_web/plugs/plug_micropub/handler_behaviour.ex diff --git a/lib/chiya_web/indie/micropub_handler.ex b/lib/chiya_web/indie/micropub_handler.ex index 4b6e6fb..8423ccc 100644 --- a/lib/chiya_web/indie/micropub_handler.ex +++ b/lib/chiya_web/indie/micropub_handler.ex @@ -1,5 +1,5 @@ defmodule ChiyaWeb.Indie.MicropubHandler do - @behaviour PlugMicropub.HandlerBehaviour + @behaviour ChiyaWeb.Plugs.PlugMicropub.HandlerBehaviour require Logger use Phoenix.VerifiedRoutes, diff --git a/lib/chiya_web/plugs/plug_micropub.ex b/lib/chiya_web/plugs/plug_micropub.ex new file mode 100644 index 0000000..c802e1b --- /dev/null +++ b/lib/chiya_web/plugs/plug_micropub.ex @@ -0,0 +1,322 @@ +defmodule ChiyaWeb.Plugs.PlugMicropub do + @moduledoc """ + A Plug for building a Micropub server. + + To use: + + """ + use Plug.Router + + plug :match + plug :dispatch + + # Plug Callbacks + + @doc false + def init(opts) do + handler = + Keyword.get(opts, :handler) || raise ArgumentError, "Micropub Plug requires :handler option" + + json_encoder = + Keyword.get(opts, :json_encoder) || + raise ArgumentError, "Micropub Plug requires :json_encoder option" + + [handler: handler, json_encoder: json_encoder] + end + + @doc false + def call(conn, opts) do + conn = put_private(conn, :plug_micropub, opts) + super(conn, opts) + end + + # Routes + + post "/" do + with {:ok, access_token, conn} <- get_access_token(conn), + {:ok, action, conn} <- get_action(conn) do + handle_action(action, access_token, conn) + else + error -> send_error(conn, error) + end + end + + get "/" do + with {:ok, access_token, conn} <- get_access_token(conn), + {:ok, query} <- get_query(conn) do + handle_query(query, access_token, conn) + else + error -> send_error(conn, error) + end + end + + post "/media" do + handler = conn.private[:plug_micropub][:handler] + + with {:ok, access_token, conn} <- get_access_token(conn), + {:ok, file} <- get_file(conn), + {:ok, url} <- handler.handle_media(file, access_token) do + conn + |> put_resp_header("location", url) + |> send_resp(:created, "") + else + error -> send_error(conn, error) + end + end + + match _ do + send_error(conn, {:error, :invalid_request}) + end + + # Internal Functions + + defp send_content(conn, content) do + json_encoder = conn.private[:plug_micropub][:json_encoder] + body = json_encoder.encode!(content) + + conn + |> put_resp_content_type("application/json") + |> send_resp(:ok, body) + end + + defp send_error(conn, {:error, error}) do + body = %{error: error} + _send_error(conn, body) + end + + defp send_error(conn, {:error, error, description}) do + body = %{error: error, error_description: description} + _send_error(conn, body) + end + + defp _send_error(conn, body) do + json_encoder = conn.private[:plug_micropub][:json_encoder] + + code = get_error_code(body.error) + body = json_encoder.encode!(body) + + conn + |> put_resp_content_type("application/json") + |> send_resp(code, body) + end + + defp get_error_code(:insufficient_scope), do: :unauthorized + defp get_error_code(:invalid_request), do: :bad_request + defp get_error_code(code), do: code + + defp get_action(conn) do + {action, body_params} = Map.pop(conn.body_params, "action") + conn = %Plug.Conn{conn | body_params: body_params} + + case action do + nil -> + {:ok, :create, conn} + + action when action in ["delete", "undelete", "update"] -> + {:ok, String.to_existing_atom(action), conn} + + _ -> + {:error, :invalid_request} + end + end + + defp get_query(conn) do + case Map.fetch(conn.query_params, "q") do + {:ok, query} when query in ["config", "source", "syndicate-to"] -> + {:ok, String.to_existing_atom(query)} + + _ -> + {:error, :invalid_request} + end + end + + defp get_file(conn) do + case Map.fetch(conn.body_params, "file") do + {:ok, file} -> {:ok, file} + :error -> {:error, :invalid_request} + end + end + + defp get_access_token(conn) do + {access_token, body_params} = Map.pop(conn.body_params, "access_token") + conn = %Plug.Conn{conn | body_params: body_params} + + case access_token do + nil -> parse_auth_header(conn) + access_token -> {:ok, access_token, conn} + end + end + + defp parse_auth_header(conn) do + with [header] <- get_req_header(conn, "authorization"), + _ = IO.inspect(header), + "Bearer" <> token <- header, + do: {:ok, String.trim(token), conn}, + else: (_ -> {:error, :unauthorized}) + end + + defp handle_action(:create, access_token, conn) do + content_type = conn |> get_req_header("content-type") |> List.first() + handler = conn.private[:plug_micropub][:handler] + + with {:ok, type, properties} <- parse_create_body(content_type, conn.body_params), + {:ok, code, url} <- handler.handle_create(type, properties, access_token) do + conn + |> put_resp_header("location", url) + |> send_resp(code, "") + else + error -> send_error(conn, error) + end + end + + defp handle_action(:update, access_token, conn) do + content_type = conn |> get_req_header("content-type") |> List.first() + + with "application/json" <- content_type, + {url, properties} when is_binary(url) <- Map.pop(conn.body_params, "url"), + {:ok, replace, add, delete} <- parse_update_properties(properties), + do: do_update(conn, access_token, url, replace, add, delete), + else: (_ -> send_error(conn, {:error, :invalid_request})) + end + + defp handle_action(:delete, access_token, conn) do + with {:ok, url} <- Map.fetch(conn.body_params, "url"), + do: do_delete(conn, access_token, url), + else: (_ -> send_error(conn, {:error, :invalid_request})) + end + + defp handle_action(:undelete, access_token, conn) do + with {:ok, url} <- Map.fetch(conn.body_params, "url"), + do: do_undelete(conn, access_token, url), + else: (_ -> send_error(conn, {:error, :invalid_request})) + end + + defp handle_query(:config, access_token, conn) do + handler = conn.private[:plug_micropub][:handler] + + case handler.handle_config_query(access_token) do + {:ok, content} -> send_content(conn, content) + error -> send_error(conn, error) + end + end + + defp handle_query(:source, access_token, conn) do + with {:ok, url} <- Map.fetch(conn.query_params, "url"), + do: do_source_query(conn, access_token, url), + else: (_ -> send_error(conn, {:error, :invalid_request})) + end + + defp handle_query(:"syndicate-to", access_token, conn) do + handler = conn.private[:plug_micropub][:handler] + + case handler.handle_syndicate_to_query(access_token) do + {:ok, content} -> send_content(conn, content) + error -> send_error(conn, error) + end + end + + defp parse_update_properties(properties) do + properties = Map.take(properties, ["replace", "add", "delete"]) + + valid? = + Enum.all?(properties, fn + {"delete", prop} when is_list(prop) -> + Enum.all?(prop, &is_binary/1) + + {_k, prop} when is_map(prop) -> + Enum.all?(prop, fn + {_k, v} when is_list(v) -> true + _ -> false + end) + + _ -> + false + end) + + if valid? do + replace = Map.get(properties, "replace", %{}) + add = Map.get(properties, "add", %{}) + delete = Map.get(properties, "delete", %{}) + {:ok, replace, add, delete} + else + :error + end + end + + defp do_update(conn, access_token, url, replace, add, delete) do + handler = conn.private[:plug_micropub][:handler] + + case handler.handle_update(url, replace, add, delete, access_token) do + :ok -> + send_resp(conn, :no_content, "") + + {:ok, url} -> + conn + |> put_resp_header("location", url) + |> send_resp(:created, "") + + error -> + send_error(conn, error) + end + end + + defp do_delete(conn, access_token, url) do + handler = conn.private[:plug_micropub][:handler] + + case handler.handle_delete(url, access_token) do + :ok -> send_resp(conn, :no_content, "") + error -> send_error(conn, error) + end + end + + defp do_undelete(conn, access_token, url) do + handler = conn.private[:plug_micropub][:handler] + + case handler.handle_undelete(url, access_token) do + :ok -> + send_resp(conn, :no_content, "") + + {:ok, url} -> + conn + |> put_resp_header("location", url) + |> send_resp(:created, "") + + error -> + send_error(conn, error) + end + end + + defp do_source_query(conn, access_token, url) do + handler = conn.private[:plug_micropub][:handler] + properties = Map.get(conn.query_params, "properties", []) + + case handler.handle_source_query(url, properties, access_token) do + {:ok, content} -> send_content(conn, content) + error -> send_error(conn, error) + end + end + + defp parse_create_body("application/json", params) do + with {:ok, ["h-" <> type]} <- Map.fetch(params, "type"), + {:ok, properties} when is_map(properties) <- Map.fetch(params, "properties") do + properties = Map.new(properties) + + {:ok, type, properties} + else + _ -> {:error, :invalid_request} + end + end + + defp parse_create_body(_, params) do + with {type, params} when is_binary(type) <- Map.pop(params, "h") do + properties = + params + |> Enum.map(fn {k, v} -> {k, List.wrap(v)} end) + |> Map.new() + + {:ok, type, properties} + else + _ -> {:error, :invalid_request} + end + end +end diff --git a/lib/chiya_web/plugs/plug_micropub/handler_behaviour.ex b/lib/chiya_web/plugs/plug_micropub/handler_behaviour.ex new file mode 100644 index 0000000..0530021 --- /dev/null +++ b/lib/chiya_web/plugs/plug_micropub/handler_behaviour.ex @@ -0,0 +1,65 @@ +defmodule ChiyaWeb.Plugs.PlugMicropub.HandlerBehaviour do + @moduledoc """ + Behaviour defining the interface for a PlugMicropub Handler + """ + + @type access_token :: String.t() + @type handler_error_atom :: :invalid_request | :forbidden | :insufficient_scope + @type handler_error :: + {:error, handler_error_atom} | {:error, handler_error_atom, description :: String.t()} + + @callback handle_create(type :: String.t(), properties :: map, access_token) :: + {:ok, :created | :accepted, url :: String.t()} + | handler_error + + @callback handle_update( + url :: String.t(), + replace :: map, + add :: map, + delete :: map, + access_token + ) :: + :ok + | {:ok, url :: String.t()} + | handler_error + + @callback handle_delete(url :: String.t(), access_token) :: + :ok + | handler_error + + @callback handle_undelete(url :: String.t(), access_token) :: + :ok + | {:ok, url :: String.t()} + | handler_error + + @callback handle_config_query(access_token) :: + {:ok, map} + | handler_error + + @callback handle_config_query(access_token) :: + {:ok, map} + | handler_error + + @callback handle_channel_query(access_token) :: + {:ok, map} + | handler_error + + @callback handle_category_query(access_token) :: + {:ok, map} + | handler_error + + @callback handle_syndicate_to_query(access_token) :: + {:ok, map} + | handler_error + + @callback handle_source_query( + url :: String.t(), + properties :: [String.t()], + access_token + ) :: + {:ok, map} + | handler_error + + @callback handle_media(file :: Plug.Upload.t(), access_token) :: + {:ok, url :: String.t()} | handler_error +end \ No newline at end of file diff --git a/lib/chiya_web/router.ex b/lib/chiya_web/router.ex index d99a44d..77e771b 100644 --- a/lib/chiya_web/router.ex +++ b/lib/chiya_web/router.ex @@ -40,7 +40,7 @@ defmodule ChiyaWeb.Router do ## Indie routes scope "/indie" do forward "/micropub", - PlugMicropub, + ChiyaWeb.Plugs.PlugMicropub, handler: ChiyaWeb.Indie.MicropubHandler, json_encoder: Jason end diff --git a/mix.exs b/mix.exs index 64f72f4..b2da613 100644 --- a/mix.exs +++ b/mix.exs @@ -50,7 +50,6 @@ defmodule Chiya.MixProject do {:phoenix_live_reload, "~> 1.2", only: :dev}, {:phoenix_live_view, "~> 0.19"}, {:plug_cowboy, "~> 2.5"}, - {:plug_micropub, git: "https://git.inhji.de/inhji/plug_micropub.git", branch: "chiya"}, {:postgrex, ">= 0.0.0"}, {:swoosh, "~> 1.11"}, {:tailwind, "~> 0.2.0", runtime: Mix.env() == :dev}, -- 2.39.5