Compare commits
11 Commits
f3995911d7
...
a12ba25f18
Author | SHA1 | Date |
---|---|---|
Shadowfacts | a12ba25f18 | |
Shadowfacts | 13d7f832fe | |
Shadowfacts | 074e208174 | |
Shadowfacts | 81712fb1bf | |
Shadowfacts | 7c03d2627f | |
Shadowfacts | 091a275b22 | |
Shadowfacts | 9e39d56325 | |
Shadowfacts | 2316c5d41d | |
Shadowfacts | 0d6fd68fb3 | |
Shadowfacts | d652b71426 | |
Shadowfacts | 1b8f3e212c |
|
@ -29,6 +29,8 @@ config :mime, :types, %{
|
|||
"application/activity+json" => ["activity+json"]
|
||||
}
|
||||
|
||||
config :http_signatures, adapter: Clacks.SignatureAdapter
|
||||
|
||||
# Import environment specific config. This must remain at the bottom
|
||||
# of this file so it overrides the configuration defined above.
|
||||
import_config "#{Mix.env()}.exs"
|
||||
|
|
|
@ -95,6 +95,17 @@ defmodule Clacks.ActivityPub do
|
|||
}
|
||||
end
|
||||
|
||||
@spec accept_follow(follow_activity :: map()) :: map()
|
||||
def accept_follow(%{"type" => "Follow", "object" => followed} = follow_activity) do
|
||||
%{
|
||||
"@context" => @context,
|
||||
"type" => "Accept",
|
||||
"id" => activity_id(Ecto.UUID.generate()),
|
||||
"actor" => followed,
|
||||
"object" => follow_activity
|
||||
}
|
||||
end
|
||||
|
||||
@spec object_id(id :: String.t()) :: String.t()
|
||||
def object_id(id) do
|
||||
url = Application.get_env(:clacks, ClacksWeb.Endpoint)[:url]
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
defmodule Clacks.ActivityPub.Federator do
|
||||
require Logger
|
||||
alias Clacks.{Repo, Actor, User, Keys}
|
||||
import Ecto.Query
|
||||
|
||||
@public "https://www.w3.org/ns/activitystreams#Public"
|
||||
|
||||
@spec federate_to_followers(activity :: map(), actor :: Actor.t()) :: :ok | {:error, any()}
|
||||
def federate_to_followers(activity, actor) do
|
||||
Repo.all(
|
||||
from a in Actor, where: fragment("?->>'id'", a.data) in ^actor.followers, select: a.data
|
||||
)
|
||||
|> Enum.map(&inbox_for(activity, &1))
|
||||
|> Enum.uniq()
|
||||
|> Enum.reduce_while(:ok, fn inbox, _acc ->
|
||||
case federate(activity, inbox) do
|
||||
{:error, _} = err ->
|
||||
{:halt, err}
|
||||
|
||||
_ ->
|
||||
{:cont, :ok}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@spec federate(activity :: map(), inbox :: String.t()) :: :ok | {:error, any()}
|
||||
def federate(%{"actor" => actor_id} = activity, inbox) do
|
||||
Logger.info("Federating #{activity["id"]} to #{inbox}")
|
||||
%{host: inbox_host, path: inbox_path} = URI.parse(inbox)
|
||||
|
||||
{:ok, body} = Jason.encode(activity)
|
||||
digest = "SHA-256=" <> Base.encode64(:crypto.hash(:sha256, body))
|
||||
date = signature_timestamp()
|
||||
|
||||
signature_params = %{
|
||||
"(request-target)": "post #{inbox_path}",
|
||||
host: inbox_host,
|
||||
"content-length": byte_size(body),
|
||||
digest: digest
|
||||
}
|
||||
|
||||
{private_key, key_id} = private_key_for_actor(actor_id)
|
||||
signature_string = HTTPSignatures.sign(private_key, key_id, signature_params)
|
||||
|
||||
headers = [
|
||||
{"Content-Type", "application/activity+json"},
|
||||
{"Date", date},
|
||||
{"Signature", signature_string},
|
||||
{"Digest", digest}
|
||||
]
|
||||
|
||||
opts = [hackney: Application.get_env(:clacks, :hackney_opts, [])]
|
||||
|
||||
case HTTPoison.post(inbox, body, headers, opts) do
|
||||
{:ok, %HTTPoison.Response{status_code: status_code}} when status_code in 200..299 ->
|
||||
:ok
|
||||
|
||||
{:error, _} = err ->
|
||||
err
|
||||
end
|
||||
end
|
||||
|
||||
# see https://www.w3.org/TR/activitypub/#shared-inbox-delivery
|
||||
@spec inbox_for(activity :: map(), actor :: map()) :: String.t()
|
||||
defp inbox_for(activity, actor) do
|
||||
cond do
|
||||
@public in activity["to"] or @public in activity["cc"] ->
|
||||
shared_inbox_for(actor)
|
||||
|
||||
actor.data["followers"] in activity["to"] or actor.data["followers"] in activity["cc"] ->
|
||||
shared_inbox_for(actor)
|
||||
|
||||
true ->
|
||||
actor.data["inbox"]
|
||||
end
|
||||
end
|
||||
|
||||
@spec shared_inbox_for(actor :: map()) :: String.t()
|
||||
defp shared_inbox_for(%{"endpoints" => %{"sharedInbox" => shared}}), do: shared
|
||||
defp shared_inbox_for(%{"inbox" => inbox}), do: inbox
|
||||
|
||||
@spec signature_timestamp() :: String.t()
|
||||
defp signature_timestamp(date \\ NaiveDateTime.utc_now()) do
|
||||
Timex.format!(date, "{WDshort}, {0D} {Mshort} {YYYY} {h24}:{m}:{s} GMT")
|
||||
end
|
||||
|
||||
defp private_key_for_actor(ap_id) do
|
||||
%Actor{user: %User{private_key: pem}} = Actor.get_by_ap_id(ap_id) |> Repo.preload(:user)
|
||||
{:ok, private_key, _} = Keys.keys_from_private_key_pem(pem)
|
||||
{private_key, ap_id <> "#main-key"}
|
||||
end
|
||||
end
|
|
@ -27,9 +27,9 @@ defmodule Clacks.Actor do
|
|||
|> validate_required([:ap_id, :nickname, :local, :data])
|
||||
end
|
||||
|
||||
@spec get_by_nickanme(nickname :: String.t()) :: t() | nil
|
||||
def get_by_nickanme(nickname) do
|
||||
Repo.one(from a in __MODULE__, where: a.nickname == ^nickname)
|
||||
@spec get_by_nickname(nickname :: String.t()) :: t() | nil
|
||||
def get_by_nickname(nickname) do
|
||||
Repo.get_by(__MODULE__, nickname: nickname)
|
||||
end
|
||||
|
||||
@spec get_by_ap_id(ap_id :: String.t(), force_refetch :: boolean()) :: t() | nil
|
||||
|
@ -43,7 +43,7 @@ defmodule Clacks.Actor do
|
|||
|
||||
@spec get_cached_by_ap_id(ap_id :: String.t()) :: t() | nil
|
||||
def get_cached_by_ap_id(ap_id) do
|
||||
Repo.one(from a in __MODULE__, where: a.ap_id == ^ap_id)
|
||||
Repo.get_by(__MODULE__, ap_id: ap_id)
|
||||
end
|
||||
|
||||
@spec fetch(ap_id :: String.t()) :: t() | nil
|
||||
|
@ -74,4 +74,9 @@ defmodule Clacks.Actor do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
@spec get_all_following(actor :: t()) :: [t()]
|
||||
def get_all_following(actor) do
|
||||
Repo.all(from a in __MODULE__, where: ^actor.ap_id in a.followers)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
defmodule Clacks.Inbox do
|
||||
require Logger
|
||||
alias Clacks.{Repo, Activity, Object, Actor, ActivityPub}
|
||||
|
||||
defp store_activity(%{"actor" => actor} = activity, local \\ false) when is_binary(actor) do
|
||||
changeset =
|
||||
Activity.changeset(%Activity{}, %{
|
||||
data: activity,
|
||||
local: local,
|
||||
actor: actor
|
||||
})
|
||||
|
||||
Repo.insert(changeset)
|
||||
end
|
||||
|
||||
@spec handle(activity :: map()) :: :ok | {:error, reason :: any()}
|
||||
|
||||
def handle(%{"type" => "Create", "object" => object} = activity) do
|
||||
changeset = Object.changeset(%Object{}, %{data: object})
|
||||
|
||||
case Repo.insert(changeset) do
|
||||
{:error, changeset} ->
|
||||
Logger.error("Couldn't store object: #{inspect(changeset)}")
|
||||
{:error, "Couldn't store changeset"}
|
||||
|
||||
{:ok, _object} ->
|
||||
case store_activity(activity) do
|
||||
{:error, changeset} ->
|
||||
Logger.error("Couldn't store Create activity: #{inspect(changeset)}")
|
||||
{:error, "Couldn't store Create activity"}
|
||||
|
||||
{:ok, _activity} ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle(%{"type" => "Follow", "object" => followed_id, "actor" => follower_id} = activity)
|
||||
when is_binary(followed_id) do
|
||||
followed = Actor.get_by_ap_id(followed_id)
|
||||
follower = Actor.get_by_ap_id(follower_id)
|
||||
|
||||
store_activity(activity)
|
||||
|
||||
changeset = Actor.changeset(followed, %{followers: [follower_id | followed.followers]})
|
||||
|
||||
case Repo.update(changeset) do
|
||||
{:error, changeset} ->
|
||||
Logger.error("Couldn't store Follow activity: #{inspect(changeset)}")
|
||||
{:error, "Couldn't store Follow activity"}
|
||||
|
||||
{:ok, _followed} ->
|
||||
accept = ActivityPub.accept_follow(activity)
|
||||
|
||||
case store_activity(accept, true) do
|
||||
{:error, changeset} ->
|
||||
Logger.error("Couldn't store Accept activity: #{inspect(changeset)}")
|
||||
{:error, "Couldn't store Accept activity"}
|
||||
|
||||
{:ok, _accept} ->
|
||||
ActivityPub.Federator.federate(accept, follower.data["inbox"])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# as a fallback, just store the activity
|
||||
def handle(activity) do
|
||||
case store_activity(activity) do
|
||||
{:error, changeset} ->
|
||||
Logger.error("Could not store activity: #{inspect(changeset)}")
|
||||
{:error, "Could not store activity"}
|
||||
|
||||
{:ok, _activity} ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
|
@ -6,7 +6,7 @@ defmodule Clacks.Keys do
|
|||
{:ok, pem}
|
||||
end
|
||||
|
||||
def keys_from_pem(pem) do
|
||||
def keys_from_private_key_pem(pem) do
|
||||
with [private_key_code] <- :public_key.pem_decode(pem),
|
||||
private_key <- :public_key.pem_entry_decode(private_key_code),
|
||||
{:RSAPrivateKey, _, modulus, exponent, _, _, _, _, _, _, _} <- private_key do
|
||||
|
@ -17,6 +17,16 @@ defmodule Clacks.Keys do
|
|||
end
|
||||
end
|
||||
|
||||
def key_from_pem(pem) do
|
||||
with [entry] <- :public_key.pem_decode(pem),
|
||||
key <- :public_key.pem_entry_decode(entry) do
|
||||
{:ok, key}
|
||||
else
|
||||
error ->
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
def public_key_pem({:RSAPublicKey, _, _} = key) do
|
||||
entry = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, key)
|
||||
pem = :public_key.pem_encode([entry])
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
defmodule Clacks.Paginator do
|
||||
import Ecto.Query
|
||||
|
||||
def paginate(query, %{"max_id" => max_id}) do
|
||||
query
|
||||
|> where([a], a.id < ^max_id)
|
||||
|> order_by(desc: :id)
|
||||
end
|
||||
|
||||
def paginate(query, %{"since_id" => since_id}) do
|
||||
query
|
||||
|> where([a], a.id > ^since_id)
|
||||
|> order_by(desc: :id)
|
||||
end
|
||||
|
||||
def paginate(query, %{"min_id" => min_id}) do
|
||||
query
|
||||
|> where([a], a.id > ^min_id)
|
||||
|> order_by(asc: :id)
|
||||
end
|
||||
|
||||
def paginate(query, _params) do
|
||||
query
|
||||
|> order_by(desc: :id)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,48 @@
|
|||
defmodule Clacks.SignatureAdapter do
|
||||
alias Clacks.{Actor, Keys}
|
||||
@behaviour HTTPSignatures.Adapter
|
||||
|
||||
def fetch_public_key(conn) do
|
||||
get_key(conn, false)
|
||||
end
|
||||
|
||||
def refetch_public_key(conn) do
|
||||
get_key(conn, true)
|
||||
end
|
||||
|
||||
defp get_key(conn, force_refetch) do
|
||||
actor_id = get_actor_id(conn)
|
||||
|
||||
case actor_id do
|
||||
nil ->
|
||||
{:error, "couldn't get actor id"}
|
||||
|
||||
_ ->
|
||||
case Actor.get_by_ap_id(actor_id, force_refetch) do
|
||||
%Actor{data: %{"publicKey" => %{"publicKeyPem" => pem}}} ->
|
||||
Keys.key_from_pem(pem)
|
||||
|
||||
_ ->
|
||||
{:error, "couldn't get pem from actor #{actor_id}"}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp get_actor_id(conn) do
|
||||
case HTTPSignatures.signature_for_conn(conn) do
|
||||
%{"keyId" => key_id} ->
|
||||
key_id_to_actor_id(key_id)
|
||||
|
||||
_ ->
|
||||
case conn.body_params do
|
||||
%{"actor" => actor} when is_binary(actor) -> actor
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp key_id_to_actor_id(key_id) do
|
||||
%URI{URI.parse(key_id) | fragment: nil}
|
||||
|> URI.to_string()
|
||||
end
|
||||
end
|
|
@ -1,12 +1,15 @@
|
|||
defmodule Clacks.User do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
alias Clacks.Repo
|
||||
|
||||
@type t() :: %__MODULE__{}
|
||||
|
||||
schema "users" do
|
||||
field :username, :string
|
||||
field :private_key, :string
|
||||
field :password, :string, virtual: true
|
||||
field :password_hash, :string
|
||||
|
||||
has_one :actor, Clacks.Actor
|
||||
|
||||
|
@ -15,7 +18,31 @@ defmodule Clacks.User do
|
|||
|
||||
def changeset(%__MODULE__{} = schema, attrs) do
|
||||
schema
|
||||
|> cast(attrs, [:username, :private_key])
|
||||
|> validate_required([:username, :private_key])
|
||||
|> cast(attrs, [:username, :private_key, :password_hash])
|
||||
|> validate_required([:username, :private_key, :password_hash])
|
||||
end
|
||||
|
||||
def registration_changeset(%__MODULE__{} = schema, attrs) do
|
||||
schema
|
||||
|> cast(attrs, [:username, :private_key, :password])
|
||||
|> validate_length(:password, min: 8)
|
||||
|> put_password_hash()
|
||||
end
|
||||
|
||||
def change_password_changeset(%__MODULE__{} = schema, attrs) do
|
||||
schema
|
||||
|> cast(attrs, [:password])
|
||||
|> validate_length(:password, min: 8)
|
||||
|> put_password_hash()
|
||||
end
|
||||
|
||||
defp put_password_hash(
|
||||
%Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset
|
||||
) do
|
||||
change(changeset, Bcrypt.add_hash(password))
|
||||
end
|
||||
|
||||
def get_by_username(username) do
|
||||
Repo.get_by(__MODULE__, username: username)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,10 +1,27 @@
|
|||
defmodule ClacksWeb.ActorController do
|
||||
use ClacksWeb, :controller
|
||||
alias Clacks.{Repo, Actor}
|
||||
import Ecto.Query
|
||||
alias Clacks.Actor
|
||||
|
||||
def get(conn, %{"nickname" => nickname}) do
|
||||
case Actor.get_by_nickanme(nickname) do
|
||||
@context "https://www.w3.org/ns/activitystreams"
|
||||
|
||||
plug :get_actor
|
||||
|
||||
defp get_actor(%Plug.Conn{path_params: %{"nickname" => nickname}} = conn, _opts) do
|
||||
case Actor.get_by_nickname(nickname) do
|
||||
nil ->
|
||||
conn
|
||||
|> put_status(404)
|
||||
|> halt()
|
||||
|
||||
actor ->
|
||||
assign(conn, :actor, actor)
|
||||
end
|
||||
end
|
||||
|
||||
defp get_actor(conn, _opts), do: conn
|
||||
|
||||
def get(conn, _params) do
|
||||
case conn.assigns[:actor] do
|
||||
%Actor{local: true, data: data} ->
|
||||
conn
|
||||
|> put_resp_header("content-type", "application/activity+json")
|
||||
|
@ -13,10 +30,92 @@ defmodule ClacksWeb.ActorController do
|
|||
%Actor{local: false, ap_id: ap_id} ->
|
||||
conn
|
||||
|> redirect(external: ap_id)
|
||||
end
|
||||
end
|
||||
|
||||
_ ->
|
||||
conn
|
||||
|> put_status(404)
|
||||
def followers(conn, %{"page" => page}) do
|
||||
{page, _} = Integer.parse(page)
|
||||
|
||||
followers = conn.assigns[:actor].followers
|
||||
|
||||
data =
|
||||
collection_page(conn, followers, page)
|
||||
|> Map.put("@context", @context)
|
||||
|
||||
conn
|
||||
|> put_resp_header("content-type", "application/activity+json")
|
||||
|> json(data)
|
||||
end
|
||||
|
||||
def followers(conn, _params) do
|
||||
%Actor{followers: followers} = conn.assigns[:actor]
|
||||
|
||||
data = %{
|
||||
"@context" => @context,
|
||||
"type" => "OrderedCollection",
|
||||
"id" => current_url(conn, %{}),
|
||||
"totalItems" => length(followers),
|
||||
"first" => collection_page(conn, followers, 1)
|
||||
}
|
||||
|
||||
conn
|
||||
|> put_resp_header("content-type", "application/activity+json")
|
||||
|> json(data)
|
||||
end
|
||||
|
||||
def following(conn, %{"page" => page}) do
|
||||
actor = conn.assigns[:actor]
|
||||
|
||||
following =
|
||||
Actor.get_all_following(actor)
|
||||
|> Enum.map(fn actor -> actor.ap_id end)
|
||||
|
||||
data =
|
||||
collection_page(conn, following, page)
|
||||
|> Map.put("@context", @context)
|
||||
|
||||
conn
|
||||
|> put_resp_header("content-type", "application/activity+json")
|
||||
|> json(data)
|
||||
end
|
||||
|
||||
def following(conn, _params) do
|
||||
actor = conn.assigns[:actor]
|
||||
|
||||
following =
|
||||
Actor.get_all_following(actor)
|
||||
|> Enum.map(fn actor -> actor.ap_id end)
|
||||
|
||||
data = %{
|
||||
"@context" => @context,
|
||||
"type" => "OrderedCollection",
|
||||
"id" => current_url(conn, %{}),
|
||||
"totalItems" => length(following),
|
||||
"first" => collection_page(conn, following, 1)
|
||||
}
|
||||
|
||||
conn
|
||||
|> put_resp_header("content-type", "application/activity+json")
|
||||
|> json(data)
|
||||
end
|
||||
|
||||
defp collection_page(conn, collection, page) do
|
||||
chunks = Enum.chunk_every(collection, 20)
|
||||
# page is 1 indexed, so subtract 1 to get the current chunk
|
||||
current_chunk = Enum.at(chunks, page - 1)
|
||||
|
||||
data = %{
|
||||
"type" => "OrderedCollectionPage",
|
||||
"totalItems" => length(collection),
|
||||
"partOf" => current_url(conn, %{}),
|
||||
"id" => current_url(conn, %{page: page}),
|
||||
"orderedItems" => current_chunk || []
|
||||
}
|
||||
|
||||
if page < length(chunks) do
|
||||
Map.put(data, "next", current_url(conn, %{page: page + 1}))
|
||||
else
|
||||
data
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
defmodule ClacksWeb.InboxController do
|
||||
require Logger
|
||||
use ClacksWeb, :controller
|
||||
alias Clacks.Inbox
|
||||
|
||||
plug Plug.Parsers, parsers: [:urlencoded, :json], json_decoder: Jason
|
||||
plug ClacksWeb.Plug.HTTPSignature
|
||||
|
||||
def shared(conn, _params) do
|
||||
handle(conn, conn.body_params)
|
||||
end
|
||||
|
||||
def user_specific(conn, _params) do
|
||||
handle(conn, conn.body_params)
|
||||
end
|
||||
|
||||
def handle(conn, %{"type" => type} = activity) do
|
||||
case Inbox.handle(activity) do
|
||||
:ok ->
|
||||
put_status(conn, 200)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Could not handle incoming #{type} activity: #{inspect(reason)}")
|
||||
put_status(conn, 500)
|
||||
end
|
||||
|> json(%{})
|
||||
end
|
||||
end
|
|
@ -0,0 +1,42 @@
|
|||
defmodule ClacksWeb.LoginController do
|
||||
use ClacksWeb, :controller
|
||||
alias Clacks.User
|
||||
alias ClacksWeb.Router.Helpers, as: Routes
|
||||
alias ClacksWeb.Endpoint
|
||||
|
||||
def login(conn, params) do
|
||||
render(conn, "login.html", %{
|
||||
continue: Map.get(params, "continue")
|
||||
})
|
||||
end
|
||||
|
||||
def login_post(conn, %{"username" => username, "password" => password} = params) do
|
||||
user = User.get_by_username(username)
|
||||
|
||||
case Bcrypt.check_pass(user, password) do
|
||||
{:ok, user} ->
|
||||
user_token = Phoenix.Token.sign(Endpoint, "user token", user.id)
|
||||
redirect_uri = Map.get(params, "continue") || "/"
|
||||
|
||||
conn
|
||||
|> put_session(:user_token, user_token)
|
||||
|> redirect(to: redirect_uri)
|
||||
|
||||
{:error, _reason} ->
|
||||
conn
|
||||
|> put_flash(:error, "Invalid username or password.")
|
||||
|> redirect(to: Routes.login_path(Endpoint, :login))
|
||||
end
|
||||
end
|
||||
|
||||
def login_post(conn, _params) do
|
||||
redirect(conn, to: Routes.login_path(Endpoint, :login))
|
||||
end
|
||||
|
||||
def logout(conn, _params) do
|
||||
conn
|
||||
|> clear_session()
|
||||
|> put_flash(:info, "Logged out.")
|
||||
|> redirect(to: "/")
|
||||
end
|
||||
end
|
|
@ -0,0 +1,76 @@
|
|||
defmodule ClacksWeb.OutboxController do
|
||||
use ClacksWeb, :controller
|
||||
alias Clacks.{Repo, Actor, Activity}
|
||||
import Ecto.Query
|
||||
|
||||
@context "https://www.w3.org/ns/activitystreams"
|
||||
@outbox_types ["Create", "Announce"]
|
||||
|
||||
plug :get_actor
|
||||
|
||||
defp get_actor(%Plug.Conn{path_params: %{"nickname" => nickname}} = conn, _opts) do
|
||||
case Actor.get_by_nickname(nickname) do
|
||||
nil ->
|
||||
conn
|
||||
|> put_status(404)
|
||||
|
||||
%Actor{local: false} ->
|
||||
conn
|
||||
|> put_status(404)
|
||||
|
||||
actor ->
|
||||
assign(conn, :actor, actor)
|
||||
end
|
||||
end
|
||||
|
||||
def outbox(conn, params) when params == %{} do
|
||||
actor = conn.assigns[:actor]
|
||||
|
||||
activities = Repo.all(outbox_query(params, actor))
|
||||
|
||||
data = %{
|
||||
"@context" => @context,
|
||||
"type" => "OrderedCollection",
|
||||
"id" => current_url(conn, %{}),
|
||||
"first" => outbox_page(conn, params, activities)
|
||||
}
|
||||
|
||||
conn
|
||||
|> put_resp_header("content-type", "application/activity+json")
|
||||
|> json(data)
|
||||
end
|
||||
|
||||
def outbox(conn, params) do
|
||||
actor = conn.assigns[:actor]
|
||||
|
||||
activities = Repo.all(outbox_query(params, actor))
|
||||
|
||||
data =
|
||||
outbox_page(conn, params, activities)
|
||||
|> Map.put("@context", @context)
|
||||
|
||||
conn
|
||||
|> put_resp_header("content-type", "application/activity+json")
|
||||
|> json(data)
|
||||
end
|
||||
|
||||
defp outbox_query(params, %Actor{ap_id: ap_id}) do
|
||||
Activity
|
||||
|> where([a], a.actor == ^ap_id)
|
||||
|> where([a], fragment("?->>'type'", a.data) in @outbox_types)
|
||||
|> Clacks.Paginator.paginate(params)
|
||||
|> limit(^Map.get(params, "limit", 20))
|
||||
end
|
||||
|
||||
defp outbox_page(conn, pagination_params, activities) do
|
||||
last_id = List.last(activities).id
|
||||
|
||||
%{
|
||||
"type" => "OrderedColletionPage",
|
||||
"partOf" => current_url(conn, %{}),
|
||||
"id" => current_url(conn, pagination_params),
|
||||
"next" => current_url(conn, %{max_id: last_id}),
|
||||
"orderedItems" => Enum.map(activities, & &1.data)
|
||||
}
|
||||
end
|
||||
end
|
|
@ -6,7 +6,7 @@ defmodule ClacksWeb.WebFingerController do
|
|||
|
||||
def get(conn, %{"resource" => resource}) do
|
||||
with [_, nickname] <- Regex.run(@acct_regex, resource),
|
||||
%Actor{local: true} = actor <- Actor.get_by_nickanme(nickname) do
|
||||
%Actor{local: true} = actor <- Actor.get_by_nickname(nickname) do
|
||||
url = Application.get_env(:clacks, ClacksWeb.Endpoint)[:url]
|
||||
host = url[:host]
|
||||
port = url[:port]
|
||||
|
|
|
@ -29,7 +29,8 @@ defmodule ClacksWeb.Endpoint do
|
|||
plug Plug.Parsers,
|
||||
parsers: [:urlencoded, :multipart, :json],
|
||||
pass: ["*/*"],
|
||||
json_decoder: Phoenix.json_library()
|
||||
json_decoder: Jason,
|
||||
body_reader: {ClacksWeb.Plug.Digest, :read_body, []}
|
||||
|
||||
plug Plug.MethodOverride
|
||||
plug Plug.Head
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
defmodule ClacksWeb.Plug.Digest do
|
||||
alias Plug.Conn
|
||||
|
||||
def read_body(conn, opts) do
|
||||
{:ok, body, conn} = Conn.read_body(conn, opts)
|
||||
digest = "SHA-256=" <> Base.encode64(:crypto.hash(:sha256, body))
|
||||
{:ok, body, Conn.assign(conn, :digest, digest)}
|
||||
end
|
||||
end
|
|
@ -0,0 +1,42 @@
|
|||
defmodule ClacksWeb.Plug.HTTPSignature do
|
||||
require Logger
|
||||
import Plug.Conn
|
||||
|
||||
def init(opts), do: opts
|
||||
|
||||
def call(conn, _opts) do
|
||||
case get_req_header(conn, "signature") do
|
||||
[_signature | _] ->
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header(
|
||||
"(request-target)",
|
||||
String.downcase(conn.method) <> " " <> conn.request_path
|
||||
)
|
||||
|> case do
|
||||
%Plug.Conn{assigns: %{digest: digest}} = conn when is_binary(digest) ->
|
||||
put_req_header(conn, "digest", digest)
|
||||
|
||||
_ ->
|
||||
conn
|
||||
end
|
||||
|
||||
if HTTPSignatures.validate_conn(conn) do
|
||||
conn
|
||||
else
|
||||
Logger.debug("Could not validate signature for #{inspect(conn)}")
|
||||
|
||||
conn
|
||||
|> put_status(401)
|
||||
|> halt()
|
||||
end
|
||||
|
||||
_ ->
|
||||
Logger.debug("No signature header for #{inspect(conn)}")
|
||||
|
||||
conn
|
||||
|> put_status(401)
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,41 @@
|
|||
defmodule ClacksWeb.Plug.WebAuthenticate do
|
||||
import Plug.Conn
|
||||
alias Clacks.{Repo, User}
|
||||
alias ClacksWeb.Router.Helpers, as: Routes
|
||||
alias ClacksWeb.Endpoint
|
||||
|
||||
def init(%{on_failure: on_failure_action} = opts)
|
||||
when on_failure_action in [:redirect_to_login, :pass],
|
||||
do: opts
|
||||
|
||||
def init(_), do: %{on_failure: :redirect_to_login}
|
||||
|
||||
def call(conn, %{on_failure: on_failure_action}) do
|
||||
user_token = get_session(conn, :user_token)
|
||||
|
||||
case Phoenix.Token.verify(Endpoint, "user token", user_token, max_age: 7 * 24 * 60 * 60) do
|
||||
{:error, _reason} ->
|
||||
on_failure(conn, on_failure_action)
|
||||
|
||||
{:ok, user_id} ->
|
||||
case Repo.get(User, user_id) do
|
||||
nil ->
|
||||
on_failure(conn, on_failure_action)
|
||||
|
||||
user ->
|
||||
user = Repo.preload(user, :actor)
|
||||
assign(conn, :user, user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp on_failure(conn, :redirect_to_login) do
|
||||
conn
|
||||
|> Phoenix.Controller.redirect(to: Routes.login_path(Endpoint, :login))
|
||||
|> halt()
|
||||
end
|
||||
|
||||
defp on_failure(conn, :pass) do
|
||||
conn
|
||||
end
|
||||
end
|
|
@ -7,6 +7,11 @@ defmodule ClacksWeb.Router do
|
|||
plug :fetch_flash
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers
|
||||
plug ClacksWeb.Plug.WebAuthenticate, on_failure: :pass
|
||||
end
|
||||
|
||||
pipeline :browser_authenticated do
|
||||
plug ClacksWeb.Plug.WebAuthenticate, on_failure: :redirect_to_login
|
||||
end
|
||||
|
||||
pipeline :activitypub do
|
||||
|
@ -15,13 +20,29 @@ defmodule ClacksWeb.Router do
|
|||
|
||||
scope "/", ClacksWeb do
|
||||
pipe_through :browser
|
||||
|
||||
get "/login", LoginController, :login
|
||||
post "/login", LoginController, :login_post
|
||||
post "/logout", LoginController, :logout_post
|
||||
end
|
||||
|
||||
scope "/", ClacksWeb do
|
||||
pipe_through :browser
|
||||
pipe_through :browser_authenticated
|
||||
end
|
||||
|
||||
scope "/", ClacksWeb do
|
||||
pipe_through :activitypub
|
||||
|
||||
get "/objects/:id", ObjectsController, :get
|
||||
|
||||
get "/users/:nickname", ActorController, :get
|
||||
get "/users/:nickname/followers", ActorController, :followers
|
||||
get "/users/:nickname/following", ActorController, :following
|
||||
get "/users/:nickname/outbox", OutboxController, :outbox
|
||||
|
||||
post "/inbox", InboxController, :shared
|
||||
post "/users/:nickname/inbox", InboxController, :user_specific
|
||||
|
||||
get "/.well-known/webfinger", WebFingerController, :get
|
||||
end
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<%= form_tag Routes.login_path(@conn, :login_post), method: :post do %>
|
||||
<%= if @continue do %>
|
||||
<input type="hidden" name="continue" value="<%= @continue %>">
|
||||
<% end %>
|
||||
|
||||
<label for="username">Username</label>
|
||||
<input id="username" type="text" name="username">
|
||||
<br>
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" name="password">
|
||||
<br>
|
||||
<%= submit "Log In" %>
|
||||
<% end %>
|
|
@ -0,0 +1,3 @@
|
|||
defmodule ClacksWeb.PageView do
|
||||
use ClacksWeb, :view
|
||||
end
|
|
@ -1,3 +1,3 @@
|
|||
defmodule ClacksWeb.PageView do
|
||||
defmodule ClacksWeb.LoginView do
|
||||
use ClacksWeb, :view
|
||||
end
|
||||
|
|
|
@ -5,13 +5,14 @@ defmodule Mix.Tasks.Clacks.User do
|
|||
@shortdoc "Creates a new user"
|
||||
def run(["create"]) do
|
||||
username = IO.gets("Username: ") |> String.trim()
|
||||
# password = IO.gets("Password: ") |> String.trim()
|
||||
password = IO.gets("Password: ") |> String.trim()
|
||||
|
||||
{:ok, pem} = Keys.generate_rsa_pem()
|
||||
{:ok, _private, public} = Keys.keys_from_pem(pem)
|
||||
{:ok, _private, public} = Keys.keys_from_private_key_pem(pem)
|
||||
{:ok, public_key_pem} = Keys.public_key_pem(public)
|
||||
|
||||
changeset = User.changeset(%User{}, %{username: username, private_key: pem})
|
||||
changeset =
|
||||
User.changeset(%User{}, %{username: username, private_key: pem, password: password})
|
||||
|
||||
# start the app so the DB connection is established
|
||||
Mix.Task.run("app.start")
|
||||
|
|
4
mix.exs
4
mix.exs
|
@ -47,7 +47,9 @@ defmodule Clacks.MixProject do
|
|||
{:http_signatures,
|
||||
git: "https://git.pleroma.social/pleroma/http_signatures.git",
|
||||
ref: "293d77bb6f4a67ac8bde1428735c3b42f22cbb30"},
|
||||
{:httpoison, "~> 1.5.1"}
|
||||
{:httpoison, "~> 1.5.1"},
|
||||
{:timex, "~> 3.6.1"},
|
||||
{:bcrypt_elixir, "~> 2.0"}
|
||||
]
|
||||
end
|
||||
|
||||
|
|
6
mix.lock
6
mix.lock
|
@ -1,6 +1,9 @@
|
|||
%{
|
||||
"base62": {:hex, :base62, "1.2.1", "4866763e08555a7b3917064e9eef9194c41667276c51b59de2bc42c6ea65f806", [:mix], [{:custom_base, "~> 0.2.1", [hex: :custom_base, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "2.0.3", "64e0792d5b5064391927bf3b8e436994cafd18ca2d2b76dea5c76e0adcf66b7c", [:make, :mix], [{:comeonin, "~> 5.1", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"},
|
||||
"comeonin": {:hex, :comeonin, "5.1.2", "fbbbbbfcf0f0e9900c0336d16c8d462edf838ba1759577e29cc5fbd7c28a4540", [:mix], [], "hexpm"},
|
||||
"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"},
|
||||
"cowboy": {:hex, :cowboy, "2.6.3", "99aa50e94e685557cad82e704457336a453d4abcb77839ad22dbe71f311fcc06", [:rebar3], [{:cowlib, "~> 2.7.3", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"cowlib": {:hex, :cowlib, "2.7.3", "a7ffcd0917e6d50b4d5fb28e9e2085a0ceb3c97dea310505f7460ff5ed764ce9", [:rebar3], [], "hexpm"},
|
||||
|
@ -9,6 +12,7 @@
|
|||
"decimal": {:hex, :decimal, "1.8.0", "ca462e0d885f09a1c5a342dbd7c1dcf27ea63548c65a65e67334f4b61803822e", [:mix], [], "hexpm"},
|
||||
"ecto": {:hex, :ecto, "3.2.1", "a0f9af0fb50b19d3bb6237e512ac0ba56ea222c2bbea92e7c6c94897932c76ba", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.2.0", "751cea597e8deb616084894dd75cbabfdbe7255ff01e8c058ca13f0353a3921b", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.2.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.2.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm"},
|
||||
"file_system": {:hex, :file_system, "0.2.7", "e6f7f155970975789f26e77b8b8d8ab084c59844d8ecfaf58cbda31c494d14aa", [:mix], [], "hexpm"},
|
||||
"flake_id": {:hex, :flake_id, "0.1.0", "7716b086d2e405d09b647121a166498a0d93d1a623bead243e1f74216079ccb3", [:mix], [{:base62, "~> 1.2", [hex: :base62, repo: "hexpm", optional: false]}, {:ecto, ">= 2.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"},
|
||||
"gettext": {:hex, :gettext, "0.17.0", "abe21542c831887a2b16f4c94556db9c421ab301aee417b7c4fbde7fbdbe01ec", [:mix], [], "hexpm"},
|
||||
|
@ -33,5 +37,7 @@
|
|||
"ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"},
|
||||
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm"},
|
||||
"telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm"},
|
||||
"timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"tzdata": {:hex, :tzdata, "1.0.1", "f6027a331af7d837471248e62733c6ebee86a72e57c613aa071ebb1f750fc71a", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"},
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
defmodule Clacks.Repo.Migrations.UsersAddPasswordHash do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:users) do
|
||||
add :password_hash, :string
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue