Packages

A reusable static blog engine for Elixir. Generates a complete website from markdown with syntax highlighting, RSS, sitemap, SEO structured data, MarsEdit integration via Micropub/XML-RPC, and Cloudflare R2 publishing.

Current section

Files

Jump to
static_blog lib static_blog web xml_rpc.ex
Raw

lib/static_blog/web/xml_rpc.ex

defmodule StaticBlog.Web.XmlRpc do
@moduledoc """
XML-RPC shim that lets MarsEdit list and fetch posts from this blog.
Implements just enough of the micro.blog / MetaWeblog / WordPress XML-RPC
vocabulary for MarsEdit to function.
"""
require Logger
alias StaticBlog.RuntimePosts
@type fault :: {:fault, integer(), String.t()}
@doc """
Handle a single XML-RPC method call.
### Arguments
* `method_name` is the XML-RPC method name.
* `params` is the decoded parameter list from `XMLRPC.MethodCall`.
### Returns
* `{:ok, value}` where `value` is the Elixir term to encode as the
`<methodResponse>` payload.
* `{:fault, code, message}` for method-not-found and other protocol errors.
"""
@spec dispatch(String.t(), list()) :: {:ok, term()} | fault()
def dispatch(method, params) do
Logger.info("XML-RPC #{method} params=#{inspect(params)}")
do_dispatch(method, params)
end
defp do_dispatch("blogger.getUsersBlogs", [_appkey, _username, _password]) do
site = StaticBlog.site_config()
blog = %{
"blogid" => site[:base_url],
"blogName" => site[:title],
"url" => site[:base_url]
}
{:ok, [blog]}
end
defp do_dispatch("microblog.getPosts", params) do
count = count_from(params, 3)
{:ok, all_posts_as_structs(count)}
end
defp do_dispatch("metaWeblog.getRecentPosts", [_blog_id, _username, _password, count])
when is_integer(count) do
{:ok, all_posts_as_structs(count)}
end
defp do_dispatch("metaWeblog.getRecentPosts", [_blog_id, _username, _password]) do
{:ok, all_posts_as_structs(50)}
end
defp do_dispatch("metaWeblog.getPost", [post_id, _username, _password]) do
case lookup_post(post_id) do
nil -> {:fault, 404, "Post not found"}
post -> {:ok, post_to_struct(post)}
end
end
defp do_dispatch("microblog.getPost", [post_id, _username, _password]) do
do_dispatch("metaWeblog.getPost", [post_id, nil, nil])
end
defp do_dispatch("microblog.getPost", [post_id, _username, _password, _extra]) do
do_dispatch("metaWeblog.getPost", [post_id, nil, nil])
end
defp do_dispatch(
"metaWeblog.editPost",
[post_id, _username, _password, %{} = struct, publish]
) do
edit_post(post_id, struct, publish)
end
defp do_dispatch("metaWeblog.editPost", [post_id, _username, _password, %{} = struct]) do
edit_post(post_id, struct, nil)
end
defp do_dispatch(
"microblog.editPost",
[post_id, _username, _password, %{} = struct, publish]
) do
edit_post(post_id, struct, publish)
end
defp do_dispatch("microblog.editPost", [post_id, _username, _password, %{} = struct]) do
edit_post(post_id, struct, nil)
end
defp do_dispatch(
"microblog.newPost",
[_blog_id, _username, _password, %{} = struct, _publish]
) do
do_dispatch("metaWeblog.newPost", [nil, nil, nil, struct, true])
end
defp do_dispatch("microblog.deletePost", [_appkey, post_id, _username, _password, _publish]) do
do_dispatch("blogger.deletePost", [nil, post_id, nil, nil, true])
end
defp do_dispatch("blogger.deletePost", [_appkey, post_id, _username, _password, _publish]) do
case lookup_post(post_id) do
nil ->
{:fault, 404, "Post not found"}
post ->
case StaticBlog.Micropub.delete(post_url(post)) do
:ok ->
after_write!()
{:ok, true}
{:error, reason} ->
{:fault, 500, "Delete failed: #{inspect(reason)}"}
end
end
end
defp do_dispatch(
"metaWeblog.newPost",
[_blog_id, _username, _password, %{} = struct, publish]
) do
status =
case status_from_struct(struct, publish) do
nil -> :published
"draft" -> :draft
"published" -> :published
end
properties =
struct_to_properties(struct)
|> Map.put_new(:date, Date.utc_today())
|> Map.put(:status, status)
case StaticBlog.Micropub.create(properties) do
{:ok, url} ->
after_write!()
case StaticBlog.Micropub.slug_from_url(url) do
{:ok, slug} -> {:ok, :erlang.phash2(slug)}
_ -> {:ok, url}
end
{:error, reason} ->
{:fault, 400, "Create failed: #{inspect(reason)}"}
end
end
defp do_dispatch("microblog.getPages", _params), do: {:ok, []}
defp do_dispatch("wp.getPages", _params), do: {:ok, []}
defp do_dispatch("wp.getPageList", _params), do: {:ok, []}
defp do_dispatch("metaWeblog.getCategories", [_blog_id, _username, _password]) do
tags =
RuntimePosts.all()
|> Enum.flat_map(& &1.tags)
|> Enum.uniq()
|> Enum.sort()
{:ok,
Enum.map(tags, fn tag ->
%{
"categoryId" => tag,
"categoryName" => tag,
"description" => tag,
"htmlUrl" => "",
"rssUrl" => ""
}
end)}
end
defp do_dispatch("wp.newCategory", [_blog_id, _username, _password, %{} = category]) do
name = category["name"] || category["categoryName"] || ""
if name == "" do
{:fault, 400, "Blank category names are not supported"}
else
{:ok, name}
end
end
defp do_dispatch("wp.newCategory", [_blog_id, _username, _password, name])
when is_binary(name) do
if name == "" do
{:fault, 400, "Blank category names are not supported"}
else
{:ok, name}
end
end
defp do_dispatch(method, _params) do
{:fault, -32601, "Method #{method} is not supported"}
end
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
defp lookup_post(post_id) when is_integer(post_id) do
Enum.find(RuntimePosts.all(), fn post ->
:erlang.phash2(post.slug) == post_id
end)
end
defp lookup_post(post_id) when is_binary(post_id) do
case RuntimePosts.by_slug(post_id) do
%_{} = post ->
post
_ ->
case Integer.parse(post_id) do
{integer, ""} -> lookup_post(integer)
_ -> nil
end
end
end
defp lookup_post(_), do: nil
defp count_from(params, index) do
case Enum.at(params, index) do
count when is_integer(count) and count > 0 -> count
_ -> 50
end
end
defp all_posts_as_structs(count) do
RuntimePosts.all()
|> Enum.take(count)
|> Enum.map(&post_to_struct/1)
end
defp post_to_struct(post) do
url = post_url(post)
created = datetime_to_xmlrpc(post.published_at)
modified = datetime_to_xmlrpc(post.updated_at)
site = StaticBlog.site_config()
author = post.author || site[:author] || ""
id_integer = :erlang.phash2(post.slug)
id_string = Integer.to_string(id_integer)
description = StaticBlog.RuntimePosts.markdown_body(post.slug) || post.body
%{
"postid" => id_string,
"post_id" => id_string,
"id" => id_string,
"userid" => "1",
"title" => post.title,
"description" => description,
"dateCreated" => created,
"date_created_gmt" => created,
"pubDate" => created,
"dateModified" => modified,
"date_modified_gmt" => modified,
"post_date" => created,
"post_date_gmt" => created,
"post_modified" => modified,
"post_modified_gmt" => modified,
"categories" => post.tags,
"link" => url,
"permaLink" => url,
"post_status" => wp_status(post.status),
"post_type" => "post",
"wp_post_format" => "standard",
"wp_slug" => post.slug,
"wp_author_id" => "1",
"wp_author_display_name" => author,
"wp_password" => "",
"sticky" => 0,
"mt_allow_comments" => 0,
"mt_allow_pings" => 0,
"mt_keywords" => Enum.join(post.tags, ","),
"mt_excerpt" => post.summary,
"mt_text_more" => "",
"custom_fields" => []
}
end
defp post_url(post) do
site = StaticBlog.site_config()
base = String.trim_trailing(site[:base_url], "/")
"#{base}/posts/#{post.slug}/"
end
defp wp_status(:published), do: "publish"
defp wp_status(:draft), do: "draft"
defp wp_status(_), do: "publish"
defp status_from_wp("draft"), do: :draft
defp status_from_wp("publish"), do: :published
defp status_from_wp("published"), do: :published
defp status_from_wp(_), do: nil
defp edit_post(post_id, %{} = struct, publish) do
case lookup_post(post_id) do
nil ->
{:fault, 404, "Post not found"}
post ->
replace = struct_to_replace_map(struct, publish)
case StaticBlog.Micropub.update(post_url(post), replace, %{}, %{}) do
{:ok, _url} ->
after_write!()
{:ok, true}
{:error, reason} ->
{:fault, 500, "Update failed: #{inspect(reason)}"}
end
end
end
defp struct_to_replace_map(struct, publish) do
%{}
|> put_replace("name", struct["title"])
|> put_replace("content", struct["description"])
|> put_replace("category", tags_from_struct(struct))
|> put_replace("summary", struct["mt_excerpt"])
|> put_replace("post-status", status_from_struct(struct, publish))
|> put_replace("published", datetime_from_struct(struct))
end
defp tags_from_struct(struct) do
from_categories =
case struct["categories"] do
list when is_list(list) -> list
_ -> []
end
from_keywords =
case struct["mt_keywords"] do
nil ->
[]
"" ->
[]
string when is_binary(string) ->
string
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
_ ->
[]
end
case Enum.uniq(from_categories ++ from_keywords) do
[] -> nil
tags -> tags
end
end
defp datetime_from_struct(struct) do
case struct["dateCreated"] || struct["date_created_gmt"] do
%XMLRPC.DateTime{raw: raw} -> raw
string when is_binary(string) -> string
_ -> nil
end
end
defp status_from_struct(struct, publish) do
cond do
is_binary(struct["post_status"]) ->
status_from_wp(struct["post_status"]) |> to_micropub_status()
publish == false ->
"draft"
publish == true ->
"published"
true ->
nil
end
end
defp to_micropub_status(:draft), do: "draft"
defp to_micropub_status(:published), do: "published"
defp to_micropub_status(_), do: nil
defp put_replace(map, _key, nil), do: map
defp put_replace(map, _key, ""), do: map
defp put_replace(map, key, value) when is_list(value), do: Map.put(map, key, value)
defp put_replace(map, key, value), do: Map.put(map, key, [value])
defp struct_to_properties(struct) do
%{}
|> maybe_put(:title, struct["title"])
|> maybe_put(:content, struct["description"])
|> maybe_put(:tags, struct["categories"])
|> maybe_put(:slug, struct["wp_slug"])
|> maybe_put(:description, struct["mt_excerpt"])
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, _key, ""), do: map
defp maybe_put(map, _key, []), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp after_write! do
posts = RuntimePosts.all()
output = Path.expand("_site")
{:ok, _} = StaticBlog.Generator.build(output, posts)
:ok
end
defp datetime_to_xmlrpc(%DateTime{} = datetime) do
utc = DateTime.shift_zone!(datetime, "Etc/UTC")
%XMLRPC.DateTime{raw: format_datetime(utc)}
end
defp datetime_to_xmlrpc(_), do: nil
defp format_datetime(%DateTime{} = dt) do
:io_lib.format(
"~4..0B~2..0B~2..0BT~2..0B:~2..0B:~2..0B",
[dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second]
)
|> IO.iodata_to_binary()
end
end