Add some rudimentary feed and post features, add decoding of messages

This commit is contained in:
Mikko Ahlroth 2022-06-12 20:17:51 +03:00
parent 85961f6008
commit 74ebea218e
41 changed files with 828 additions and 159 deletions

View file

@ -1,4 +1,5 @@
[
plugins: [Phoenix.LiveView.HTMLFormatter],
import_deps: [:ecto, :phoenix],
inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"],
subdirectories: ["priv/*/migrations"]

View file

@ -1,2 +1,2 @@
erlang 24.2.1
elixir 1.13.2-otp-24
erlang 24.3.3
elixir 1.13.4-otp-24

View file

@ -8,27 +8,33 @@
border: 1px solid transparent;
border-radius: 4px;
}
.alert-info {
color: #31708f;
background-color: #d9edf7;
border-color: #bce8f1;
}
.alert-warning {
color: #8a6d3b;
background-color: #fcf8e3;
border-color: #faebcc;
}
.alert-danger {
color: #a94442;
background-color: #f2dede;
border-color: #ebccd1;
}
.alert p {
margin-bottom: 0;
}
.alert:empty {
display: none;
}
.invalid-feedback {
color: #a94442;
display: block;
@ -46,12 +52,12 @@
transition: opacity 1s ease-out;
}
.phx-loading{
.phx-loading {
cursor: wait;
}
.phx-modal {
opacity: 1!important;
opacity: 1 !important;
position: fixed;
z-index: 1;
left: 0;
@ -59,7 +65,7 @@
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.4);
background-color: rgba(0, 0, 0, 0.4);
}
.phx-modal-content {
@ -95,26 +101,55 @@
.fade-in {
animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys;
}
.fade-out {
animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys;
}
@keyframes fade-in-scale-keys{
0% { scale: 0.95; opacity: 0; }
100% { scale: 1.0; opacity: 1; }
@keyframes fade-in-scale-keys {
0% {
scale: 0.95;
opacity: 0;
}
100% {
scale: 1.0;
opacity: 1;
}
}
@keyframes fade-out-scale-keys{
0% { scale: 1.0; opacity: 1; }
100% { scale: 0.95; opacity: 0; }
@keyframes fade-out-scale-keys {
0% {
scale: 1.0;
opacity: 1;
}
100% {
scale: 0.95;
opacity: 0;
}
}
@keyframes fade-in-keys{
0% { opacity: 0; }
100% { opacity: 1; }
@keyframes fade-in-keys {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fade-out-keys{
0% { opacity: 1; }
100% { opacity: 0; }
@keyframes fade-out-keys {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
textarea#post_text {
min-height: 300px;
}

View file

@ -7,7 +7,10 @@
# General application configuration
import Config
minisome_mime_type = "text/minisome"
config :minisome,
mime_type: minisome_mime_type,
ecto_repos: [Minisome.Storage.Repo]
# Configures the endpoints
@ -37,6 +40,10 @@ config :logger, :console,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]
config :mime, :types, %{
minisome_mime_type => ["minisome"]
}
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason

View file

@ -2,36 +2,9 @@ import Config
# Configure your database
config :minisome, Minisome.Storage.Repo,
database: Path.expand("../minisome_dev.db", Path.dirname(__ENV__.file)),
pool_size: 5,
show_sensitive_data_on_connection_error: true
# For development, we disable any cache and enable
# debugging and code reloading.
#
# The watchers configuration can be used to run external
# watchers to your application. For example, we use it
# with esbuild to bundle .js and .css sources.
config :minisome, Minisome.Web.Endpoint,
# Binding to loopback ipv4 address prevents access from other machines.
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
http: [ip: {127, 0, 0, 1}, port: 4000],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "MR3TEt40ApczckU3+IRzAi91t5iNhbiRt4l0ChL30DdADwAwtdfa++i+NX0ezfc1",
watchers: [
# Start the esbuild watcher by calling Esbuild.install_and_run(:default, args)
esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}
]
config :minisome, Minisome.API.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 33101],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "MR3TEt40ApczckU3+IRzAi91t5iNhbiRt4l0ChL30DdADwAwtdfa++i+NX0ezfc1"
# ## SSL Support
#
# In order to use HTTPS in development, a self-signed

View file

@ -1,76 +1,106 @@
import Config
import Minisome.ConfigHelpers, only: [get_env: 1, get_env: 2, get_env: 3]
# config/runtime.exs is executed for all environments, including
# during releases. It is executed after compilation and before the
# system starts, so it is typically used to load production configuration
# and secrets from environment variables or elsewhere. Do not define
# any compile-time configuration in here, as it won't be applied.
# The block below contains prod specific runtime configuration.
config :minisome,
# How often to fetch feeds of other hosts
update_delay: get_env("UPDATE_DELAY", 10, :int)
# Start the phoenix server if environment is set and running in a release
if System.get_env("PHX_SERVER") && System.get_env("RELEASE_NAME") do
config :minisome, Minisome.Web.Endpoint, server: true
end
if config_env() == :prod do
database_path =
System.get_env("DATABASE_PATH") ||
raise """
environment variable DATABASE_PATH is missing.
For example: /etc/minisome/minisome.db
"""
host = get_env("PHX_HOST", "localhost")
port = get_env("WEB_PORT", 4000, :int)
api_port = get_env("API_PORT", 33101, :int)
config :minisome, Minisome.Storage.Repo,
database: database_path,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5")
cond do
config_env() == :dev ->
config :minisome, Minisome.Storage.Repo,
database:
get_env("DATABASE_PATH", Path.expand("../minisome_dev.db", Path.dirname(__ENV__.file)))
# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
# want to use a different value for prod and you most likely don't want
# to check this value into version control, so we use an environment
# variable instead.
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret
"""
config :minisome, Minisome.Web.Endpoint,
# Binding to loopback ipv4 address prevents access from other machines.
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
http: [ip: {127, 0, 0, 1}, port: port],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "MR3TEt40ApczckU3+IRzAi91t5iNhbiRt4l0ChL30DdADwAwtdfa++i+NX0ezfc1",
watchers: [
# Start the esbuild watcher by calling Esbuild.install_and_run(:default, args)
esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}
]
host = System.get_env("PHX_HOST") || "example.com"
port = String.to_integer(System.get_env("WEB_PORT") || "4000")
api_port = String.to_integer(System.get_env("API_PORT") || "33101")
config :minisome, Minisome.API.Endpoint,
http: [ip: {127, 0, 0, 1}, port: api_port],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "MR3TEt40ApczckU3+IRzAi91t5iNhbiRt4l0ChL30DdADwAwtdfa++i+NX0ezfc1"
config :minisome, Minisome.Web.Endpoint,
url: [host: host, port: 443],
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0},
port: port
],
secret_key_base: secret_key_base
config_env() == :prod ->
database_path =
System.get_env("DATABASE_PATH") ||
raise """
environment variable DATABASE_PATH is missing.
For example: /etc/minisome/minisome.db
"""
config :minisome, Minisome.API.Endpoint,
url: [host: host, port: api_port],
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0},
port: api_port
],
secret_key_base: secret_key_base
config :minisome, Minisome.Storage.Repo,
database: database_path,
pool_size: get_env("POOL_SIZE", 5, :int)
# ## Using releases
#
# If you are doing OTP releases, you need to instruct Phoenix
# to start each relevant endpoint:
#
# config :minisome, Minisome.Web.Endpoint, server: true
#
# Then you can assemble a release by calling `mix release`.
# See `mix help release` for more information.
# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
# want to use a different value for prod and you most likely don't want
# to check this value into version control, so we use an environment
# variable instead.
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret
"""
config :minisome, Minisome.Web.Endpoint,
url: [host: host, port: 443],
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0},
port: port
],
secret_key_base: secret_key_base
config :minisome, Minisome.API.Endpoint,
url: [host: host, port: api_port],
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0},
port: api_port
],
secret_key_base: secret_key_base
# ## Using releases
#
# If you are doing OTP releases, you need to instruct Phoenix
# to start each relevant endpoint:
#
# config :minisome, Minisome.Web.Endpoint, server: true
#
# Then you can assemble a release by calling `mix release`.
# See `mix help release` for more information.
end

