commit 11836583b16a78dd36572fed97d38da2a9ed4536 Author: Ryan Johnson Date: Mon Mar 19 22:17:02 2018 -0700 Initial commit diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..525446d --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..610d49b --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +micropub_plug-*.tar + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..50fdf75 --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2018, Ryan Johnson +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0683549 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# PlugMicropub + +A small library for helping build a Plug-based Micropub server. + +A basic example server that implements all [Micropub Rocks!][1] validation +tests can be found [here][2]. + +## Usage + +Basic Usage: + +```elixir +plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Poison + +plug PlugMicropub, + handler: MyApp.MicropubHandler, + json_decoder: Poison +``` + +### Forwarding + +If you want `PlugMicropub` to serve only a particular route, configure your router like: + +#### Plug.Router + +```elixir +forward "/micropub", + to: PlugMicropub, + init_opts: [ + handler: MyApp.MicropubHandler, + json_decoder: Poison + ] +``` + +#### Phoenix.Router + +```elixir +forward "/micropub", + PlugMicropub, + handler: MyApp.MicropubHandler, + json_decoder: Poison +``` + +[1]: https://micropub.rocks/ +[2]: https://github.com/bismark/micropub-example diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..d2d855e --- /dev/null +++ b/config/config.exs @@ -0,0 +1 @@ +use Mix.Config diff --git a/lib/plug_micropub.ex b/lib/plug_micropub.ex new file mode 100644 index 0000000..6bb8ce7 --- /dev/null +++ b/lib/plug_micropub.ex @@ -0,0 +1,322 @@ +defmodule 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 + + # 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 = + properties + |> Enum.reject(&match?({"mp-" <> _, _}, &1)) + |> Map.new() + + {: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.reject(&match?({"mp-" <> _, _}, &1)) + |> 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/plug_micropub/handler_behaviour.ex b/lib/plug_micropub/handler_behaviour.ex new file mode 100644 index 0000000..f2059b6 --- /dev/null +++ b/lib/plug_micropub/handler_behaviour.ex @@ -0,0 +1,54 @@ +defmodule 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()} + | {:error, handler_error} + | {:error, handler_error, error_description :: String.t()} + + @callback handle_config_query(access_token) :: + {:ok, map} + | handler_error + + @callback handle_config_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 diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..a2996b7 --- /dev/null +++ b/mix.exs @@ -0,0 +1,36 @@ +defmodule PlugMicropub.MixProject do + use Mix.Project + + def project do + [ + app: :plug_micropub, + version: "0.1.0", + elixir: "~> 1.6", + start_permanent: Mix.env() == :prod, + deps: deps(), + name: "PlugMicropub", + description: "A small library for building a Plug-based Micropub server.", + source_url: "https://github.com/bismark/plug_micropub", + docs: [main: "readme", extras: ["README.md"]], + package: [ + name: "plug_micropub", + licenses: ["BSD 3-Clause"], + maintainers: ["Ryan Johnson"], + links: %{github: "https://github.com/bismark/plug_micropub"} + ] + ] + end + + def application do + [ + extra_applications: [:logger] + ] + end + + defp deps do + [ + {:plug, "~> 1.5"}, + {:ex_doc, "~> 0.18.3", only: :dev, runtime: false} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..9f25dcc --- /dev/null +++ b/mix.lock @@ -0,0 +1,6 @@ +%{ + "earmark": {:hex, :earmark, "1.2.4", "99b637c62a4d65a20a9fb674b8cffb8baa771c04605a80c911c4418c69b75439", [:mix], [], "hexpm"}, + "ex_doc": {:hex, :ex_doc, "0.18.3", "f4b0e4a2ec6f333dccf761838a4b253d75e11f714b85ae271c9ae361367897b7", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, + "mime": {:hex, :mime, "1.2.0", "78adaa84832b3680de06f88f0997e3ead3b451a440d183d688085be2d709b534", [:mix], [], "hexpm"}, + "plug": {:hex, :plug, "1.5.0", "224b25b4039bedc1eac149fb52ed456770b9678bbf0349cdd810460e1e09195b", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.1", [hex: :cowboy, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm"}, +} diff --git a/test/plug_micropub_test.exs b/test/plug_micropub_test.exs new file mode 100644 index 0000000..c44fcdf --- /dev/null +++ b/test/plug_micropub_test.exs @@ -0,0 +1,4 @@ +defmodule PlugMicropubTest do + use ExUnit.Case + doctest PlugMicropub +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()