View file

@ -25,17 +25,16 @@ defmodule Minisome.API.Client.Message do
@doc """
Parse a binary into a RawMessage.
The binary must have the OpenSSH style signature right at the start, then the base 64 encoded
payload after that.
The binary must have the OpenSSH style signature right at the start, followed by two newlines, then the payload after that.
"""
@spec parse_message(binary()) :: RawMessage.t() | nil
def parse_message(data) do
with {:split, [sig_part, data_part]} <- {:split, :binary.split(data, SSH.openssh_sig_end())},
with {:split, [sig_part, data_part]} <-
{:split, :binary.split(data, "#{SSH.openssh_sig_end()}\n\n")},
sig_data = sig_part <> SSH.openssh_sig_end(),
{:ok, signature} <- SSH.parse_signature(sig_data),
{:ok, parsed_data} <- data_part |> String.trim() |> Base.decode64() do
{:ok, signature} <- SSH.parse_signature(sig_data) do
%RawMessage{
data: parsed_data,
data: data_part,
signature: signature
}
else
@ -46,10 +45,6 @@ defmodule Minisome.API.Client.Message do
{:split, _} ->
Logger.warning("Unable to parse message due to missing footer")
nil
:error ->
Logger.warning("Unable to base64 decode the message data")
nil
end
end
@ -71,10 +66,6 @@ defmodule Minisome.API.Client.Message do
"""
@spec format_message(RawMessage.t()) :: String.t()
def format_message(%RawMessage{} = msg) do
"""
#{SSH.format_signature(msg.signature)}
#{Base.encode64(msg.data)}
"""
"#{SSH.format_signature(msg.signature)}\n\n#{msg.data}"
end
end

View file

@ -29,9 +29,6 @@ defmodule Minisome.API.Client.MessageLibraries.Auth do
@impl GenericLibrary
@spec decode(ProtocolMessage.t()) :: {:ok, any()} | {:error, atom()}
def decode(%ProtocolMessage{} = message) do
case Map.get(@message_mapping, message.type) do
nil -> {:error, :unknown_type}
module -> module.decode(message)
end
GenericLibrary.generic_decode(@message_mapping, message)
end
end

View file

@ -0,0 +1,34 @@
defmodule Minisome.API.Client.MessageLibraries.Feed do
alias Minisome.API.Client.MessageLibraries.GenericLibrary
alias Minisome.API.Client.ProtocolMessage
alias Minisome.API.Client.MessageLibraries.Feed.FeedMessage
@behaviour GenericLibrary
@message_mapping %{
FeedMessage.message_type() => FeedMessage
}
@impl GenericLibrary
@spec library_name() :: String.t()
def library_name(), do: "feed"
@impl GenericLibrary
@spec encoded_version() :: Version.t()
def encoded_version(),
do: %Version{
major: 1,
minor: 0,
patch: 0
}
@impl GenericLibrary
@spec accepted_version() :: Version.t()
def accepted_version(), do: encoded_version()
@impl GenericLibrary
@spec decode(ProtocolMessage.t()) :: {:ok, any()} | {:error, atom()}
def decode(%ProtocolMessage{} = message) do
GenericLibrary.generic_decode(@message_mapping, message)
end
end

View file

@ -0,0 +1,48 @@
defmodule Minisome.API.Client.MessageLibraries.Feed.FeedMessage do
import Minisome.Utils.TypedStruct
alias Minisome.API.Client.MessageLibraries.GenericMessage
@behaviour GenericMessage
deftypedstruct(%{
contents: [Minisome.API.Client.Message.RawMessage.t()]
})
defmodule Decoded do
deftypedstruct(%{
contents: [struct()]
})
end
@impl GenericMessage
@spec message_type() :: String.t()
def message_type(), do: "feed"
@impl GenericMessage
@spec encode(t()) :: {String.t(), map()}
def encode(%__MODULE__{} = message) do
GenericMessage.encode(__MODULE__, %{
"contents" =>
for content <- message.contents do
Minisome.API.Client.Message.format_message(content)
end
})
end
@impl GenericMessage
@spec decode(any()) :: {:ok, t()} | :error
def decode(payload) do
with true <- is_map(payload),
{:ok, contents} when is_list(contents) <- Map.fetch(payload, "contents"),
raw_contents <- Enum.map(contents, &Minisome.API.Client.Message.parse_message/1),
true <- Enum.all?(raw_contents, &(!is_nil(&1))) do
{:ok,
%__MODULE__{
contents: raw_contents
}}
else
_ -> :error
end
end
end

View file

@ -1,14 +1,26 @@
defmodule Minisome.API.Client.MessageLibraries.GenericLibrary do
alias Minisome.API.Client.ProtocolMessage
@type message_mapping() :: %{
optional(Minisome.API.Client.MessageLibraries.GenericMessage.type()) => module()
}
@callback library_name() :: String.t()
@callback encoded_version() :: Version.t()
@callback accepted_version() :: Version.t()
@callback decode(Minisome.API.Client.TypedMessage.t()) :: any()
alias Minisome.API.Client.ProtocolMessage
@callback decode(ProtocolMessage.t()) :: {:ok, any()} | {:error, atom()}
def dispatch(message) do
end
@spec generic_decode(message_mapping(), ProtocolMessage.t()) :: {:ok, any()} | {:error, atom()}
def generic_decode(message_mapping, %ProtocolMessage{} = message) do
case Map.get(message_mapping, message.type) do
nil -> {:error, :unknown_type}
module -> module.decode(message.payload)
end
end
@spec encode(module(), {String.t(), any()}) :: ProtocolMessage.t()
def encode(module, {type, payload}) do
%ProtocolMessage{

View file

@ -1,5 +1,7 @@
defmodule Minisome.API.Client.MessageLibraries.GenericMessage do
@callback message_type() :: String.t()
@type type() :: String.t()
@callback message_type() :: type()
@callback encode(any()) :: {String.t(), any()}
@callback decode(any()) :: {:ok, any()} | :error

View file

@ -0,0 +1,34 @@
defmodule Minisome.API.Client.MessageLibraries.Post do
alias Minisome.API.Client.MessageLibraries.GenericLibrary
alias Minisome.API.Client.ProtocolMessage
alias Minisome.API.Client.MessageLibraries.Post.PostMessage
@behaviour GenericLibrary
@message_mapping %{
PostMessage.message_type() => PostMessage
}
@impl GenericLibrary
@spec library_name() :: String.t()
def library_name(), do: "post"
@impl GenericLibrary
@spec encoded_version() :: Version.t()
def encoded_version(),
do: %Version{
major: 1,
minor: 0,
patch: 0
}
@impl GenericLibrary
@spec accepted_version() :: Version.t()
def accepted_version(), do: encoded_version()
@impl GenericLibrary
@spec decode(ProtocolMessage.t()) :: {:ok, any()} | {:error, atom()}
def decode(%ProtocolMessage{} = message) do
GenericLibrary.generic_decode(@message_mapping, message)
end
end

View file

@ -0,0 +1,54 @@
defmodule Minisome.API.Client.MessageLibraries.Post.PostMessage do
import Minisome.Utils.TypedStruct
alias Minisome.API.Client.MessageLibraries.GenericMessage
@behaviour GenericMessage
deftypedstruct(%{
id: String.t(),
text: String.t(),
tags: [String.t()],
posted: DateTime.t()
})
@impl GenericMessage
@spec message_type() :: String.t()
def message_type(), do: "post"
@impl GenericMessage
@spec encode(t()) :: {String.t(), map()}
def encode(%__MODULE__{} = message) do
GenericMessage.encode(__MODULE__, %{
"id" => message.id,
"text" => message.text,
"tags" => message.tags,
"posted" => message.posted
})
end
@impl GenericMessage
@spec decode(any()) :: {:ok, t()} | :error
def decode(payload) do
with true <- is_map(payload),
{:ok, text} <- Map.fetch(payload, "text"),
{:ok, tags} <- Map.fetch(payload, "tags"),
{:ok, posted} <- Map.fetch(payload, "posted"),
{:ok, id} <- Map.fetch(payload, "id"),
true <- String.valid?(text),
true <- is_struct(posted, DateTime),
true <- is_list(tags),
true <- String.valid?(id),
true <- Enum.all?(tags, &String.valid?/1) do
{:ok,
%__MODULE__{
id: id,
text: text,
tags: tags,
posted: posted
}}
else
_ -> :error
end
end
end

View file

@ -3,14 +3,20 @@ defmodule Minisome.API.Client.ProtocolHandler do
Handler for protocol messages to/from raw messages.
"""
require Logger
import Minisome.Utils.WithHelper
alias Minisome.API.Client.Message
alias Minisome.API.Client.ProtocolMessage
alias Minisome.API.Client.MessageLibraries.Auth
alias Minisome.API.Client.MessageLibraries.Post
alias Minisome.API.Client.MessageLibraries.Feed
@library_mapping %{
Auth.library_name() => Auth
Auth.library_name() => Auth,
Post.library_name() => Post,
Feed.library_name() => Feed
}
@doc """
@ -18,37 +24,52 @@ defmodule Minisome.API.Client.ProtocolHandler do
"""
@spec decode_raw(Message.RawMessage.t()) :: {:ok, ProtocolMessage.t()} | {:error, any()}
def decode_raw(%Message.RawMessage{} = raw) do
with {:ok, data} = op(:unpack, Msgpax.unpack(raw.data)),
%{library: library, type: type, version: version, payload: payload} <-
op(:destructure, data),
with {:ok, data} <- op(:unpack, Msgpax.unpack(raw.data)),
{:destructure,
%{"library" => library, "type" => type, "version" => version, "payload" => payload}} <-
{:destructure, data},
library_module <- op(:library_type, Map.get(@library_mapping, library), :permissive),
{:version, [major_version, minor_version, patch_version]}
when is_integer(major_version) and is_integer(minor_version) and
is_integer(patch_version) <- {:version, version},
true <- op(:type_type, is_binary(type)) do
%ProtocolMessage{
library: library_module,
type: type,
version: %Version{
major: major_version,
minor: minor_version,
patch: patch_version
},
payload: payload
}
{:ok,
%ProtocolMessage{
library: library_module,
type: type,
version: %Version{
major: major_version,
minor: minor_version,
patch: patch_version
},
payload: payload
}}
else
{:unpack, err} -> {:error, {:invalid_msgpack, err}}
{:destructure, _} -> {:error, :invalid_payload}
{:library_type, _} -> {:error, :invalid_library_type}
{:version, _} -> {:error, :invalid_version}
{:type_type, _} -> {:error, :invalid_type_type}
{:unpack, err} ->
Logger.warn("Unable to msgpack decode protocol message: #{inspect(err)}")
{:error, {:invalid_msgpack, err}}
{:destructure, payload} ->
Logger.warn("Invalid protocol message payload: #{inspect(payload)}")
{:error, :invalid_payload}
{:library_type, type} ->
Logger.warn("Invalid protocol message library: #{inspect(type)}")
{:error, :invalid_library_type}
{:version, version} ->
Logger.warn("Invalid protocol message version: #{inspect(version)}")
{:error, :invalid_version}
{:type_type, _} ->
{:error, :invalid_type_type}
end
end
@doc """
Dispatch the protocol message to its library for decoding.
"""
@spec handle_message(ProtocolMessage.t()) :: any()
@spec handle_message(ProtocolMessage.t()) :: {:ok, any()} | {:error, atom()}
def handle_message(%ProtocolMessage{} = message) do
message.library.decode(message)
end

View file

@ -23,7 +23,6 @@ defmodule Minisome.API.AuthController do
data = Minisome.API.Client.Message.format_message(raw)
conn
|> put_resp_content_type("text/minisome")
|> send_resp(200, data)
end
end

View file

@ -0,0 +1,54 @@
defmodule Minisome.API.FeedController do
use Phoenix.Controller, namespace: Minisome.API
import Plug.Conn
alias Minisome.API.Client.MessageLibraries.Post, as: PostLibrary
alias Minisome.API.Client.MessageLibraries.Post.PostMessage
alias Minisome.API.Client.MessageLibraries.Feed, as: FeedLibrary
alias Minisome.API.Client.MessageLibraries.Feed.FeedMessage
alias Minisome.Some.Post.API, as: PostAPI
@spec feed(Plug.Conn.t(), Plug.Conn.params()) :: Plug.Conn.t()
def feed(conn, _params) do
keys = Minisome.Storage.Auth.MyKey.get_active_keys()
posts = PostAPI.all()
post_messages =
for post <- posts do
%PostMessage{
id: post.id,
text: post.text,
tags: post.tags,
posted: post.posted
}
|> form_post_message(keys)
end
message = %FeedMessage{
contents: post_messages
}
payload = FeedMessage.encode(message)
protocol_message =
Minisome.API.Client.MessageLibraries.GenericLibrary.encode(FeedLibrary, payload)
{:ok, packed} = Minisome.API.Client.ProtocolHandler.encode_message(protocol_message)
raw = Minisome.API.Client.Message.encode_message(packed, keys |> List.first() |> elem(0))
data = Minisome.API.Client.Message.format_message(raw)
conn
|> send_resp(200, data)
end
defp form_post_message(post, keys) do
payload = PostMessage.encode(post)
protocol_message =
Minisome.API.Client.MessageLibraries.GenericLibrary.encode(PostLibrary, payload)
{:ok, packed} = Minisome.API.Client.ProtocolHandler.encode_message(protocol_message)
Minisome.API.Client.Message.encode_message(packed, keys |> List.first() |> elem(0))
end
end

View file

@ -0,0 +1,14 @@
defmodule Minisome.API.ContentTypePlug do
@behaviour Plug
@minisome_content_type Application.compile_env!(:minisome, :mime_type)
@impl Plug
def init(opts), do: opts
@impl Plug
@spec call(Plug.Conn.t(), any) :: Plug.Conn.t()
def call(conn, _opts) do
Plug.Conn.put_resp_content_type(conn, @minisome_content_type)
end
end

View file

@ -5,12 +5,15 @@ defmodule Minisome.API.Router do
import Phoenix.Controller
pipeline :api do
plug :accepts, ["text"]
plug :accepts, ["minisome"]
plug Minisome.API.ContentTypePlug
end
scope "/", Minisome.API do
pipe_through :api
get "/key-info", AuthController, :key_info
get "/feed", FeedController, :feed
end
end

View file

@ -8,6 +8,8 @@ defmodule Minisome.Application do
@impl true
def start(_type, _args) do
children = [
{Finch, name: Minisome.Some.HTTP},
# Start the Ecto repository
Minisome.Storage.Repo,
# Start the Telemetry supervisor
@ -16,9 +18,18 @@ defmodule Minisome.Application do
{Phoenix.PubSub, name: Minisome.PubSub},
# Start the Endpoint (http/https)
Minisome.Web.Endpoint,
Minisome.API.Endpoint
Minisome.API.Endpoint,
# Start a worker by calling: Minisome.Worker.start_link(arg)
# {Minisome.Worker, arg}
Supervisor.child_spec({Phoenix.PubSub, name: Minisome.Some.Fetcher.PubSub},
id: Minisome.Some.Fetcher.PubSub
),
{Minisome.Some.Fetcher,
%Minisome.Some.Fetcher.Options{
hosts_getter: &Minisome.Some.Host.API.all/0,
fetch_delay: Application.get_env(:minisome, :update_delay) * 1_000
}}
]
# See https://hexdocs.pm/elixir/Supervisor.html

35
lib/config_helpers.ex Normal file
View file

@ -0,0 +1,35 @@
defmodule Minisome.ConfigHelpers do
@type config_type :: :str | :int | :bool | :json
@doc """
Get value from environment variable, converting it to the given type if needed.
If no default value is given, or `:no_default` is given as the default, an error is raised if the variable is not
set.
"""
@spec get_env(String.t(), :no_default | any(), config_type()) :: any()
def get_env(var, default \\ :no_default, type \\ :str)
def get_env(var, :no_default, type) do
System.fetch_env!(var)
|> get_with_type(type)
end
def get_env(var, default, type) do
with {:ok, val} <- System.fetch_env(var) do
get_with_type(val, type)
else
:error -> default
end
end
@spec get_with_type(String.t(), config_type()) :: any()
defp get_with_type(val, type)
defp get_with_type(val, :str), do: val
defp get_with_type(val, :int), do: String.to_integer(val)
defp get_with_type("true", :bool), do: true
defp get_with_type("false", :bool), do: false
defp get_with_type(val, :json), do: Jason.decode!(val)
defp get_with_type(val, type), do: raise("Cannot convert to #{inspect(type)}: #{inspect(val)}")
end

View file

@ -155,7 +155,7 @@ defmodule Minisome.Crypto.SSH do
"#{@openssh_sig_start}\n" <>
blob_split <>
"#{@openssh_sig_end}\n"
"#{@openssh_sig_end}"
end
@doc """

View file

@ -0,0 +1,86 @@
defmodule Minisome.Some.Fetcher do
import Minisome.Utils.TypedStruct
alias Minisome.Storage.Auth.Host
use GenServer
defmodule Options do
deftypedstruct(%{
name: {GenServer.name(), Minisome.Some.Fetcher},
fetch_delay: pos_integer(),
hosts_getter: (() -> [Host.t()])
})
end
defmodule State do
deftypedstruct(%{
fetch_delay: pos_integer(),
hosts: [Host.t()],
timer_ref: :timer.tref()
})
end
@spec start_link(Options.t()) :: GenServer.on_start()
def start_link(%Options{} = opts) do
GenServer.start_link(__MODULE__, opts, name: opts.name)
end
@impl GenServer
@spec init(Options.t()) :: {:ok, State.t()}
def init(opts) do
{:ok, tref} = :timer.send_after(opts.fetch_delay, self(), :fetch)
hosts = opts.hosts_getter.()
{:ok,
%State{
fetch_delay: opts.fetch_delay,
hosts: hosts,
timer_ref: tref
}}
end
@impl GenServer
@spec handle_info(any(), State.t()) :: {:noreply, State.t()}
def handle_info(msg, state)
def handle_info(:fetch, state) do
feeds =
for host <- state.hosts do
Minisome.Some.HTTP.get(host, "/feed")
end
|> Enum.reject(&(&1 == :error))
|> Enum.map(fn {:ok, msg} -> decode_feed(msg) end)
|> Enum.reject(&(&1 == :error))
Phoenix.PubSub.broadcast!(Minisome.Some.Fetcher.PubSub, "feed", {:feed, feeds})
{:ok, tref} = :timer.send_after(state.fetch_delay, self(), :fetch)
{:noreply, %State{state | timer_ref: tref}}
end
@spec decode_feed(Minisome.API.Client.MessageLibraries.Feed.FeedMessage.t()) ::
Minisome.API.Client.MessageLibraries.Feed.FeedMessage.Decoded.t() | :error
defp decode_feed(feed) do
raw_contents = feed.contents
with protocol_messages <-
Enum.map(raw_contents, &Minisome.API.Client.ProtocolHandler.decode_raw/1),
true <-
Enum.all?(protocol_messages, fn
{:ok, _} -> true
_ -> false
end),
decoded_messages <-
Enum.map(protocol_messages, fn {:ok, msg} ->
Minisome.API.Client.ProtocolHandler.handle_message(msg)
end) do
%Minisome.API.Client.MessageLibraries.Feed.FeedMessage.Decoded{
contents: decoded_messages |> Enum.reject(&(&1 == :error)) |> Enum.map(&elem(&1, 1))
}
else
_ -> :error
end
end
end

6
lib/some/host/api.ex Normal file
View file

@ -0,0 +1,6 @@
defmodule Minisome.Some.Host.API do
@spec all(Ecto.Repo.t()) :: [Minisome.Storage.Auth.Host.t()]
def all(repo \\ Minisome.Storage.Repo) do
repo.all(Minisome.Storage.Auth.Host)
end
end

26
lib/some/http/http.ex Normal file
View file

@ -0,0 +1,26 @@
defmodule Minisome.Some.HTTP do
alias Minisome.Storage.Auth.Host
alias Minisome.API.Client.Message.RawMessage
@spec get(Host.t(), String.t()) :: {:ok, RawMessage.t()} | :error
def get(host, path) do
req = build_req(host, path)
with {:ok, resp} <- Finch.request(req, __MODULE__),
msg when msg != nil <- Minisome.API.Client.Message.parse_message(resp.body),
{:ok, proto_msg} <- Minisome.API.Client.ProtocolHandler.decode_raw(msg),
{:ok, handled_msg} <-
Minisome.API.Client.ProtocolHandler.handle_message(proto_msg) do
{:ok, handled_msg}
else
_ -> :error
end
end
@spec build_req(Host.t(), String.t()) :: Finch.Request.t()
def build_req(host, path) do
url = URI.merge(URI.new!("http://#{host.hostname}:#{host.port}"), %URI{path: path})
Finch.build(:get, url, [{"accept", Application.get_env(:minisome, :mime_type)}])
end
end

37
lib/some/post/api.ex Normal file
View file

@ -0,0 +1,37 @@
defmodule Minisome.Some.Post.API do
alias Ecto.Changeset
alias Minisome.Storage.Repo
alias Minisome.Storage.Some.Post
@spec create_changeset(map()) :: Changeset.t()
def create_changeset(params \\ %{}) do
%Post{}
|> Changeset.cast(params, [:text, :tags])
|> Changeset.validate_required([:text])
|> Changeset.validate_length(:text, min: 1)
|> Changeset.validate_change(:tags, fn
_, val when is_list(val) ->
failed = Enum.reject(val, &String.valid?/1)
if failed != [] do
[tags: "Tags must be strings."]
else
[]
end
_, _val ->
[tags: "Tags need to be a list."]
end)
|> Changeset.put_change(:posted, DateTime.utc_now() |> DateTime.truncate(:second))
end
@spec create(Changeset.t(), Ecto.Repo.t()) :: {:ok, Post.t()} | {:error, Changeset.t()}
def create(changeset, repo \\ Repo) do
repo.insert(changeset)
end
@spec all(Ecto.Repo.t()) :: [Post.t()]
def all(repo \\ Repo) do
repo.all(Post)
end
end

View file

@ -0,0 +1,9 @@
defmodule Minisome.Some.Post.FederatedPost do
import Minisome.Utils.TypedStruct
deftypedstruct(%{
post: Minisome.API.Client.MessageLibraries.Post.PostMessage.t(),
original: Minisome.API.Client.Message.RawMessage.t(),
path: [Minisome.Storage.Auth.Host.t()]
})
end

View file

@ -1,6 +1,5 @@
defmodule Minisome.Storage.Auth.Key do
use Ecto.Schema
import Ecto.Query, only: [from: 2]
import Minisome.Storage.TypedSchema
deftypedschema "keys" do

View file

10
lib/storage/some/post.ex Normal file
View file

@ -0,0 +1,10 @@
defmodule Minisome.Storage.Some.Post do
use Ecto.Schema
import Minisome.Storage.TypedSchema
deftypedschema "posts" do
field(:posted, :utc_datetime, DateTime.t())
field(:text, :string, String.t())
field(:tags, {:array, :string}, [String.t()])
end
end

View file

@ -0,0 +1,35 @@
defmodule Minisome.Web.Components.Post do
import Phoenix.LiveView.Helpers, only: [sigil_H: 2]
@spec my(%{post: Minisome.Storage.Some.Post.t()}) :: Phoenix.LiveView.Rendered.t()
def my(assigns) do
~H"""
<section class="post">
<header>
<p>ID: <%= @post.id %></p>
</header>
<p class="post-body">
<%= @post.text %>
</p>
<footer>
<p>Posted on <%= Calendar.strftime(@post.posted, "%c") %><%= if @post.tags in [nil, []] do "" else " with tags " <> Enum.join(@post.tags, ",") end %>.</p>
</footer>
</section>
"""
end
@spec message(%{post: Minisome.API.Client.MessageLibraries.Post.PostMessage.t()}) ::
Phoenix.LiveView.Rendered.t()
def message(assigns) do
~H"""
<section class="post">
<p class="post-body">
<%= @post.text %>
</p>
<footer>
<p>Posted on <%= Calendar.strftime(@post.posted, "%c") %><%= if @post.tags in [nil, []] do "" else " with tags " <> Enum.join(@post.tags, ",") end %>.</p>
</footer>
</section>
"""
end
end

23
lib/web/live/feed.ex Normal file
View file

@ -0,0 +1,23 @@
defmodule Minisome.Web.Live.Feed do
use Minisome.Web, :live_view
@impl Phoenix.LiveView
def mount(_params, _session, socket) do
:ok = Phoenix.PubSub.subscribe(Minisome.Some.Fetcher.PubSub, "feed")
{:ok, assign(socket, posts: [])}
end
@impl Phoenix.LiveView
def handle_info(msg, socket)
def handle_info({:feed, feeds}, socket) do
posts =
for feed <- feeds, reduce: [] do
acc -> Enum.concat(feed.contents, acc)
end
|> Enum.sort_by(& &1.posted, {:desc, DateTime})
{:noreply, assign(socket, posts: posts)}
end
end

View file

@ -0,0 +1,3 @@
<%= for post <- @posts do %>
<Minisome.Web.Components.Post.message post={post} />
<% end %>

37
lib/web/live/posting.ex Normal file
View file

@ -0,0 +1,37 @@
defmodule Minisome.Web.Live.Posting do
use Minisome.Web, :live_view
alias Minisome.Some.Post.API, as: PostAPI
@impl Phoenix.LiveView
def mount(_params, _session, socket) do
{:ok,
assign(
socket,
cset: PostAPI.create_changeset(),
created: nil
)}
end
@impl Phoenix.LiveView
def handle_event(event, params, socket)
def handle_event("change", params, socket) do
{:noreply, assign(socket, cset: PostAPI.create_changeset(Map.get(params, "post", %{})))}
end
def handle_event("submit", params, socket) do
cset = PostAPI.create_changeset(Map.get(params, "post", %{}))
socket =
case PostAPI.create(cset) do
{:ok, post} ->
assign(socket, created: post)
{:error, error_cset} ->
assign(socket, cset: error_cset)
end
{:noreply, socket}
end
end

View file

@ -0,0 +1,19 @@
<.form let={f} for={@cset} phx-change="change" phx-submit="submit">
<%= label(f, :text, "Post text") %>
<%= textarea(f, :text) %>
<%= error_tag(f, :text) %>
<%= label(f, :tags, "Post tags") %>
<%= textarea(f, :tags) %>
<%= error_tag(f, :tags) %>
<%= submit("Submit") %>
<%= if @created do %>
<hr />
<h4>Created new post</h4>
<Minisome.Web.Components.Post.my post={@created} />
<% end %>
</.form>

View file

@ -1,3 +1,9 @@
defmodule Minisome.Web.Live.PrivateMain do
use Minisome.Web, :live_view
@impl Phoenix.LiveView
def mount(_params, _session, socket) do
posts = Minisome.Some.Post.API.all()
{:ok, assign(socket, posts: posts)}
end
end

View file

@ -1,3 +1,11 @@
Hey!
<%= live_redirect("Key testing", to: Routes.live_path(@socket, Minisome.Web.Live.Signing)) %>
<ul>
<li><%= live_redirect("Feed", to: Routes.live_path(@socket, Minisome.Web.Live.Feed)) %></li>
<li><%= live_redirect("Posting", to: Routes.live_path(@socket, Minisome.Web.Live.Posting)) %></li>
<li><%= live_redirect("Key testing", to: Routes.live_path(@socket, Minisome.Web.Live.Signing)) %></li>
</ul>
<%= for post <- @posts do %>
<Minisome.Web.Components.Post.my post={post} />
<% end %>

View file

@ -10,10 +10,6 @@ defmodule Minisome.Web.Router do
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", Minisome.Web do
pipe_through :browser
@ -24,11 +20,8 @@ defmodule Minisome.Web.Router do
pipe_through :browser
live "/", Live.PrivateMain
live "/feed", Live.Feed
live "/key-testing", Live.Signing
live "/posting", Live.Posting
end
# Other scopes may use custom stacks.
# scope "/api", Minisome.Web do
# pipe_through :api
# end
end

View file

@ -49,7 +49,8 @@ defmodule Minisome.MixProject do
{:jason, "~> 1.2"},
{:plug_cowboy, "~> 2.5"},
{:msgpax, "~> 2.3"},
{:ex_doc, "~> 0.28.0", runtime: false}
{:ex_doc, "~> 0.28.0", runtime: false},
{:finch, "~> 0.12.0"}
]
end

View file

@ -15,16 +15,21 @@
"ex_doc": {:hex, :ex_doc, "0.28.0", "7eaf526dd8c80ae8c04d52ac8801594426ae322b52a6156cd038f30bafa8226f", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "e55cdadf69a5d1f4cfd8477122ebac5e1fadd433a8c1022dafc5025e48db0131"},
"exqlite": {:hex, :exqlite, "0.9.3", "57c80e742584dc4486d717681956d4152c7d03fb34ddbfb269844b504824528d", [:make, :mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "5108f84bcc91fd7ae5b1b247e2be3860e449de5f8383ccaa1454278ffa1fc509"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"finch": {:hex, :finch, "0.12.0", "6bbb3e0bb62dd91cd1217d9682a30f5bfc9b0b74950bf10a0b4d4399c2076892", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "320da3f32459e7dcb77f4271b4f2445ba6c5d32cc3c7cca8e2cff599e24be5a6"},
"floki": {:hex, :floki, "0.32.0", "f915dc15258bc997d49be1f5ef7d3992f8834d6f5695270acad17b41f5bcc8e2", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "1c5a91cae1fd8931c26a4826b5e2372c284813904c8bacb468b5de39c7ececbd"},
"gettext": {:hex, :gettext, "0.19.1", "564953fd21f29358e68b91634799d9d26989f8d039d7512622efb3c3b1c97892", [:mix], [], "hexpm", "10c656c0912b8299adba9b061c06947511e3f109ab0d18b44a866a4498e77222"},
"hpax": {:hex, :hpax, "0.1.1", "2396c313683ada39e98c20a75a82911592b47e5c24391363343bde74f82396ca", [:mix], [], "hexpm", "0ae7d5a0b04a8a60caf7a39fcf3ec476f35cc2cc16c05abea730d3ce6ac6c826"},
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
"jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"},
"makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"},
"makeup_elixir": {:hex, :makeup_elixir, "0.15.2", "dc72dfe17eb240552857465cc00cce390960d9a0c055c4ccd38b70629227e97c", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fd23ae48d09b32eff49d4ced2b43c9f086d402ee4fd4fcb2d7fad97fa8823e75"},
"makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"},
"mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"},
"mint": {:hex, :mint, "1.4.1", "49b3b6ea35a9a38836d2ad745251b01ca9ec062f7cb66f546bf22e6699137126", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "cd261766e61011a9079cccf8fa9d826e7a397c24fbedf0e11b49312bea629b58"},
"msgpax": {:hex, :msgpax, "2.3.0", "14f52ad249a3f77b5e2d59f6143e6c18a6e74f34666989e22bac0a465f9835cc", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "65c36846a62ed5615baf7d7d47babb6541313a6c0b6d2ff19354bd518f52df7e"},
"nimble_options": {:hex, :nimble_options, "0.4.0", "c89babbab52221a24b8d1ff9e7d838be70f0d871be823165c94dd3418eea728f", [:mix], [], "hexpm", "e6701c1af326a11eea9634a3b1c62b475339ace9456c1a23ec3bc9a847bca02d"},
"nimble_parsec": {:hex, :nimble_parsec, "1.2.2", "b99ca56bbce410e9d5ee4f9155a212e942e224e259c7ebbf8f2c86ac21d4fa3c", [:mix], [], "hexpm", "98d51bd64d5f6a2a9c6bb7586ee8129e27dfaab1140b5a4753f24dac0ba27d2f"},
"nimble_pool": {:hex, :nimble_pool, "0.2.6", "91f2f4c357da4c4a0a548286c84a3a28004f68f05609b4534526871a22053cde", [:mix], [], "hexpm", "1c715055095d3f2705c4e236c18b618420a35490da94149ff8b580a2144f653f"},
"phoenix": {:hex, :phoenix, "1.6.6", "281c8ce8dccc9f60607346b72cdfc597c3dde134dd9df28dff08282f0b751754", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "807bd646e64cd9dc83db016199715faba72758e6db1de0707eef0a2da4924364"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"},
"phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"},

View file

@ -0,0 +1,11 @@
defmodule Minisome.Storage.Repo.Migrations.AddPosts do
use Ecto.Migration
def change do
create table(:posts) do
add(:posted, :utc_datetime, null: false)
add(:text, :text, null: false)
add(:tags, {:array, :string}, null: false, default: [])
end
end
end