From e14007bac5e871e50ce78643bc1fc55c861fdc69 Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Thu, 17 May 2018 11:32:23 +0200 Subject: [PATCH] WIP Signed-off-by: Thomas Citharel --- config/config.exs | 9 + config/dev.exs | 2 +- js/.env | 3 + lib/eventos/accounts/account.ex | 78 ++- lib/eventos/accounts/accounts.ex | 81 ++- lib/eventos/activity.ex | 7 + lib/eventos/application.ex | 3 +- lib/eventos/events/comment.ex | 27 + lib/eventos/events/event.ex | 6 +- lib/eventos/events/events.ex | 153 ++++++ lib/eventos/groups/group.ex | 5 +- .../controllers/activity_pub_controller.ex | 106 ++++ .../controllers/comment_controller.ex | 42 ++ .../controllers/group_controller.ex | 1 - .../controllers/inboxes_controller.ex | 8 + .../controllers/nodeinfo_controller.ex | 67 +++ .../controllers/outboxes_controller.ex | 11 + .../controllers/web_finger_controller.ex | 21 + lib/eventos_web/http_signature.ex | 43 ++ lib/eventos_web/router.ex | 101 ++-- lib/eventos_web/views/account_view.ex | 2 - .../views/activity_pub/account_view.ex | 117 +++++ .../views/activity_pub/object_view.ex | 37 ++ lib/eventos_web/views/comment_view.ex | 18 + lib/eventos_web/views/group_view.ex | 2 - lib/service/activity_pub/activity_pub.ex | 276 ++++++++++ lib/service/activity_pub/transmogrifier.ex | 482 ++++++++++++++++++ lib/service/activity_pub/utils.ex | 304 +++++++++++ lib/service/federator.ex | 126 +++++ .../http_signatures/http_signatures.ex | 112 ++++ lib/service/web_finger/web_finger.ex | 94 ++++ lib/service/xml_builder.ex | 44 ++ mix.exs | 5 + mix.lock | 100 ++-- .../migrations/20180430071244_remove_uri.exs | 23 + ...20180515091106_add_url_field_to_events.exs | 15 + .../20180515100237_create_comments.exs | 17 + ...timestamps_to_date_time_with_time_zone.exs | 17 + ...local_attribute_to_events_and_comments.exs | 23 + test/eventos/events/events_test.exs | 62 +++ .../service/activitypub/activitypub_test.exs | 81 +++ .../service/web_finger/web_finger_test.exs | 59 +++ .../activity_pub_controller_test.exs | 139 +++++ .../controllers/comment_controller_test.exs | 81 +++ test/support/factory.ex | 17 +- 45 files changed, 2916 insertions(+), 111 deletions(-) create mode 100644 js/.env create mode 100644 lib/eventos/activity.ex create mode 100644 lib/eventos/events/comment.ex create mode 100644 lib/eventos_web/controllers/activity_pub_controller.ex create mode 100644 lib/eventos_web/controllers/comment_controller.ex create mode 100644 lib/eventos_web/controllers/inboxes_controller.ex create mode 100644 lib/eventos_web/controllers/nodeinfo_controller.ex create mode 100644 lib/eventos_web/controllers/outboxes_controller.ex create mode 100644 lib/eventos_web/controllers/web_finger_controller.ex create mode 100644 lib/eventos_web/http_signature.ex create mode 100644 lib/eventos_web/views/activity_pub/account_view.ex create mode 100644 lib/eventos_web/views/activity_pub/object_view.ex create mode 100644 lib/eventos_web/views/comment_view.ex create mode 100644 lib/service/activity_pub/activity_pub.ex create mode 100644 lib/service/activity_pub/transmogrifier.ex create mode 100644 lib/service/activity_pub/utils.ex create mode 100644 lib/service/federator.ex create mode 100644 lib/service/http_signatures/http_signatures.ex create mode 100644 lib/service/web_finger/web_finger.ex create mode 100644 lib/service/xml_builder.ex create mode 100644 priv/repo/migrations/20180430071244_remove_uri.exs create mode 100644 priv/repo/migrations/20180515091106_add_url_field_to_events.exs create mode 100644 priv/repo/migrations/20180515100237_create_comments.exs create mode 100644 priv/repo/migrations/20180515142835_alter_event_timestamps_to_date_time_with_time_zone.exs create mode 100644 priv/repo/migrations/20180515145438_add_local_attribute_to_events_and_comments.exs create mode 100644 test/eventos/service/activitypub/activitypub_test.exs create mode 100644 test/eventos/service/web_finger/web_finger_test.exs create mode 100644 test/eventos_web/controllers/activity_pub_controller_test.exs create mode 100644 test/eventos_web/controllers/comment_controller_test.exs diff --git a/config/config.exs b/config/config.exs index 4cf6ba116..be9dc4e7a 100644 --- a/config/config.exs +++ b/config/config.exs @@ -9,6 +9,15 @@ use Mix.Config config :eventos, ecto_repos: [Eventos.Repo] +config :eventos, :instance, + name: "Localhost", + version: "1.0.0-dev", + registrations_open: true + +config :mime, :types, %{ + "application/activity+json" => ["activity-json"] +} + # Configures the endpoint config :eventos, EventosWeb.Endpoint, url: [host: "localhost"], diff --git a/config/dev.exs b/config/dev.exs index 14b0774c0..c2c41044c 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -7,7 +7,7 @@ use Mix.Config # watchers to your application. For example, we use it # with brunch.io to recompile .js and .css sources. config :eventos, EventosWeb.Endpoint, - http: [port: 4000], + http: [port: 4001], debug_errors: true, code_reloader: true, check_origin: false, diff --git a/js/.env b/js/.env new file mode 100644 index 000000000..01866b3c9 --- /dev/null +++ b/js/.env @@ -0,0 +1,3 @@ +API_HOST=localhost +API_ORIGIN=http://localhost:4001 +API_PATH=/api/v1 diff --git a/lib/eventos/accounts/account.ex b/lib/eventos/accounts/account.ex index 70a85a5a7..4efceb326 100644 --- a/lib/eventos/accounts/account.ex +++ b/lib/eventos/accounts/account.ex @@ -4,9 +4,15 @@ defmodule Eventos.Accounts.Account do """ use Ecto.Schema import Ecto.Changeset + alias Eventos.Accounts alias Eventos.Accounts.{Account, User} alias Eventos.Groups.{Group, Member, Request} alias Eventos.Events.Event + alias Eventos.Service.ActivityPub + + import Logger + + @type t :: %Account{description: String.t, id: integer(), inserted_at: DateTime.t, updated_at: DateTime.t, display_name: String.t, domain: String.t, private_key: String.t, public_key: String.t, suspended: boolean(), url: String.t, username: String.t, organized_events: list(), groups: list(), group_request: list(), user: User.t} schema "accounts" do field :description, :string @@ -15,7 +21,6 @@ defmodule Eventos.Accounts.Account do field :private_key, :string field :public_key, :string field :suspended, :boolean, default: false - field :uri, :string field :url, :string field :username, :string field :avatar_url, :string @@ -31,15 +36,78 @@ defmodule Eventos.Accounts.Account do @doc false def changeset(%Account{} = account, attrs) do account - |> cast(attrs, [:username, :domain, :display_name, :description, :private_key, :public_key, :suspended, :uri, :url, :avatar_url, :banner_url]) - |> validate_required([:username, :public_key, :suspended, :uri, :url]) + |> cast(attrs, [:username, :domain, :display_name, :description, :private_key, :public_key, :suspended, :url]) + |> validate_required([:username, :public_key, :suspended, :url]) |> unique_constraint(:username, name: :accounts_username_domain_index) end def registration_changeset(%Account{} = account, attrs) do account - |> cast(attrs, [:username, :domain, :display_name, :description, :private_key, :public_key, :suspended, :uri, :url, :avatar_url, :banner_url]) - |> validate_required([:username, :public_key, :suspended, :uri, :url]) + |> cast(attrs, [:username, :domain, :display_name, :description, :private_key, :public_key, :suspended, :url]) + |> validate_required([:username, :public_key, :suspended, :url]) |> unique_constraint(:username) end + + @email_regex ~r/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ + def remote_account_creation(params) do + changes = + %Account{} + |> cast(params, [:description, :display_name, :url, :username, :public_key]) + |> validate_required([:url, :username, :public_key]) + |> unique_constraint(:username) + |> validate_format(:username, @email_regex) + |> validate_length(:description, max: 5000) + |> validate_length(:display_name, max: 100) + |> put_change(:local, false) + + Logger.debug("Remote account creation") + Logger.debug(inspect changes) + changes + # if changes.valid? do + # case changes.changes[:info]["source_data"] do + # %{"followers" => followers} -> + # changes + # |> put_change(:follower_address, followers) + # + # _ -> + # followers = User.ap_followers(%User{nickname: changes.changes[:nickname]}) + # + # changes + # |> put_change(:follower_address, followers) + # end + # else + # changes + # end + end + + def get_or_fetch_by_url(url) do + if user = Accounts.get_account_by_url(url) do + user + else + case ActivityPub.make_account_from_url(url) do + {:ok, user} -> + user + _ -> {:error, "Could not fetch by AP id"} + end + end + end + + @spec get_public_key_for_url(Account.t) :: {:ok, String.t} + def get_public_key_for_url(url) do + with %Account{} = account <- get_or_fetch_by_url(url) do + get_public_key_for_account(account) + else + _ -> :error + end + end + + @spec get_public_key_for_account(Account.t) :: {:ok, String.t} + def get_public_key_for_account(%Account{} = account) do + {:ok, account.public_key} + end + + @spec get_private_key_for_account(Account.t) :: {:ok, String.t} + def get_private_key_for_account(%Account{} = account) do + account.private_key + end end diff --git a/lib/eventos/accounts/accounts.ex b/lib/eventos/accounts/accounts.ex index 2a5e39096..2e89d4ab2 100644 --- a/lib/eventos/accounts/accounts.ex +++ b/lib/eventos/accounts/accounts.ex @@ -8,6 +8,9 @@ defmodule Eventos.Accounts do alias Eventos.Repo alias Eventos.Accounts.Account + alias Eventos.Accounts + + alias Eventos.Service.ActivityPub @doc """ Returns the list of accounts. @@ -110,6 +113,20 @@ defmodule Eventos.Accounts do Account.changeset(account, %{}) end + @doc """ + Returns a text representation of a local account like user@domain.tld + """ + def account_to_local_username_and_domain(account) do + "#{account.username}@#{Application.get_env(:my, EventosWeb.Endpoint)[:url][:host]}" + end + + @doc """ + Returns a webfinger representation of an account + """ + def account_to_webfinger_s(account) do + "acct:#{account_to_local_username_and_domain(account)}" + end + alias Eventos.Accounts.User @doc """ @@ -130,6 +147,34 @@ defmodule Eventos.Accounts do Repo.preload(users, :account) end + defp blank?(""), do: nil + defp blank?(n), do: n + + def insert_or_update_account(data) do + data = + data + |> Map.put(:name, blank?(data[:display_name]) || data[:username]) + + cs = Account.remote_account_creation(data) + Repo.insert(cs, on_conflict: [set: [public_key: data.public_key]], conflict_target: [:username, :domain]) + end + +# def increase_event_count(%Account{} = account) do +# event_count = (account.info["event_count"] || 0) + 1 +# new_info = Map.put(account.info, "note_count", note_count) +# +# cs = info_changeset(account, %{info: new_info}) +# +# update_and_set_cache(cs) +# end + + def count_users() do + Repo.one( + from u in User, + select: count(u.id) + ) + end + @doc """ Gets a single user. @@ -151,6 +196,29 @@ defmodule Eventos.Accounts do Repo.preload(user, :account) end + def get_account_by_url(url) do + Repo.get_by(Account, url: url) + end + + def get_account_by_username(username) do + Repo.get_by!(Account, username: username) + end + + def get_or_fetch_by_url(url) do + if account = get_account_by_url(url) do + account + else + ap_try = ActivityPub.make_account_from_url(url) + + case ap_try do + {:ok, account} -> + account + + _ -> {:error, "Could not fetch by AP id"} + end + end + end + @doc """ Get an user by email """ @@ -191,18 +259,17 @@ defmodule Eventos.Accounts do Register user """ def register(%{email: email, password: password, username: username}) do - {:ok, {privkey, pubkey}} = RsaEx.generate_keypair("4096") - + #{:ok, {privkey, pubkey}} = RsaEx.generate_keypair("4096") + {:ok, rsa_priv_key} = ExPublicKey.generate_key() + {:ok, rsa_pub_key} = ExPublicKey.public_key_from_private_key(rsa_priv_key) avatar = gravatar(email) account = Eventos.Accounts.Account.registration_changeset(%Eventos.Accounts.Account{}, %{ username: username, domain: nil, - private_key: privkey, - public_key: pubkey, - uri: "h", - url: "h", - avatar_url: avatar, + private_key: rsa_priv_key |> ExPublicKey.pem_encode(), + public_key: rsa_pub_key |> ExPublicKey.pem_encode(), + url: EventosWeb.Endpoint.url() <> "/@" <> username, }) user = Eventos.Accounts.User.registration_changeset(%Eventos.Accounts.User{}, %{ diff --git a/lib/eventos/activity.ex b/lib/eventos/activity.ex new file mode 100644 index 000000000..b28d5b615 --- /dev/null +++ b/lib/eventos/activity.ex @@ -0,0 +1,7 @@ +defmodule Eventos.Activity do + @moduledoc """ + Represents an activity + """ + + defstruct [:id, :data, :local, :actor, :recipients, :notifications] +end diff --git a/lib/eventos/application.ex b/lib/eventos/application.ex index 6dfadb8c0..e17b7a559 100644 --- a/lib/eventos/application.ex +++ b/lib/eventos/application.ex @@ -17,7 +17,8 @@ defmodule Eventos.Application do supervisor(EventosWeb.Endpoint, []), # Start your own worker by calling: Eventos.Worker.start_link(arg1, arg2, arg3) # worker(Eventos.Worker, [arg1, arg2, arg3]), - worker(Guardian.DB.Token.SweeperServer, []) + worker(Guardian.DB.Token.SweeperServer, []), + worker(Eventos.Service.Federator, []), ] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/lib/eventos/events/comment.ex b/lib/eventos/events/comment.ex new file mode 100644 index 000000000..43bc3781d --- /dev/null +++ b/lib/eventos/events/comment.ex @@ -0,0 +1,27 @@ +defmodule Eventos.Events.Comment do + use Ecto.Schema + import Ecto.Changeset + + alias Eventos.Events.Event + alias Eventos.Accounts.Account + alias Eventos.Accounts.Comment + + schema "comments" do + field :text, :string + field :url, :string + field :local, :boolean, default: true + belongs_to :account, Account, [foreign_key: :account_id] + belongs_to :event, Event, [foreign_key: :event_id] + belongs_to :in_reply_to_comment, Comment, [foreign_key: :in_reply_to_comment_id] + belongs_to :origin_comment, Comment, [foreign_key: :origin_comment_id] + + timestamps() + end + + @doc false + def changeset(comment, attrs) do + comment + |> cast(attrs, [:url, :text, :account_id, :event_id, :in_reply_to_comment_id]) + |> validate_required([:url, :text, :account_id]) + end +end diff --git a/lib/eventos/events/event.ex b/lib/eventos/events/event.ex index 91143c735..b9ab55596 100644 --- a/lib/eventos/events/event.ex +++ b/lib/eventos/events/event.ex @@ -39,6 +39,8 @@ defmodule Eventos.Events.Event do alias Eventos.Addresses.Address schema "events" do + field :url, :string + field :local, :boolean, default: true field :begins_on, Timex.Ecto.DateTimeWithTimezone field :description, :string field :ends_on, Timex.Ecto.DateTimeWithTimezone @@ -60,13 +62,13 @@ defmodule Eventos.Events.Event do has_many :sessions, Session belongs_to :address, Address - timestamps() + timestamps(type: :utc_datetime) end @doc false def changeset(%Event{} = event, attrs) do event - |> cast(attrs, [:title, :description, :begins_on, :ends_on, :organizer_account_id, :organizer_group_id, :category_id, :state, :status, :public, :thumbnail, :large_image, :publish_at]) + |> cast(attrs, [:title, :description, :url, :begins_on, :ends_on, :organizer_account_id, :organizer_group_id, :category_id, :state, :status, :public, :thumbnail, :large_image, :publish_at]) |> cast_assoc(:tags) |> cast_assoc(:address) |> validate_required([:title, :description, :begins_on, :ends_on, :organizer_account_id, :category_id]) diff --git a/lib/eventos/events/events.ex b/lib/eventos/events/events.ex index e29a7eacc..a0d2e0c46 100644 --- a/lib/eventos/events/events.ex +++ b/lib/eventos/events/events.ex @@ -7,6 +7,7 @@ defmodule Eventos.Events do alias Eventos.Repo alias Eventos.Events.Event + alias Eventos.Events.Comment alias Eventos.Accounts.Account @doc """ @@ -22,6 +23,36 @@ defmodule Eventos.Events do Repo.all(Event) end + def get_events_for_account(%Account{id: account_id} = _account, page \\ 1, limit \\ 10) do + start = (page - 1) * limit + + query = from e in Event, + where: e.organizer_account_id == ^account_id, + limit: ^limit, + order_by: [desc: :id], + offset: ^start, + preload: [:organizer_account, :organizer_group, :category, :sessions, :tracks, :tags, :participants, :address] + events = Repo.all(query) + count_events = Repo.one(from e in Event, select: count(e.id)) + {:ok, events, count_events} + end + + def count_local_events do + Repo.one( + from e in Event, + select: count(e.id), + where: e.local == ^true + ) + end + + def count_local_comments do + Repo.one( + from c in Comment, + select: count(c.id), + where: c.local == ^true + ) + end + @doc """ Gets a single event. @@ -38,6 +69,13 @@ defmodule Eventos.Events do """ def get_event!(id), do: Repo.get!(Event, id) + @doc """ + Gets an event by it's URL + """ + def get_event_by_url!(url) do + Repo.get_by(Event, url: url) + end + @doc """ Gets a single event, with all associations loaded. """ @@ -46,6 +84,25 @@ defmodule Eventos.Events do Repo.preload(event, [:organizer_account, :organizer_group, :category, :sessions, :tracks, :tags, :participants, :address]) end + @doc """ + Gets an event by it's URL + """ + def get_event_full_by_url!(url) do + event = Repo.get_by(Event, url: url) + Repo.preload(event, [:organizer_account, :organizer_group, :category, :sessions, :tracks, :tags, :participants, :address]) + end + + @spec get_event_full_by_username_and_slug!(String.t, String.t) :: Event.t + def get_event_full_by_username_and_slug!(username, slug) do + event = Repo.one( + from e in Event, + join: a in Account, + on: a.id == e.organizer_account_id and a.username == ^username, + where: e.slug == ^slug + ) + Repo.preload(event, [:organizer_account, :organizer_group, :category, :sessions, :tracks, :tags, :participants, :address]) + end + @doc """ Creates a event. @@ -706,4 +763,100 @@ defmodule Eventos.Events do def change_track(%Track{} = track) do Track.changeset(track, %{}) end + + alias Eventos.Events.Comment + + @doc """ + Returns the list of comments. + + ## Examples + + iex> list_comments() + [%Comment{}, ...] + + """ + def list_comments do + Repo.all(Comment) + end + + @doc """ + Gets a single comment. + + Raises `Ecto.NoResultsError` if the Comment does not exist. + + ## Examples + + iex> get_comment!(123) + %Comment{} + + iex> get_comment!(456) + ** (Ecto.NoResultsError) + + """ + def get_comment!(id), do: Repo.get!(Comment, id) + + @doc """ + Creates a comment. + + ## Examples + + iex> create_comment(%{field: value}) + {:ok, %Comment{}} + + iex> create_comment(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_comment(attrs \\ %{}) do + %Comment{} + |> Comment.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a comment. + + ## Examples + + iex> update_comment(comment, %{field: new_value}) + {:ok, %Comment{}} + + iex> update_comment(comment, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_comment(%Comment{} = comment, attrs) do + comment + |> Comment.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a Comment. + + ## Examples + + iex> delete_comment(comment) + {:ok, %Comment{}} + + iex> delete_comment(comment) + {:error, %Ecto.Changeset{}} + + """ + def delete_comment(%Comment{} = comment) do + Repo.delete(comment) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking comment changes. + + ## Examples + + iex> change_comment(comment) + %Ecto.Changeset{source: %Comment{}} + + """ + def change_comment(%Comment{} = comment) do + Comment.changeset(comment, %{}) + end end diff --git a/lib/eventos/groups/group.ex b/lib/eventos/groups/group.ex index 3043386a9..05d25a914 100644 --- a/lib/eventos/groups/group.ex +++ b/lib/eventos/groups/group.ex @@ -43,7 +43,6 @@ defmodule Eventos.Groups.Group do field :suspended, :boolean, default: false field :title, :string field :slug, TitleSlug.Type - field :uri, :string field :url, :string many_to_many :members, Account, join_through: Member has_many :organized_events, Event, [foreign_key: :organizer_group_id] @@ -56,8 +55,8 @@ defmodule Eventos.Groups.Group do @doc false def changeset(%Group{} = group, attrs) do group - |> cast(attrs, [:title, :description, :suspended, :url, :uri, :address_id]) - |> validate_required([:title, :description, :suspended, :url, :uri]) + |> cast(attrs, [:title, :description, :suspended, :url, :address_id]) + |> validate_required([:title, :description, :suspended, :url]) |> TitleSlug.maybe_generate_slug() |> TitleSlug.unique_constraint() end diff --git a/lib/eventos_web/controllers/activity_pub_controller.ex b/lib/eventos_web/controllers/activity_pub_controller.ex new file mode 100644 index 000000000..b086dc03a --- /dev/null +++ b/lib/eventos_web/controllers/activity_pub_controller.ex @@ -0,0 +1,106 @@ +defmodule EventosWeb.ActivityPubController do + use EventosWeb, :controller + alias Eventos.{Accounts, Accounts.Account, Events, Events.Event} + alias EventosWeb.ActivityPub.{ObjectView, AccountView} + alias Eventos.Service.ActivityPub + alias Eventos.Service.Federator + + require Logger + + action_fallback(:errors) + + def account(conn, %{"username" => username}) do + with %Account{} = account <- Accounts.get_account_by_username(username) do + conn + |> put_resp_header("content-type", "application/activity+json") + |> json(AccountView.render("account.json", %{account: account})) + end + end + + def event(conn, %{"username" => username, "slug" => slug}) do + with %Event{} = event <- Events.get_event_full_by_username_and_slug!(username, slug) do + conn + |> put_resp_header("content-type", "application/activity+json") + |> json(ObjectView.render("event.json", %{event: event})) + end + end + +# def following(conn, %{"username" => username, "page" => page}) do +# with %Account{} = account <- Accounts.get_account_by_username(username) do +# {page, _} = Integer.parse(page) +# +# conn +# |> put_resp_header("content-type", "application/activity+json") +# |> json(UserView.render("following.json", %{account: account, page: page})) +# end +# end +# +# def following(conn, %{"nickname" => nickname}) do +# with %User{} = user <- User.get_cached_by_nickname(nickname), +# {:ok, user} <- Pleroma.Web.WebFinger.ensure_keys_present(user) do +# conn +# |> put_resp_header("content-type", "application/activity+json") +# |> json(UserView.render("following.json", %{user: user})) +# end +# end +# +# def followers(conn, %{"nickname" => nickname, "page" => page}) do +# with %User{} = user <- User.get_cached_by_nickname(nickname), +# {:ok, user} <- Pleroma.Web.WebFinger.ensure_keys_present(user) do +# {page, _} = Integer.parse(page) +# +# conn +# |> put_resp_header("content-type", "application/activity+json") +# |> json(UserView.render("followers.json", %{user: user, page: page})) +# end +# end +# +# def followers(conn, %{"nickname" => nickname}) do +# with %User{} = user <- User.get_cached_by_nickname(nickname), +# {:ok, user} <- Pleroma.Web.WebFinger.ensure_keys_present(user) do +# conn +# |> put_resp_header("content-type", "application/activity+json") +# |> json(UserView.render("followers.json", %{user: user})) +# end +# end + + def outbox(conn, %{"username" => username, "page" => page}) do + with {page, ""} = Integer.parse(page), + %Account{} = account <- Accounts.get_account_by_username(username) do + conn + |> put_resp_header("content-type", "application/activity+json") + |> json(AccountView.render("outbox.json", %{account: account, page: page})) + end + end + + def outbox(conn, %{"username" => username}) do + outbox(conn, %{"username" => username, "page" => "0"}) + end + + # TODO: Ensure that this inbox is a recipient of the message + def inbox(%{assigns: %{valid_signature: true}} = conn, params) do + Federator.enqueue(:incoming_ap_doc, params) + json(conn, "ok") + end + + def inbox(conn, params) do + headers = Enum.into(conn.req_headers, %{}) + + if !String.contains?(headers["signature"] || "", params["actor"]) do + Logger.info("Signature not from author, relayed message, fetching from source") + ActivityPub.fetch_event_from_url(params["object"]["id"]) + else + Logger.info("Signature error") + Logger.info("Could not validate #{params["actor"]}") + Logger.info(inspect(conn.req_headers)) + end + + json(conn, "ok") + end + + def errors(conn, _e) do + conn + |> put_status(500) + |> json("error") + end +end diff --git a/lib/eventos_web/controllers/comment_controller.ex b/lib/eventos_web/controllers/comment_controller.ex new file mode 100644 index 000000000..af0560ab6 --- /dev/null +++ b/lib/eventos_web/controllers/comment_controller.ex @@ -0,0 +1,42 @@ +defmodule EventosWeb.CommentController do + use EventosWeb, :controller + + alias Eventos.Events + alias Eventos.Events.Comment + + action_fallback EventosWeb.FallbackController + + def index(conn, _params) do + comments = Events.list_comments() + render(conn, "index.json", comments: comments) + end + + def create(conn, %{"comment" => comment_params}) do + with {:ok, %Comment{} = comment} <- Events.create_comment(comment_params) do + conn + |> put_status(:created) + |> put_resp_header("location", comment_path(conn, :show, comment)) + |> render("show.json", comment: comment) + end + end + + def show(conn, %{"id" => id}) do + comment = Events.get_comment!(id) + render(conn, "show.json", comment: comment) + end + + def update(conn, %{"id" => id, "comment" => comment_params}) do + comment = Events.get_comment!(id) + + with {:ok, %Comment{} = comment} <- Events.update_comment(comment, comment_params) do + render(conn, "show.json", comment: comment) + end + end + + def delete(conn, %{"id" => id}) do + comment = Events.get_comment!(id) + with {:ok, %Comment{}} <- Events.delete_comment(comment) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/lib/eventos_web/controllers/group_controller.ex b/lib/eventos_web/controllers/group_controller.ex index a3a060b18..e7daf1f3e 100644 --- a/lib/eventos_web/controllers/group_controller.ex +++ b/lib/eventos_web/controllers/group_controller.ex @@ -15,7 +15,6 @@ defmodule EventosWeb.GroupController do end def create(conn, %{"group" => group_params}) do - group_params = Map.put(group_params, "uri", "h") group_params = Map.put(group_params, "url", "h") with {:ok, %Group{} = group} <- Groups.create_group(group_params) do conn diff --git a/lib/eventos_web/controllers/inboxes_controller.ex b/lib/eventos_web/controllers/inboxes_controller.ex new file mode 100644 index 000000000..89060f24d --- /dev/null +++ b/lib/eventos_web/controllers/inboxes_controller.ex @@ -0,0 +1,8 @@ +defmodule EventosWeb.InboxesController do + + use EventosWeb, :controller + + def create(conn) do + + end +end diff --git a/lib/eventos_web/controllers/nodeinfo_controller.ex b/lib/eventos_web/controllers/nodeinfo_controller.ex new file mode 100644 index 000000000..7a4e5dfd4 --- /dev/null +++ b/lib/eventos_web/controllers/nodeinfo_controller.ex @@ -0,0 +1,67 @@ +defmodule EventosWeb.NodeinfoController do + use EventosWeb, :controller + + alias EventosWeb + alias Eventos.{Accounts, Events} + + @instance Application.get_env(:eventos, :instance) + + def schemas(conn, _params) do + response = %{ + links: [ + %{ + rel: "http://nodeinfo.diaspora.software/ns/schema/2.0", + href: EventosWeb.Endpoint.url() <> "/nodeinfo/2.0.json" + } + ] + } + + json(conn, response) + end + + # Schema definition: https://github.com/jhass/nodeinfo/blob/master/schemas/2.0/schema.json + def nodeinfo(conn, %{"version" => "2.0.json"}) do + import Logger + Logger.debug(inspect @instance) + #stats = Stats.get_stats() + + response = %{ + version: "2.0", + software: %{ + name: "eventos", + version: Keyword.get(@instance, :version) + }, + protocols: ["activitypub"], + services: %{ + inbound: [], + outbound: [] + }, + openRegistrations: Keyword.get(@instance, :registrations_open), + usage: %{ + users: %{ + #total: stats.user_count || 0 + total: Accounts.count_users() + }, + localPosts: Events.count_local_events(), + localComments: Events.count_local_comments(), + #localPosts: stats.status_count || 0 + }, + metadata: %{ + nodeName: Keyword.get(@instance, :name) + } + } + + conn + |> put_resp_header( + "content-type", + "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8" + ) + |> json(response) + end + + def nodeinfo(conn, _) do + conn + |> put_status(404) + |> json(%{error: "Nodeinfo schema version not handled"}) + end +end diff --git a/lib/eventos_web/controllers/outboxes_controller.ex b/lib/eventos_web/controllers/outboxes_controller.ex new file mode 100644 index 000000000..bc891e5e4 --- /dev/null +++ b/lib/eventos_web/controllers/outboxes_controller.ex @@ -0,0 +1,11 @@ +defmodule EventosWeb.OutboxesController do + + use EventosWeb, :controller + + def show(conn) do + account = Guardian.Plug.current_resource(conn).account + events = account.events + + render(conn, "index.json", events: events) + end +end diff --git a/lib/eventos_web/controllers/web_finger_controller.ex b/lib/eventos_web/controllers/web_finger_controller.ex new file mode 100644 index 000000000..5a63403a5 --- /dev/null +++ b/lib/eventos_web/controllers/web_finger_controller.ex @@ -0,0 +1,21 @@ +defmodule EventosWeb.WebFingerController do + use EventosWeb, :controller + + alias Eventos.Service.WebFinger + + def host_meta(conn, _params) do + xml = WebFinger.host_meta() + + conn + |> put_resp_content_type("application/xrd+xml") + |> send_resp(200, xml) + end + + def webfinger(conn, %{"resource" => resource}) do + with {:ok, response} <- WebFinger.webfinger(resource, "JSON") do + json(conn, response) + else + _e -> send_resp(conn, 404, "Couldn't find user") + end + end +end diff --git a/lib/eventos_web/http_signature.ex b/lib/eventos_web/http_signature.ex new file mode 100644 index 000000000..7d3b1725b --- /dev/null +++ b/lib/eventos_web/http_signature.ex @@ -0,0 +1,43 @@ +defmodule EventosWeb.HTTPSignaturePlug do + alias Eventos.Service.HTTPSignatures + import Plug.Conn + require Logger + + def init(options) do + options + end + + def call(%{assigns: %{valid_signature: true}} = conn, _opts) do + conn + end + + def call(conn, _opts) do + user = conn.params["actor"] + Logger.debug("Checking sig for #{user}") + with [signature | _] <- get_req_header(conn, "signature") do + cond do + signature && String.contains?(signature, user) -> + conn = + conn + |> put_req_header( + "(request-target)", + String.downcase("#{conn.method}") <> " #{conn.request_path}" + ) + + assign(conn, :valid_signature, HTTPSignatures.validate_conn(conn)) + + signature -> + Logger.debug("Signature not from actor") + assign(conn, :valid_signature, false) + + true -> + Logger.debug("No signature header!") + conn + end + else + _ -> + Logger.debug("No signature header!") + conn + end + end +end diff --git a/lib/eventos_web/router.ex b/lib/eventos_web/router.ex index 40e40dcda..ea6ddc19f 100644 --- a/lib/eventos_web/router.ex +++ b/lib/eventos_web/router.ex @@ -8,6 +8,15 @@ defmodule EventosWeb.Router do plug :accepts, ["json"] end + pipeline :well_known do + plug :accepts, ["json/application"] + end + + pipeline :activity_pub do + plug :accepts, ["activity-json"] + plug(EventosWeb.HTTPSignaturePlug) + end + pipeline :api_auth do plug :accepts, ["json"] plug EventosWeb.AuthPipeline @@ -24,43 +33,73 @@ defmodule EventosWeb.Router do scope "/api", EventosWeb do pipe_through :api - post "/users", UserController, :register - post "/login", UserSessionController, :sign_in - resources "/groups", GroupController, only: [:index, :show] - resources "/events", EventController, only: [:index, :show] - get "/events/:id/ics", EventController, :export_to_ics - get "/events/:id/tracks", TrackController, :show_tracks_for_event - get "/events/:id/sessions", SessionController, :show_sessions_for_event - resources "/accounts", AccountController, only: [:index, :show] - resources "/tags", TagController, only: [:index, :show] - resources "/categories", CategoryController, only: [:index, :show] - resources "/sessions", SessionController, only: [:index, :show] - resources "/tracks", TrackController, only: [:index, :show] - resources "/addresses", AddressController, only: [:index, :show] + scope "/v1" do + + post "/users", UserController, :register + post "/login", UserSessionController, :sign_in + resources "/groups", GroupController, only: [:index, :show] + resources "/events", EventController, only: [:index, :show] + resources "/comments", CommentController, only: [:show] + get "/events/:id/ics", EventController, :export_to_ics + get "/events/:id/tracks", TrackController, :show_tracks_for_event + get "/events/:id/sessions", SessionController, :show_sessions_for_event + resources "/accounts", AccountController, only: [:index, :show] + resources "/tags", TagController, only: [:index, :show] + resources "/categories", CategoryController, only: [:index, :show] + resources "/sessions", SessionController, only: [:index, :show] + resources "/tracks", TrackController, only: [:index, :show] + resources "/addresses", AddressController, only: [:index, :show] + end end # Other scopes may use custom stacks. scope "/api", EventosWeb do pipe_through :api_auth - get "/user", UserController, :show_current_account - post "/sign-out", UserSessionController, :sign_out - resources "/users", UserController, except: [:new, :edit, :show] - resources "/accounts", AccountController, except: [:new, :edit] - resources "/events", EventController - post "/events/:id/request", EventRequestController, :create_for_event - resources "/participants", ParticipantController - resources "/requests", EventRequestController - resources "/groups", GroupController, except: [:index, :show] - post "/groups/:id/request", GroupRequestController, :create_for_group - resources "/members", MemberController - resources "/requests", GroupRequestController - resources "/sessions", SessionController, except: [:index, :show] - resources "/tracks", TrackController, except: [:index, :show] - get "/tracks/:id/sessions", SessionController, :show_sessions_for_track - resources "/categories", CategoryController - resources "/tags", TagController - resources "/addresses", AddressController, except: [:index, :show] + scope "/v1" do + + get "/user", UserController, :show_current_account + post "/sign-out", UserSessionController, :sign_out + resources "/users", UserController, except: [:new, :edit, :show] + resources "/accounts", AccountController, except: [:new, :edit] + resources "/events", EventController + resources "/comments", CommentController, except: [:new, :edit] + post "/events/:id/request", EventRequestController, :create_for_event + resources "/participant", ParticipantController + resources "/requests", EventRequestController + resources "/groups", GroupController, except: [:index, :show] + post "/groups/:id/request", GroupRequestController, :create_for_group + resources "/members", MemberController + resources "/requests", GroupRequestController + resources "/sessions", SessionController, except: [:index, :show] + resources "/tracks", TrackController, except: [:index, :show] + get "/tracks/:id/sessions", SessionController, :show_sessions_for_track + resources "/categories", CategoryController + resources "/tags", TagController + resources "/addresses", AddressController, except: [:index, :show] + end + end + + scope "/.well-known", EventosWeb do + pipe_through :well_known + + get "/host-meta", WebFingerController, :host_meta + get "/webfinger", WebFingerController, :webfinger + get "/nodeinfo", NodeinfoController, :schemas + end + + scope "/nodeinfo", EventosWeb do + get("/:version", NodeinfoController, :nodeinfo) + end + + scope "/", EventosWeb do + pipe_through :activity_pub + + get "/@:username", ActivityPubController, :account + get "/@:username/outbox", ActivityPubController, :outbox + get "/@:username/:slug", ActivityPubController, :event + post "/@:username/inbox", ActivityPubController, :inbox + post "/inbox", ActivityPubController, :inbox end scope "/", EventosWeb do diff --git a/lib/eventos_web/views/account_view.ex b/lib/eventos_web/views/account_view.ex index 30d44bcbb..e2475f997 100644 --- a/lib/eventos_web/views/account_view.ex +++ b/lib/eventos_web/views/account_view.ex @@ -25,7 +25,6 @@ defmodule EventosWeb.AccountView do description: account.description, # public_key: account.public_key, suspended: account.suspended, - uri: account.uri, url: account.url, avatar_url: account.avatar_url, banner_url: account.banner_url, @@ -40,7 +39,6 @@ defmodule EventosWeb.AccountView do description: account.description, # public_key: account.public_key, suspended: account.suspended, - uri: account.uri, url: account.url, avatar_url: account.avatar_url, banner_url: account.banner_url, diff --git a/lib/eventos_web/views/activity_pub/account_view.ex b/lib/eventos_web/views/activity_pub/account_view.ex new file mode 100644 index 000000000..1163e541f --- /dev/null +++ b/lib/eventos_web/views/activity_pub/account_view.ex @@ -0,0 +1,117 @@ +defmodule EventosWeb.ActivityPub.AccountView do + use EventosWeb, :view + + alias EventosWeb.ActivityPub.AccountView + alias EventosWeb.ActivityPub.ObjectView + alias EventosWeb.WebFinger + alias Eventos.Accounts.Account + alias Eventos.Repo + alias Eventos.Service.ActivityPub + alias Eventos.Service.ActivityPub.Transmogrifier + alias Eventos.Service.ActivityPub.Utils + import Ecto.Query + + def render("account.json", %{account: account}) do + {:ok, public_key} = Account.get_public_key_for_account(account) + + %{ + "id" => account.url, + "type" => "Person", + #"following" => "#{account.url}/following", + #"followers" => "#{account.url}/followers", + "inbox" => "#{account.url}/inbox", + "outbox" => "#{account.url}/outbox", + "preferredUsername" => account.username, + "name" => account.display_name, + "summary" => account.description, + "url" => account.url, + #"manuallyApprovesFollowers" => false, + "publicKey" => %{ + "id" => "#{account.url}#main-key", + "owner" => account.url, + "publicKeyPem" => public_key + }, + "endpoints" => %{ + "sharedInbox" => "#{EventosWeb.Endpoint.url()}/inbox" + }, +# "icon" => %{ +# "type" => "Image", +# "url" => User.avatar_url(account) +# }, +# "image" => %{ +# "type" => "Image", +# "url" => User.banner_url(account) +# } + } + |> Map.merge(Utils.make_json_ld_header()) + end + + def render("outbox.json", %{account: account, page: page}) do + {page, no_page} = if page == 0 do + {1, true} + else + {page, false} + end + + {activities, total} = ActivityPub.fetch_public_activities_for_account(account, page) + + collection = + Enum.map(activities, fn act -> + {:ok, data} = Transmogrifier.prepare_outgoing(act.data) + data + end) + + iri = "#{account.url}/outbox" + + page = %{ + "id" => "#{iri}?page=#{page}", + "type" => "OrderedCollectionPage", + "partOf" => iri, + "totalItems" => total, + "orderedItems" => render_many(activities, AccountView, "activity.json", as: :activity), + "next" => "#{iri}?page=#{page + 1}" + } + + if no_page do + %{ + "id" => iri, + "type" => "OrderedCollection", + "totalItems" => total, + "first" => page + } + |> Map.merge(Utils.make_json_ld_header()) + else + page |> Map.merge(Utils.make_json_ld_header()) + end + end + + def render("activity.json", %{activity: activity}) do + %{ + "id" => activity.data.url <> "/activity", + "type" => "Create", + "actor" => activity.data.organizer_account.url, + "published" => Timex.now(), + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "object" => render_one(activity.data, ObjectView, "event.json", as: :event) + } + end + + def collection(collection, iri, page, total \\ nil) do + offset = (page - 1) * 10 + items = Enum.slice(collection, offset, 10) + items = Enum.map(items, fn user -> user.ap_id end) + total = total || length(collection) + + map = %{ + "id" => "#{iri}?page=#{page}", + "type" => "OrderedCollectionPage", + "partOf" => iri, + "totalItems" => total, + "orderedItems" => items + } + + if offset < total do + Map.put(map, "next", "#{iri}?page=#{page + 1}") + end + end +end diff --git a/lib/eventos_web/views/activity_pub/object_view.ex b/lib/eventos_web/views/activity_pub/object_view.ex new file mode 100644 index 000000000..40147ca6a --- /dev/null +++ b/lib/eventos_web/views/activity_pub/object_view.ex @@ -0,0 +1,37 @@ +defmodule EventosWeb.ActivityPub.ObjectView do + use EventosWeb, :view + alias Eventos.Service.ActivityPub.Transmogrifier + @base %{ + "@context" => [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + %{ + "manuallyApprovesFollowers" => "as:manuallyApprovesFollowers", + "sensitive" => "as:sensitive", + "Hashtag" => "as:Hashtag", + "toot" => "http://joinmastodon.org/ns#", + "Emoji" => "toot:Emoji" + } + ] + } + + def render("event.json", %{event: event}) do + + + event = %{ + "type" => "Event", + "id" => event.url, + "name" => event.title, + "category" => %{"title" => event.category.title}, + "content" => event.description, + "mediaType" => "text/markdown", + "published" => Timex.format!(event.inserted_at, "{ISO:Extended}"), + "updated" => Timex.format!(event.updated_at, "{ISO:Extended}"), + } + Map.merge(event, @base) + end + + def render("category.json", %{category: category}) do + category + end +end diff --git a/lib/eventos_web/views/comment_view.ex b/lib/eventos_web/views/comment_view.ex new file mode 100644 index 000000000..4dd58edbd --- /dev/null +++ b/lib/eventos_web/views/comment_view.ex @@ -0,0 +1,18 @@ +defmodule EventosWeb.CommentView do + use EventosWeb, :view + alias EventosWeb.CommentView + + def render("index.json", %{comments: comments}) do + %{data: render_many(comments, CommentView, "comment.json")} + end + + def render("show.json", %{comment: comment}) do + %{data: render_one(comment, CommentView, "comment.json")} + end + + def render("comment.json", %{comment: comment}) do + %{id: comment.id, + url: comment.url, + text: comment.text} + end +end diff --git a/lib/eventos_web/views/group_view.ex b/lib/eventos_web/views/group_view.ex index b00a7c725..1e454a148 100644 --- a/lib/eventos_web/views/group_view.ex +++ b/lib/eventos_web/views/group_view.ex @@ -23,7 +23,6 @@ defmodule EventosWeb.GroupView do description: group.description, suspended: group.suspended, url: group.url, - uri: group.uri } end @@ -33,7 +32,6 @@ defmodule EventosWeb.GroupView do description: group.description, suspended: group.suspended, url: group.url, - uri: group.uri, members: render_many(group.members, AccountView, "acccount_basic.json"), events: render_many(group.organized_events, EventView, "event_simple.json") } diff --git a/lib/service/activity_pub/activity_pub.ex b/lib/service/activity_pub/activity_pub.ex new file mode 100644 index 000000000..9c775bc30 --- /dev/null +++ b/lib/service/activity_pub/activity_pub.ex @@ -0,0 +1,276 @@ +defmodule Eventos.Service.ActivityPub do + alias Eventos.Events + alias Eventos.Events.Event + alias Eventos.Service.ActivityPub.Transmogrifier + alias Eventos.Service.WebFinger + alias Eventos.Activity + + alias Eventos.Accounts + alias Eventos.Accounts.Account + + alias Eventos.Service.Federator + + import Logger + import Eventos.Service.ActivityPub.Utils + + def get_recipients(data) do + (data["to"] || []) ++ (data["cc"] || []) + end + + def insert(map, local \\ true) when is_map(map) do + with map <- lazy_put_activity_defaults(map), + :ok <- insert_full_object(map) do + map = Map.put(map, "id", Ecto.UUID.generate()) + activity = %Activity{ + data: map, + local: local, + actor: map["actor"], + recipients: get_recipients(map) + } + + # Notification.create_notifications(activity) + #stream_out(activity) + {:ok, activity} + else + %Activity{} = activity -> {:ok, activity} + error -> {:error, error} + end + end + + def fetch_event_from_url(url) do + if object = Events.get_event_by_url!(url) do + {:ok, object} + else + Logger.info("Fetching #{url} via AP") + + with true <- String.starts_with?(url, "http"), + {:ok, %{body: body, status_code: code}} when code in 200..299 <- + HTTPoison.get( + url, + [Accept: "application/activity+json"], + follow_redirect: true, + timeout: 10000, + recv_timeout: 20000 + ), + {:ok, data} <- Jason.decode(body), + nil <- Events.get_event_by_url!(data["id"]), + params <- %{ + "type" => "Create", + "to" => data["to"], + "cc" => data["cc"], + "actor" => data["attributedTo"], + "object" => data + }, + {:ok, activity} <- Transmogrifier.handle_incoming(params) do + {:ok, Events.get_event_by_url!(activity.data["object"]["id"])} + else + object = %Event{} -> {:ok, object} + e -> e + end + end + end + + def create(%{to: to, actor: actor, context: context, object: object} = params) do + additional = params[:additional] || %{} + # only accept false as false value + local = !(params[:local] == false) + published = params[:published] + + with create_data <- + make_create_data( + %{to: to, actor: actor, published: published, context: context, object: object}, + additional + ), + {:ok, activity} <- insert(create_data, local), + :ok <- maybe_federate(activity) do + # {:ok, actor} <- Accounts.increase_event_count(actor) do + {:ok, activity} + end + end + + def accept(%{to: to, actor: actor, object: object} = params) do + # only accept false as false value + local = !(params[:local] == false) + + with data <- %{"to" => to, "type" => "Accept", "actor" => actor, "object" => object}, + {:ok, activity} <- insert(data, local), + :ok <- maybe_federate(activity) do + {:ok, activity} + end + end + + def update(%{to: to, cc: cc, actor: actor, object: object} = params) do + # only accept false as false value + local = !(params[:local] == false) + + with data <- %{ + "to" => to, + "cc" => cc, + "type" => "Update", + "actor" => actor, + "object" => object + }, + {:ok, activity} <- insert(data, local), + :ok <- maybe_federate(activity) do + {:ok, activity} + end + end + + def follow(follower, followed, activity_id \\ nil, local \\ true) do + with data <- make_follow_data(follower, followed, activity_id), + {:ok, activity} <- insert(data, local), + :ok <- maybe_federate(activity) do + {:ok, activity} + end + end + + def delete(%Event{url: url, organizer_account: account} = event, local \\ true) do + + data = %{ + "type" => "Delete", + "actor" => account.url, + "object" => url, + "to" => [account.url <> "/followers", "https://www.w3.org/ns/activitystreams#Public"] + } + + with Events.delete_event(event), + {:ok, activity} <- insert(data, local), + :ok <- maybe_federate(activity) + do + {:ok, activity} + end + end + + def create_public_activities(%Account{} = account) do + + end + + def make_account_from_url(url) do + with {:ok, data} <- fetch_and_prepare_user_from_url(url) do + Accounts.insert_or_update_account(data) + else + e -> + Logger.error("Failed to make account from url") + Logger.error(inspect e) + {:error, e} + end + end + + def make_account_from_nickname(nickname) do + with {:ok, %{"url" => url}} when not is_nil(url) <- WebFinger.finger(nickname) do + make_account_from_url(url) + else + _e -> {:error, "No ActivityPub URL found in WebFinger"} + end + end + + def publish(actor, activity) do +# followers = +# if actor.follower_address in activity.recipients do +# {:ok, followers} = User.get_followers(actor) +# followers |> Enum.filter(&(!&1.local)) +# else +# [] +# end + followers = ["http://localhost:3000/users/tcit/inbox"] + + remote_inboxes = followers + + {:ok, data} = Transmogrifier.prepare_outgoing(activity.data) + json = Jason.encode!(data) + + Enum.each(remote_inboxes, fn inbox -> + Federator.enqueue(:publish_single_ap, %{ + inbox: inbox, + json: json, + actor: actor, + id: activity.data["id"] + }) + end) + end + + def publish_one(%{inbox: inbox, json: json, actor: actor, id: id}) do + Logger.info("Federating #{id} to #{inbox}") + host = URI.parse(inbox).host + + signature = + Eventos.Service.HTTPSignatures.sign(actor, %{host: host, "content-length": byte_size(json)}) + Logger.debug("signature") + Logger.debug(inspect signature) + + {:ok, response} = HTTPoison.post( + inbox, + json, + [{"Content-Type", "application/activity+json"}, {"signature", signature}], + hackney: [pool: :default] + ) + Logger.debug(inspect response) + end + + def fetch_and_prepare_user_from_url(url) do + Logger.debug("Fetching and preparing user from url") + with {:ok, %{status_code: 200, body: body}} <- + HTTPoison.get(url, [Accept: "application/activity+json"], [follow_redirect: true]), + {:ok, data} <- Jason.decode(body) do + user_data_from_user_object(data) + else + e -> Logger.error("Could not decode user at fetch #{url}, #{inspect(e)}") + end + end + + def user_data_from_user_object(data) do + avatar = + data["icon"]["url"] && + %{ + "type" => "Image", + "url" => [%{"href" => data["icon"]["url"]}] + } + + banner = + data["image"]["url"] && + %{ + "type" => "Image", + "url" => [%{"href" => data["image"]["url"]}] + } + + user_data = %{ + url: data["id"], + info: %{ + "ap_enabled" => true, + "source_data" => data, + "banner" => banner + }, + avatar: avatar, + username: "#{data["preferredUsername"]}@#{URI.parse(data["id"]).host}", + display_name: data["name"], + follower_address: data["followers"], + description: data["summary"], + public_key: data["publicKey"]["publicKeyPem"], + } + + {:ok, user_data} + end + + @spec fetch_public_activities_for_account(Account.t, integer(), integer()) :: list() + def fetch_public_activities_for_account(%Account{} = account, page \\ 10, limit \\ 1) do + {:ok, events, total} = Events.get_events_for_account(account, page, limit) + activities = Enum.map(events, fn event -> + {:ok, activity} = event_to_activity(event) + activity + end) + {activities, total} + end + + defp event_to_activity(%Event{} = event) do + activity = %Activity{ + data: event, + local: true, + actor: event.organizer_account.url, + recipients: ["https://www.w3.org/ns/activitystreams#Public"] + } + + # Notification.create_notifications(activity) + #stream_out(activity) + {:ok, activity} + end +end diff --git a/lib/service/activity_pub/transmogrifier.ex b/lib/service/activity_pub/transmogrifier.ex new file mode 100644 index 000000000..774293025 --- /dev/null +++ b/lib/service/activity_pub/transmogrifier.ex @@ -0,0 +1,482 @@ +defmodule Eventos.Service.ActivityPub.Transmogrifier do + @moduledoc """ + A module to handle coding from internal to wire ActivityPub and back. + """ + alias Eventos.Accounts.Account + alias Eventos.Accounts + alias Eventos.Events.Event + alias Eventos.Service.ActivityPub + + import Ecto.Query + + require Logger + + @doc """ + Modifies an incoming AP object (mastodon format) to our internal format. + """ + def fix_object(object) do + object + |> Map.put("actor", object["attributedTo"]) + |> fix_attachments + |> fix_context + #|> fix_in_reply_to + |> fix_emoji + |> fix_tag + end + +# def fix_in_reply_to(%{"inReplyTo" => in_reply_to_id} = object) +# when not is_nil(in_reply_to_id) do +# case ActivityPub.fetch_object_from_id(in_reply_to_id) do +# {:ok, replied_object} -> +# activity = Activity.get_create_activity_by_object_ap_id(replied_object.data["id"]) +# +# object +# |> Map.put("inReplyTo", replied_object.data["id"]) +# |> Map.put("inReplyToAtomUri", object["inReplyToAtomUri"] || in_reply_to_id) +# |> Map.put("inReplyToStatusId", activity.id) +# |> Map.put("conversation", replied_object.data["context"] || object["conversation"]) +# |> Map.put("context", replied_object.data["context"] || object["conversation"]) +# +# e -> +# Logger.error("Couldn't fetch #{object["inReplyTo"]} #{inspect(e)}") +# object +# end +# end + + def fix_in_reply_to(object), do: object + + def fix_context(object) do + object + |> Map.put("context", object["conversation"]) + end + + def fix_attachments(object) do + attachments = + (object["attachment"] || []) + |> Enum.map(fn data -> + url = [%{"type" => "Link", "mediaType" => data["mediaType"], "href" => data["url"]}] + Map.put(data, "url", url) + end) + + object + |> Map.put("attachment", attachments) + end + + def fix_emoji(object) do + tags = object["tag"] || [] + emoji = tags |> Enum.filter(fn data -> data["type"] == "Emoji" and data["icon"] end) + + emoji = + emoji + |> Enum.reduce(%{}, fn data, mapping -> + name = data["name"] + + if String.starts_with?(name, ":") do + name = name |> String.slice(1..-2) + end + + mapping |> Map.put(name, data["icon"]["url"]) + end) + + # we merge mastodon and pleroma emoji into a single mapping, to allow for both wire formats + emoji = Map.merge(object["emoji"] || %{}, emoji) + + object + |> Map.put("emoji", emoji) + end + + def fix_tag(object) do + tags = + (object["tag"] || []) + |> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end) + |> Enum.map(fn data -> String.slice(data["name"], 1..-1) end) + + combined = (object["tag"] || []) ++ tags + + object + |> Map.put("tag", combined) + end + + # TODO: validate those with a Ecto scheme + # - tags + # - emoji + def handle_incoming(%{"type" => "Create", "object" => %{"type" => "Note"} = object} = data) do + with %Account{} = account <- Account.get_or_fetch_by_url(data["actor"]) do + object = fix_object(data["object"]) + + params = %{ + to: data["to"], + object: object, + actor: account, + context: object["conversation"], + local: false, + published: data["published"], + additional: + Map.take(data, [ + "cc", + "id" + ]) + } + + ActivityPub.create(params) + end + end + + def handle_incoming( + %{"type" => "Follow", "object" => followed, "actor" => follower, "id" => id} = data + ) do + with %Account{} = followed <- Accounts.get_account_by_url(followed), + %Account{} = follower <- Accounts.get_or_fetch_by_url(follower), + {:ok, activity} <- ActivityPub.follow(follower, followed, id, false) do + ActivityPub.accept(%{to: [follower.url], actor: followed.url, object: data, local: true}) + + #Accounts.follow(follower, followed) + {:ok, activity} + else + _e -> :error + end + end +# +# def handle_incoming( +# %{"type" => "Like", "object" => object_id, "actor" => actor, "id" => id} = data +# ) do +# with %User{} = actor <- User.get_or_fetch_by_ap_id(actor), +# {:ok, object} <- +# get_obj_helper(object_id) || ActivityPub.fetch_object_from_id(object_id), +# {:ok, activity, object} <- ActivityPub.like(actor, object, id, false) do +# {:ok, activity} +# else +# _e -> :error +# end +# end +# +# def handle_incoming( +# %{"type" => "Announce", "object" => object_id, "actor" => actor, "id" => id} = data +# ) do +# with %User{} = actor <- User.get_or_fetch_by_ap_id(actor), +# {:ok, object} <- +# get_obj_helper(object_id) || ActivityPub.fetch_object_from_id(object_id), +# {:ok, activity, object} <- ActivityPub.announce(actor, object, id, false) do +# {:ok, activity} +# else +# _e -> :error +# end +# end +# +# def handle_incoming( +# %{"type" => "Update", "object" => %{"type" => "Person"} = object, "actor" => actor_id} = +# data +# ) do +# with %User{ap_id: ^actor_id} = actor <- User.get_by_ap_id(object["id"]) do +# {:ok, new_user_data} = ActivityPub.user_data_from_user_object(object) +# +# banner = new_user_data[:info]["banner"] +# +# update_data = +# new_user_data +# |> Map.take([:name, :bio, :avatar]) +# |> Map.put(:info, Map.merge(actor.info, %{"banner" => banner})) +# +# actor +# |> User.upgrade_changeset(update_data) +# |> User.update_and_set_cache() +# +# ActivityPub.update(%{ +# local: false, +# to: data["to"] || [], +# cc: data["cc"] || [], +# object: object, +# actor: actor_id +# }) +# else +# e -> +# Logger.error(e) +# :error +# end +# end +# +# # TODO: Make secure. +# def handle_incoming( +# %{"type" => "Delete", "object" => object_id, "actor" => actor, "id" => id} = data +# ) do +# object_id = +# case object_id do +# %{"id" => id} -> id +# id -> id +# end +# +# with %User{} = actor <- User.get_or_fetch_by_ap_id(actor), +# {:ok, object} <- +# get_obj_helper(object_id) || ActivityPub.fetch_object_from_id(object_id), +# {:ok, activity} <- ActivityPub.delete(object, false) do +# {:ok, activity} +# else +# e -> :error +# end +# end +# +# # TODO +# # Accept +# # Undo +# +# def handle_incoming(_), do: :error +# +# def get_obj_helper(id) do +# if object = Object.get_by_ap_id(id), do: {:ok, object}, else: nil +# end +# +# def set_reply_to_uri(%{"inReplyTo" => inReplyTo} = object) do +# with false <- String.starts_with?(inReplyTo, "http"), +# {:ok, %{data: replied_to_object}} <- get_obj_helper(inReplyTo) do +# Map.put(object, "inReplyTo", replied_to_object["external_url"] || inReplyTo) +# else +# _e -> object +# end +# end +# +# def set_reply_to_uri(obj), do: obj +# +# # Prepares the object of an outgoing create activity. +# def prepare_object(object) do +# object +# |> set_sensitive +# |> add_hashtags +# |> add_mention_tags +# |> add_emoji_tags +# |> add_attributed_to +# |> prepare_attachments +# |> set_conversation +# |> set_reply_to_uri +# end + + @doc + """ + internal -> Mastodon + """ + + def prepare_outgoing(%{"type" => "Create", "object" => %{"type" => "Note"} = object} = data) do + object = + object + #|> prepare_object + + data = + data + |> Map.put("object", object) + |> Map.put("@context", "https://www.w3.org/ns/activitystreams") + + {:ok, data} + end + + def prepare_outgoing(%{"type" => type} = data) do + data = + data + #|> maybe_fix_object_url + |> Map.put("@context", "https://www.w3.org/ns/activitystreams") + + {:ok, data} + end + + def prepare_outgoing(%Event{} = event) do + event = + event + |> Map.from_struct + |> Map.drop([:"__meta__"]) + |> Map.put(:"@context", "https://www.w3.org/ns/activitystreams") + + {:ok, event} + end +# +# def maybe_fix_object_url(data) do +# if is_binary(data["object"]) and not String.starts_with?(data["object"], "http") do +# case ActivityPub.fetch_object_from_id(data["object"]) do +# {:ok, relative_object} -> +# if relative_object.data["external_url"] do +# data = +# data +# |> Map.put("object", relative_object.data["external_url"]) +# else +# data +# end +# +# e -> +# Logger.error("Couldn't fetch #{data["object"]} #{inspect(e)}") +# data +# end +# else +# data +# end +# end +# +# def add_hashtags(object) do +# tags = +# (object["tag"] || []) +# |> Enum.map(fn tag -> +# %{ +# "href" => Pleroma.Web.Endpoint.url() <> "/tags/#{tag}", +# "name" => "##{tag}", +# "type" => "Hashtag" +# } +# end) +# +# object +# |> Map.put("tag", tags) +# end +# +# def add_mention_tags(object) do +# recipients = object["to"] ++ (object["cc"] || []) +# +# mentions = +# recipients +# |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end) +# |> Enum.filter(& &1) +# |> Enum.map(fn user -> +# %{"type" => "Mention", "href" => user.ap_id, "name" => "@#{user.nickname}"} +# end) +# +# tags = object["tag"] || [] +# +# object +# |> Map.put("tag", tags ++ mentions) +# end +# +# # TODO: we should probably send mtime instead of unix epoch time for updated +# def add_emoji_tags(object) do +# tags = object["tag"] || [] +# emoji = object["emoji"] || [] +# +# out = +# emoji +# |> Enum.map(fn {name, url} -> +# %{ +# "icon" => %{"url" => url, "type" => "Image"}, +# "name" => ":" <> name <> ":", +# "type" => "Emoji", +# "updated" => "1970-01-01T00:00:00Z", +# "id" => url +# } +# end) +# +# object +# |> Map.put("tag", tags ++ out) +# end +# +# def set_conversation(object) do +# Map.put(object, "conversation", object["context"]) +# end +# +# def set_sensitive(object) do +# tags = object["tag"] || [] +# Map.put(object, "sensitive", "nsfw" in tags) +# end +# +# def add_attributed_to(object) do +# attributedTo = object["attributedTo"] || object["actor"] +# +# object +# |> Map.put("attributedTo", attributedTo) +# end +# +# def prepare_attachments(object) do +# attachments = +# (object["attachment"] || []) +# |> Enum.map(fn data -> +# [%{"mediaType" => media_type, "href" => href} | _] = data["url"] +# %{"url" => href, "mediaType" => media_type, "name" => data["name"], "type" => "Document"} +# end) +# +# object +# |> Map.put("attachment", attachments) +# end +# +# defp user_upgrade_task(user) do +# old_follower_address = User.ap_followers(user) +# +# q = +# from( +# u in User, +# where: ^old_follower_address in u.following, +# update: [ +# set: [ +# following: +# fragment( +# "array_replace(?,?,?)", +# u.following, +# ^old_follower_address, +# ^user.follower_address +# ) +# ] +# ] +# ) +# +# Repo.update_all(q, []) +# +# maybe_retire_websub(user.ap_id) +# +# # Only do this for recent activties, don't go through the whole db. +# # Only look at the last 1000 activities. +# since = (Repo.aggregate(Activity, :max, :id) || 0) - 1_000 +# +# q = +# from( +# a in Activity, +# where: ^old_follower_address in a.recipients, +# where: a.id > ^since, +# update: [ +# set: [ +# recipients: +# fragment( +# "array_replace(?,?,?)", +# a.recipients, +# ^old_follower_address, +# ^user.follower_address +# ) +# ] +# ] +# ) +# +# Repo.update_all(q, []) +# end +# +# def upgrade_user_from_ap_id(ap_id, async \\ true) do +# with %User{local: false} = user <- User.get_by_ap_id(ap_id), +# {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id) do +# data = +# data +# |> Map.put(:info, Map.merge(user.info, data[:info])) +# +# already_ap = User.ap_enabled?(user) +# +# {:ok, user} = +# User.upgrade_changeset(user, data) +# |> Repo.update() +# +# if !already_ap do +# # This could potentially take a long time, do it in the background +# if async do +# Task.start(fn -> +# user_upgrade_task(user) +# end) +# else +# user_upgrade_task(user) +# end +# end +# +# {:ok, user} +# else +# e -> e +# end +# end +# +# def maybe_retire_websub(ap_id) do +# # some sanity checks +# if is_binary(ap_id) && String.length(ap_id) > 8 do +# q = +# from( +# ws in Pleroma.Web.Websub.WebsubClientSubscription, +# where: fragment("? like ?", ws.topic, ^"#{ap_id}%") +# ) +# +# Repo.delete_all(q) +# end +# end +end diff --git a/lib/service/activity_pub/utils.ex b/lib/service/activity_pub/utils.ex new file mode 100644 index 000000000..6cd73bda2 --- /dev/null +++ b/lib/service/activity_pub/utils.ex @@ -0,0 +1,304 @@ +defmodule Eventos.Service.ActivityPub.Utils do + alias Eventos.Repo + alias Eventos.Accounts + alias Eventos.Accounts.Account + alias Eventos.Events.Event + alias Eventos.Events + alias Eventos.Activity + alias EventosWeb + alias EventosWeb.Router.Helpers + alias EventosWeb.Endpoint + alias Ecto.{Changeset, UUID} + import Ecto.Query + + def make_json_ld_header do + %{ + "@context" => [ + "https://www.w3.org/ns/activitystreams", + %{ + "manuallyApprovesFollowers" => "as:manuallyApprovesFollowers", + "sensitive" => "as:sensitive", + "Hashtag" => "as:Hashtag", + "toot" => "http://joinmastodon.org/ns#", + "Emoji" => "toot:Emoji" + } + ] + } + end + + def make_date do + DateTime.utc_now() |> DateTime.to_iso8601() + end + + def generate_activity_id do + generate_id("activities") + end + + def generate_context_id do + generate_id("contexts") + end + +# def generate_object_id do +# Helpers.o_status_url(Endpoint, :object, UUID.generate()) +# end + + def generate_id(type) do + "#{EventosWeb.Endpoint.url()}/#{type}/#{UUID.generate()}" + end + +# def create_context(context) do +# context = context || generate_id("contexts") +# changeset = Object.context_mapping(context) +# +# case Repo.insert(changeset) do +# {:ok, object} -> +# object +# +# # This should be solved by an upsert, but it seems ecto +# # has problems accessing the constraint inside the jsonb. +# {:error, _} -> +# Events.get_cached_by_url(context) +# end +# end + + @doc """ + Enqueues an activity for federation if it's local + """ + def maybe_federate(%Activity{local: true} = activity) do + priority = + case activity.data["type"] do + "Delete" -> 10 + "Create" -> 1 + _ -> 5 + end + + Eventos.Service.Federator.enqueue(:publish, activity, priority) + :ok + end + + def maybe_federate(_), do: :ok + + @doc """ + Adds an id and a published data if they aren't there, + also adds it to an included object + """ + def lazy_put_activity_defaults(map) do +# %{data: %{"id" => context}, id: context_id} = create_context(map["context"]) +# +# map = +# map +# |> Map.put_new_lazy("id", &generate_activity_id/0) +# |> Map.put_new_lazy("published", &make_date/0) +# |> Map.put_new("context", context) +# |> Map.put_new("context_id", context_id) + + if is_map(map["object"]) do + object = lazy_put_object_defaults(map["object"], map) + %{map | "object" => object} + else + map + end + end + + @doc """ + Adds an id and published date if they aren't there. + """ + def lazy_put_object_defaults(map, activity \\ %{}) do + map + #|> Map.put_new_lazy("id", &generate_object_id/0) + |> Map.put_new_lazy("published", &make_date/0) + |> Map.put_new("context", activity["context"]) + |> Map.put_new("context_id", activity["context_id"]) + end + + @doc """ + Inserts a full object if it is contained in an activity. + """ + def insert_full_object(%{"object" => %{"type" => type} = object_data}) + when is_map(object_data) and type == "Event" do + with {:ok, _} <- Events.create_event(object_data) do + :ok + end + end + + @doc """ + Inserts a full object if it is contained in an activity. + """ + def insert_full_object(%{"object" => %{"type" => type} = object_data}) + when is_map(object_data) and type == "Note" do + account = Accounts.get_account_by_url(object_data["actor"]) + data = %{"text" => object_data["content"], "url" => object_data["url"], "account_id" => account.id, "in_reply_to_comment_id" => object_data["inReplyTo"]} + with {:ok, _} <- Events.create_comment(data) do + :ok + end + end + + def insert_full_object(_), do: :ok + +# def update_object_in_activities(%{data: %{"id" => id}} = object) do +# # TODO +# # Update activities that already had this. Could be done in a seperate process. +# # Alternatively, just don't do this and fetch the current object each time. Most +# # could probably be taken from cache. +# relevant_activities = Activity.all_by_object_url(id) +# +# Enum.map(relevant_activities, fn activity -> +# new_activity_data = activity.data |> Map.put("object", object.data) +# changeset = Changeset.change(activity, data: new_activity_data) +# Repo.update(changeset) +# end) +# end + + #### Like-related helpers + +# @doc """ +# Returns an existing like if a user already liked an object +# """ +# def get_existing_like(actor, %{data: %{"id" => id}}) do +# query = +# from( +# activity in Activity, +# where: fragment("(?)->>'actor' = ?", activity.data, ^actor), +# # this is to use the index +# where: +# fragment( +# "coalesce((?)->'object'->>'id', (?)->>'object') = ?", +# activity.data, +# activity.data, +# ^id +# ), +# where: fragment("(?)->>'type' = 'Like'", activity.data) +# ) +# +# Repo.one(query) +# end + + def make_like_data(%Account{url: url} = actor, %{data: %{"id" => id}} = object, activity_id) do + data = %{ + "type" => "Like", + "actor" => url, + "object" => id, + "to" => [actor.follower_address, object.data["actor"]], + "cc" => ["https://www.w3.org/ns/activitystreams#Public"], + "context" => object.data["context"] + } + + if activity_id, do: Map.put(data, "id", activity_id), else: data + end + + def update_element_in_object(property, element, object) do + with new_data <- + object.data + |> Map.put("#{property}_count", length(element)) + |> Map.put("#{property}s", element), + changeset <- Changeset.change(object, data: new_data), + {:ok, object} <- Repo.update(changeset) do + {:ok, object} + end + end + +# def update_likes_in_object(likes, object) do +# update_element_in_object("like", likes, object) +# end +# +# def add_like_to_object(%Activity{data: %{"actor" => actor}}, object) do +# with likes <- [actor | object.data["likes"] || []] |> Enum.uniq() do +# update_likes_in_object(likes, object) +# end +# end +# +# def remove_like_from_object(%Activity{data: %{"actor" => actor}}, object) do +# with likes <- (object.data["likes"] || []) |> List.delete(actor) do +# update_likes_in_object(likes, object) +# end +# end + + #### Follow-related helpers + + @doc """ + Makes a follow activity data for the given follower and followed + """ + def make_follow_data(%Account{url: follower_id}, %Account{url: followed_id}, activity_id) do + data = %{ + "type" => "Follow", + "actor" => follower_id, + "to" => [followed_id], + "cc" => ["https://www.w3.org/ns/activitystreams#Public"], + "object" => followed_id + } + + if activity_id, do: Map.put(data, "id", activity_id), else: data + end + +# def fetch_latest_follow(%Account{url: follower_id}, %Account{url: followed_id}) do +# query = +# from( +# activity in Activity, +# where: +# fragment( +# "? @> ?", +# activity.data, +# ^%{type: "Follow", actor: follower_id, object: followed_id} +# ), +# order_by: [desc: :id], +# limit: 1 +# ) +# +# Repo.one(query) +# end + + #### Announce-related helpers + + @doc """ + Make announce activity data for the given actor and object + """ + def make_announce_data( + %Account{url: url} = user, + %Event{id: id} = object, + activity_id + ) do + data = %{ + "type" => "Announce", + "actor" => url, + "object" => id, + "to" => [user.follower_address, object.data["actor"]], + "cc" => ["https://www.w3.org/ns/activitystreams#Public"], + "context" => object.data["context"] + } + + if activity_id, do: Map.put(data, "id", activity_id), else: data + end + + def add_announce_to_object(%Activity{data: %{"actor" => actor}}, object) do + with announcements <- [actor | object.data["announcements"] || []] |> Enum.uniq() do + update_element_in_object("announcement", announcements, object) + end + end + + #### Unfollow-related helpers + + def make_unfollow_data(follower, followed, follow_activity) do + %{ + "type" => "Undo", + "actor" => follower.url, + "to" => [followed.url], + "object" => follow_activity.data["id"] + } + end + + #### Create-related helpers + + def make_create_data(params, additional) do + published = params.published || make_date() + + %{ + "type" => "Create", + "to" => params.to |> Enum.uniq(), + "actor" => params.actor.url, + "object" => params.object, + "published" => published, + "context" => params.context + } + |> Map.merge(additional) + end +end diff --git a/lib/service/federator.ex b/lib/service/federator.ex new file mode 100644 index 000000000..fbc240b65 --- /dev/null +++ b/lib/service/federator.ex @@ -0,0 +1,126 @@ +defmodule Eventos.Service.Federator do + use GenServer + alias Eventos.Accounts + alias Eventos.Activity + alias Eventos.Service.ActivityPub + alias Eventos.Service.ActivityPub.Transmogrifier + require Logger + + @max_jobs 20 + + def init(args) do + {:ok, args} + end + + def start_link do + + spawn(fn -> + # 1 minute + Process.sleep(1000 * 60 * 1) + end) + + GenServer.start_link( + __MODULE__, + %{ + in: {:sets.new(), []}, + out: {:sets.new(), []} + }, + name: __MODULE__ + ) + end + + def handle(:publish, activity) do + Logger.debug(inspect activity) + Logger.debug(fn -> "Running publish for #{activity.data["id"]}" end) + + with actor when not is_nil(actor) <- Accounts.get_account_by_url(activity.data["actor"]) do + + Logger.info(fn -> "Sending #{activity.data["id"]} out via AP" end) + ActivityPub.publish(actor, activity) + end + end + + def handle(:incoming_ap_doc, params) do + Logger.info("Handling incoming AP activity") + Logger.debug(inspect params) + + with {:ok, _activity} <- Transmogrifier.handle_incoming(params) do + else + %Activity{} -> + Logger.info("Already had #{params["id"]}") + + _e -> + # Just drop those for now + Logger.info("Unhandled activity") + Logger.info(Poison.encode!(params, pretty: 2)) + end + end + + def handle(:publish_single_ap, params) do + ActivityPub.publish_one(params) + end + + def handle(type, _) do + Logger.debug(fn -> "Unknown task: #{type}" end) + {:error, "Don't know what to do with this"} + end + + def enqueue(type, payload, priority \\ 1) do + Logger.debug("enqueue") + if Mix.env() == :test do + handle(type, payload) + else + GenServer.cast(__MODULE__, {:enqueue, type, payload, priority}) + end + end + + def maybe_start_job(running_jobs, queue) do + if :sets.size(running_jobs) < @max_jobs && queue != [] do + {{type, payload}, queue} = queue_pop(queue) + {:ok, pid} = Task.start(fn -> handle(type, payload) end) + mref = Process.monitor(pid) + {:sets.add_element(mref, running_jobs), queue} + else + {running_jobs, queue} + end + end + + def handle_cast({:enqueue, type, payload, _priority}, state) + when type in [:incoming_doc, :incoming_ap_doc] do + %{in: {i_running_jobs, i_queue}, out: {o_running_jobs, o_queue}} = state + i_queue = enqueue_sorted(i_queue, {type, payload}, 1) + {i_running_jobs, i_queue} = maybe_start_job(i_running_jobs, i_queue) + {:noreply, %{in: {i_running_jobs, i_queue}, out: {o_running_jobs, o_queue}}} + end + + def handle_cast({:enqueue, type, payload, _priority}, state) do + %{in: {i_running_jobs, i_queue}, out: {o_running_jobs, o_queue}} = state + o_queue = enqueue_sorted(o_queue, {type, payload}, 1) + {o_running_jobs, o_queue} = maybe_start_job(o_running_jobs, o_queue) + {:noreply, %{in: {i_running_jobs, i_queue}, out: {o_running_jobs, o_queue}}} + end + + def handle_cast(m, state) do + IO.inspect("Unknown: #{inspect(m)}, #{inspect(state)}") + {:noreply, state} + end + + def handle_info({:DOWN, ref, :process, _pid, _reason}, state) do + %{in: {i_running_jobs, i_queue}, out: {o_running_jobs, o_queue}} = state + i_running_jobs = :sets.del_element(ref, i_running_jobs) + o_running_jobs = :sets.del_element(ref, o_running_jobs) + {i_running_jobs, i_queue} = maybe_start_job(i_running_jobs, i_queue) + {o_running_jobs, o_queue} = maybe_start_job(o_running_jobs, o_queue) + + {:noreply, %{in: {i_running_jobs, i_queue}, out: {o_running_jobs, o_queue}}} + end + + def enqueue_sorted(queue, element, priority) do + [%{item: element, priority: priority} | queue] + |> Enum.sort_by(fn %{priority: priority} -> priority end) + end + + def queue_pop([%{item: element} | queue]) do + {element, queue} + end +end diff --git a/lib/service/http_signatures/http_signatures.ex b/lib/service/http_signatures/http_signatures.ex new file mode 100644 index 000000000..14dcc32b8 --- /dev/null +++ b/lib/service/http_signatures/http_signatures.ex @@ -0,0 +1,112 @@ +# https://tools.ietf.org/html/draft-cavage-http-signatures-08 +defmodule Eventos.Service.HTTPSignatures do + alias Eventos.Accounts.Account + alias Eventos.Service.ActivityPub + require Logger + + def split_signature(sig) do + default = %{"headers" => "date"} + + sig = + sig + |> String.trim() + |> String.split(",") + |> Enum.reduce(default, fn part, acc -> + [key | rest] = String.split(part, "=") + value = Enum.join(rest, "=") + Map.put(acc, key, String.trim(value, "\"")) + end) + + Map.put(sig, "headers", String.split(sig["headers"], ~r/\s/)) + end + + def validate(headers, signature, public_key) do + sigstring = build_signing_string(headers, signature["headers"]) + Logger.debug("Signature: #{signature["signature"]}") + Logger.debug("Sigstring: #{sigstring}") + {:ok, sig} = Base.decode64(signature["signature"]) + Logger.debug(inspect sig) + Logger.debug(inspect public_key) + case ExPublicKey.verify(sigstring, sig, public_key) do + {:ok, sig_valid} -> + sig_valid + {:error, err} -> + Logger.error(err) + false + end + end + + def validate_conn(conn) do + # TODO: How to get the right key and see if it is actually valid for that request. + # For now, fetch the key for the actor. + with actor_id <- conn.params["actor"], + {:ok, public_key} <- Account.get_public_key_for_url(actor_id) do + case HTTPSign.verify(conn, public_key) do + {:ok, conn} -> + true + _ -> + Logger.debug("Could not validate, re-fetching user and trying one more time") + # Fetch user anew and try one more time + with actor_id <- conn.params["actor"], + {:ok, _user} <- ActivityPub.make_account_from_url(actor_id), + {:ok, public_key} <- Account.get_public_key_for_url(actor_id) do + case HTTPSign.verify(conn, public_key) do + {:ok, conn} -> + true + {:error, :forbidden} -> + false + end + end + end + else + e -> + Logger.debug("Could not public key!") + Logger.debug(inspect e) + false + end + end + +# def validate_conn(conn, public_key) do +# headers = Enum.into(conn.req_headers, %{}) +# signature = split_signature(headers["signature"]) +# validate(headers, signature, public_key) +# end + + def build_signing_string(headers, used_headers) do + used_headers + |> Enum.map(fn header -> "#{header}: #{headers[header]}" end) + |> Enum.join("\n") + end + + def sign(account, headers) do + sigstring = build_signing_string(headers, Map.keys(headers)) + + {:ok, private_key} = Account.get_private_key_for_account(account) + + Logger.debug("private_key") + Logger.debug(inspect private_key) + Logger.debug("sigstring") + Logger.debug(inspect sigstring) + {:ok, signature} = HTTPSign.Crypto.sign(:rsa, sigstring, private_key) + Logger.debug(inspect signature) + + signature = Base.encode64(signature) + + sign = [ + keyId: account.url <> "#main-key", + algorithm: "rsa-sha256", + headers: Map.keys(headers) |> Enum.join(" "), + signature: signature + ] + |> Enum.map(fn {k, v} -> "#{k}=\"#{v}\"" end) + |> Enum.join(",") + + Logger.debug("sign") + Logger.debug(inspect sign) + {:ok, public_key} = Account.get_public_key_for_account(account) + Logger.debug("inspect split signature inside sign") + Logger.debug(inspect split_signature(sign)) + Logger.debug(inspect validate(headers, split_signature(sign), public_key)) + sign + end +end diff --git a/lib/service/web_finger/web_finger.ex b/lib/service/web_finger/web_finger.ex new file mode 100644 index 000000000..cb263eaf8 --- /dev/null +++ b/lib/service/web_finger/web_finger.ex @@ -0,0 +1,94 @@ +defmodule Eventos.Service.WebFinger do + + alias Eventos.Accounts + alias Eventos.Service.XmlBuilder + alias Eventos.Repo + require Jason + require Logger + + def host_meta do + base_url = EventosWeb.Endpoint.url() + + { + :XRD, + %{xmlns: "http://docs.oasis-open.org/ns/xri/xrd-1.0"}, + { + :Link, + %{ + rel: "lrdd", + type: "application/xrd+xml", + template: "#{base_url}/.well-known/webfinger?resource={uri}" + } + } + } + |> XmlBuilder.to_doc() + end + + def webfinger(resource, "JSON") do + host = EventosWeb.Endpoint.host() + regex = ~r/(acct:)?(?\w+)@#{host}/ + + with %{"username" => username} <- Regex.named_captures(regex, resource) do + user = Accounts.get_account_by_username(username) + {:ok, represent_user(user, "JSON")} + else + _e -> + with user when not is_nil(user) <- Accounts.get_account_by_url(resource) do + {:ok, represent_user(user, "JSON")} + else + _e -> + {:error, "Couldn't find user"} + end + end + end + + def represent_user(user, "JSON") do + %{ + "subject" => "acct:#{user.username}@#{EventosWeb.Endpoint.host() <> ":4001"}", + "aliases" => [user.url], + "links" => [ + %{"rel" => "self", "type" => "application/activity+json", "href" => user.url}, + ] + } + end + + defp webfinger_from_json(doc) do + data = + Enum.reduce(doc["links"], %{"subject" => doc["subject"]}, fn link, data -> + case {link["type"], link["rel"]} do + {"application/activity+json", "self"} -> + Map.put(data, "url", link["href"]) + _ -> + Logger.debug("Unhandled type: #{inspect(link["type"])}") + data + end + end) + + {:ok, data} + end + + def finger(account) do + account = String.trim_leading(account, "@") + + domain = + with [_name, domain] <- String.split(account, "@") do + domain + else + _e -> + URI.parse(account).host + end + + address = "http://#{domain}/.well-known/webfinger?resource=acct:#{account}" + + with response <- HTTPoison.get(address, [Accept: "application/json"],follow_redirect: true), + {:ok, %{status_code: status_code, body: body}} when status_code in 200..299 <- response do + {:ok, doc} = Jason.decode(body) + webfinger_from_json(doc) + else + e -> + Logger.debug(fn -> "Couldn't finger #{account}" end) + Logger.debug(fn -> inspect(e) end) + {:error, e} + end + end +end diff --git a/lib/service/xml_builder.ex b/lib/service/xml_builder.ex new file mode 100644 index 000000000..4e884cead --- /dev/null +++ b/lib/service/xml_builder.ex @@ -0,0 +1,44 @@ +defmodule Eventos.Service.XmlBuilder do + def to_xml({tag, attributes, content}) do + open_tag = make_open_tag(tag, attributes) + + content_xml = to_xml(content) + + "<#{open_tag}>#{content_xml}" + end + + def to_xml({tag, %{} = attributes}) do + open_tag = make_open_tag(tag, attributes) + + "<#{open_tag} />" + end + + def to_xml({tag, content}), do: to_xml({tag, %{}, content}) + + def to_xml(content) when is_binary(content) do + to_string(content) + end + + def to_xml(content) when is_list(content) do + for element <- content do + to_xml(element) + end + |> Enum.join() + end + + def to_xml(%NaiveDateTime{} = time) do + NaiveDateTime.to_iso8601(time) + end + + def to_doc(content), do: ~s() <> to_xml(content) + + defp make_open_tag(tag, attributes) do + attributes_string = + for {attribute, value} <- attributes do + "#{attribute}=\"#{value}\"" + end + |> Enum.join(" ") + + [tag, attributes_string] |> Enum.join(" ") |> String.trim() + end +end diff --git a/mix.exs b/mix.exs index ebaebac30..b8ed0b51f 100644 --- a/mix.exs +++ b/mix.exs @@ -59,7 +59,12 @@ defmodule Eventos.Mixfile do {:timex_ecto, "~> 3.0"}, {:icalendar, "~> 0.6"}, {:exgravatar, "~> 2.0.1"}, + {:littlefinger, "~> 0.1"}, {:httpoison, "~> 1.0"}, + {:json_ld, "~> 0.2"}, + {:jason, "~> 1.0"}, + {:ex_crypto, "~> 0.9.0"}, + {:http_sign, "~> 0.1.1"}, # Dev and test dependencies {:phoenix_live_reload, "~> 1.0", only: :dev}, {:ex_machina, "~> 2.1", only: :test}, diff --git a/mix.lock b/mix.lock index 2e7cc9ae5..5d4c0c916 100644 --- a/mix.lock +++ b/mix.lock @@ -1,64 +1,66 @@ %{ - "argon2_elixir": {:hex, :argon2_elixir, "1.2.14", "0fc4bfbc1b7e459954987d3d2f3836befd72d63f3a355e3978f5005dd6e80816", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm"}, + "argon2_elixir": {:hex, :argon2_elixir, "1.3.0", "fbc521ca54e8802eeaf571caf1cf385827db3b02cae30d9aa591b83ea79785c2", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm"}, "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"}, - "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [], [], "hexpm"}, - "certifi": {:hex, :certifi, "2.0.0", "a0c0e475107135f76b8c1d5bc7efb33cd3815cb3cf3dea7aefdd174dabead064", [], [], "hexpm"}, - "coherence": {:hex, :coherence, "0.5.0", "aaa785aa29e47d140030502b66b08fb58ec84e8120acbfaa6e6a61d3322ffa76", [], [{:comeonin, "~> 3.0", [hex: :comeonin, repo: "hexpm", optional: false]}, {:ecto, "~> 2.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:gettext, "~> 0.13", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.3", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.10", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_swoosh, "~> 0.2", [hex: :phoenix_swoosh, repo: "hexpm", optional: false]}, {:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: false]}, {:timex_ecto, "~> 3.1", [hex: :timex_ecto, repo: "hexpm", optional: false]}, {:uuid, "~> 1.0", [hex: :uuid, repo: "hexpm", optional: false]}], "hexpm"}, - "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [], [], "hexpm"}, - "comeonin": {:hex, :comeonin, "4.0.3", "4e257dcb748ed1ca2651b7ba24fdbd1bd24efd12482accf8079141e3fda23a10", [:mix], [{:argon2_elixir, "~> 1.2", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:bcrypt_elixir, "~> 0.12.1 or ~> 1.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: true]}, {:pbkdf2_elixir, "~> 0.12", [hex: :pbkdf2_elixir, repo: "hexpm", optional: true]}], "hexpm"}, + "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, + "certifi": {:hex, :certifi, "2.3.1", "d0f424232390bf47d82da8478022301c561cf6445b5b5fb6a84d49a9e76d2639", [:rebar3], [{:parse_trans, "3.2.0", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, + "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"}, + "comeonin": {:hex, :comeonin, "4.1.1", "c7304fc29b45b897b34142a91122bc72757bc0c295e9e824999d5179ffc08416", [:mix], [{:argon2_elixir, "~> 1.2", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:bcrypt_elixir, "~> 0.12.1 or ~> 1.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: true]}, {:pbkdf2_elixir, "~> 0.12", [hex: :pbkdf2_elixir, repo: "hexpm", optional: true]}], "hexpm"}, "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, - "cors_plug": {:hex, :cors_plug, "1.5.0", "6311ea6ac9fb78b987df52a7654136626a7a0c3b77f83da265f952a24f2fc1b0", [:mix], [{:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, + "cors_plug": {:hex, :cors_plug, "1.5.2", "72df63c87e4f94112f458ce9d25800900cc88608c1078f0e4faddf20933eda6e", [:mix], [{:plug, "~> 1.3 or ~> 1.4 or ~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, "cowboy": {:hex, :cowboy, "1.1.2", "61ac29ea970389a88eca5a65601460162d370a70018afe6f949a29dca91f3bb0", [:rebar3], [{:cowlib, "~> 1.0.2", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3.2", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], [], "hexpm"}, - "credo": {:hex, :credo, "0.8.10", "261862bb7363247762e1063713bb85df2bbd84af8d8610d1272cd9c1943bba63", [], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}], "hexpm"}, - "db_connection": {:hex, :db_connection, "1.1.2", "2865c2a4bae0714e2213a0ce60a1b12d76a6efba0c51fbda59c9ab8d1accc7a8", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"}, - "decimal": {:hex, :decimal, "1.4.1", "ad9e501edf7322f122f7fc151cce7c2a0c9ada96f2b0155b8a09a795c2029770", [:mix], [], "hexpm"}, - "dogma": {:hex, :dogma, "0.1.15", "5bceba9054b2b97a4adcb2ab4948ca9245e5258b883946e82d32f785340fd411", [], [{:poison, ">= 2.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, - "earmark": {:hex, :earmark, "1.2.4", "99b637c62a4d65a20a9fb674b8cffb8baa771c04605a80c911c4418c69b75439", [], [], "hexpm"}, - "ecto": {:hex, :ecto, "2.2.7", "2074106ff4a5cd9cb2b54b12ca087c4b659ddb3f6b50be4562883c1d763fb031", [:mix], [{:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: true]}, {:decimal, "~> 1.2", [hex: :decimal, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"}, - "ecto_autoslug_field": {:hex, :ecto_autoslug_field, "0.4.0", "f07db9ac545c7489b49ae77d0675a4a1635af821d3d4c95b8399edfa8f779deb", [], [{:ecto, ">= 2.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:slugger, ">= 0.2.0", [hex: :slugger, repo: "hexpm", optional: false]}], "hexpm"}, - "elixir_make": {:hex, :elixir_make, "0.4.0", "992f38fabe705bb45821a728f20914c554b276838433349d4f2341f7a687cddf", [:mix], [], "hexpm"}, - "ex_doc": {:hex, :ex_doc, "0.18.1", "37c69d2ef62f24928c1f4fdc7c724ea04aecfdf500c4329185f8e3649c915baf", [], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, - "ex_machina": {:hex, :ex_machina, "2.1.0", "4874dc9c78e7cf2d429f24dc3c4005674d4e4da6a08be961ffccc08fb528e28b", [], [{:ecto, "~> 2.1", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, - "ex_unit_notifier": {:hex, :ex_unit_notifier, "0.1.4", "36a2dcab829f506e01bf17816590680dd1474407926d43e64c1263e627c364b8", [], [], "hexpm"}, - "excoveralls": {:hex, :excoveralls, "0.8.0", "99d2691d3edf8612f128be3f9869c4d44b91c67cec92186ce49470ae7a7404cf", [], [{:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:hackney, ">= 0.12.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, - "exgravatar": {:hex, :exgravatar, "2.0.1", "66d595c7d63dd6bbac442c5542a724375ae29144059c6fe093e61553850aace4", [], [], "hexpm"}, - "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, - "file_system": {:hex, :file_system, "0.2.2", "7f1e9de4746f4eb8a4ca8f2fbab582d84a4e40fa394cce7bfcb068b988625b06", [:mix], [], "hexpm"}, - "fs": {:hex, :fs, "0.9.2", "ed17036c26c3f70ac49781ed9220a50c36775c6ca2cf8182d123b6566e49ec59", [], [], "hexpm"}, - "geo": {:hex, :geo, "2.0.0", "4a847aa42fcfac5b9ea23f938e1f4bde1c39c240a5d54b52eb0456530a2d040a", [], [{:ecto, "~> 2.1", [hex: :ecto, repo: "hexpm", optional: true]}, {:poison, "~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"}, - "geo_postgis": {:hex, :geo_postgis, "1.0.0", "6556003ba92b18f4180cacdeaebf5d9c69c794ac395f2e1a4c15165d503815be", [], [{:geo, "~> 2.0", [hex: :geo, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm"}, - "gettext": {:hex, :gettext, "0.14.0", "1a019a2e51d5ad3d126efe166dcdf6563768e5d06c32a99ad2281a1fa94b4c72", [:mix], [], "hexpm"}, + "credo": {:hex, :credo, "0.9.3", "76fa3e9e497ab282e0cf64b98a624aa11da702854c52c82db1bf24e54ab7c97a", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:poison, ">= 0.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, + "db_connection": {:hex, :db_connection, "1.1.3", "89b30ca1ef0a3b469b1c779579590688561d586694a3ce8792985d4d7e575a61", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"}, + "decimal": {:hex, :decimal, "1.5.0", "b0433a36d0e2430e3d50291b1c65f53c37d56f83665b43d79963684865beab68", [:mix], [], "hexpm"}, + "earmark": {:hex, :earmark, "1.2.5", "4d21980d5d2862a2e13ec3c49ad9ad783ffc7ca5769cf6ff891a4553fbaae761", [:mix], [], "hexpm"}, + "ecto": {:hex, :ecto, "2.2.10", "e7366dc82f48f8dd78fcbf3ab50985ceeb11cb3dc93435147c6e13f2cda0992e", [:mix], [{:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: true]}, {:decimal, "~> 1.2", [hex: :decimal, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"}, + "ecto_autoslug_field": {:hex, :ecto_autoslug_field, "0.5.1", "c8a160fa6e5e0002740fe1c500bcc27d10bdb073a93715ce8a01b7af8a290777", [:mix], [{:ecto, ">= 2.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:slugger, ">= 0.2.0", [hex: :slugger, repo: "hexpm", optional: false]}], "hexpm"}, + "elixir_make": {:hex, :elixir_make, "0.4.1", "6628b86053190a80b9072382bb9756a6c78624f208ec0ff22cb94c8977d80060", [:mix], [], "hexpm"}, + "ex_crypto": {:hex, :ex_crypto, "0.9.0", "e04a831034c4d0a43fb2858f696d6b5ae0f87f07dedca3452912fd3cb5ee3ca2", [:mix], [{:poison, ">= 2.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, + "ex_doc": {:hex, :ex_doc, "0.18.3", "f4b0e4a2ec6f333dccf761838a4b253d75e11f714b85ae271c9ae361367897b7", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, + "ex_machina": {:hex, :ex_machina, "2.2.0", "fec496331e04fc2db2a1a24fe317c12c0c4a50d2beb8ebb3531ed1f0d84be0ed", [:mix], [{:ecto, "~> 2.1", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, + "ex_unit_notifier": {:hex, :ex_unit_notifier, "0.1.4", "36a2dcab829f506e01bf17816590680dd1474407926d43e64c1263e627c364b8", [:mix], [], "hexpm"}, + "excoveralls": {:hex, :excoveralls, "0.8.2", "b941a08a1842d7aa629e0bbc969186a4cefdd035bad9fe15d43aaaaaeb8fae36", [:mix], [{:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:hackney, ">= 0.12.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, + "exgravatar": {:hex, :exgravatar, "2.0.1", "66d595c7d63dd6bbac442c5542a724375ae29144059c6fe093e61553850aace4", [:mix], [], "hexpm"}, + "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, + "file_system": {:hex, :file_system, "0.2.5", "a3060f063b116daf56c044c273f65202e36f75ec42e678dc10653056d3366054", [:mix], [], "hexpm"}, + "geo": {:hex, :geo, "2.1.0", "f9a7a1403dde669c4e3f1885aeb4f3b3fb4e51cd28ada6d9f97463e5da65c04a", [:mix], [{:ecto, "~> 2.1", [hex: :ecto, repo: "hexpm", optional: true]}, {:poison, "~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"}, + "geo_postgis": {:hex, :geo_postgis, "1.1.0", "4c9efc082a8b625c335967fec9f5671c2bc8a0a686f9c5130445ebbcca989740", [:mix], [{:geo, "~> 2.0", [hex: :geo, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm"}, + "gettext": {:hex, :gettext, "0.15.0", "40a2b8ce33a80ced7727e36768499fc9286881c43ebafccae6bab731e2b2b8ce", [:mix], [], "hexpm"}, "guardian": {:hex, :guardian, "1.0.1", "db0fbaf571c3b874785818b7272eaf5f1ed97a2f9b1f8bc5dc8b0fb8f8f7bb06", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.0 or ~> 1.2 or ~> 1.3", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}, {:uuid, ">= 1.1.1", [hex: :uuid, repo: "hexpm", optional: false]}], "hexpm"}, - "guardian_db": {:hex, :guardian_db, "1.1.0", "45ab94206cce38f7443dc27de6dc52966ccbdeff65ca1b1f11a6d8f3daceb556", [], [{:ecto, "~> 2.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:guardian, "~> 1.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm"}, - "hackney": {:hex, :hackney, "1.10.1", "c38d0ca52ea80254936a32c45bb7eb414e7a96a521b4ce76d00a69753b157f21", [], [{:certifi, "2.0.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, - "httpoison": {:hex, :httpoison, "1.0.0", "1f02f827148d945d40b24f0b0a89afe40bfe037171a6cf70f2486976d86921cd", [], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, - "icalendar": {:hex, :icalendar, "0.6.0", "0e30054b234752fa1ec3e2b928101f8c98f70067766590360d7790b41faab315", [], [{:timex, "~> 3.0", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm"}, - "idna": {:hex, :idna, "5.1.0", "d72b4effeb324ad5da3cab1767cb16b17939004e789d8c0ad5b70f3cea20c89a", [], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, + "guardian_db": {:hex, :guardian_db, "1.1.0", "45ab94206cce38f7443dc27de6dc52966ccbdeff65ca1b1f11a6d8f3daceb556", [:mix], [{:ecto, "~> 2.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:guardian, "~> 1.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm"}, + "hackney": {:hex, :hackney, "1.12.1", "8bf2d0e11e722e533903fe126e14d6e7e94d9b7983ced595b75f532e04b7fdc7", [:rebar3], [{:certifi, "2.3.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "5.1.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, + "http_sign": {:hex, :http_sign, "0.1.1", "b16edb83aa282892f3271f9a048c155e772bf36e15700ab93901484c55f8dd10", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, + "httpoison": {:hex, :httpoison, "1.1.1", "96ed7ab79f78a31081bb523eefec205fd2900a02cda6dbc2300e7a1226219566", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, + "icalendar": {:hex, :icalendar, "0.7.0", "6acf28c7e38ad1c4515c59e336878fb78bb646c8aa70d2ee3786ea194711a7b7", [:mix], [{:timex, "~> 3.0", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm"}, + "idna": {:hex, :idna, "5.1.1", "cbc3b2fa1645113267cc59c760bafa64b2ea0334635ef06dbac8801e42f7279c", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, + "jason": {:hex, :jason, "1.0.0", "0f7cfa9bdb23fed721ec05419bcee2b2c21a77e926bce0deda029b5adc716fe2", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, "jose": {:hex, :jose, "1.8.4", "7946d1e5c03a76ac9ef42a6e6a20001d35987afd68c2107bcd8f01a84e75aa73", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm"}, - "jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [], [], "hexpm"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [], [], "hexpm"}, - "mime": {:hex, :mime, "1.2.0", "78adaa84832b3680de06f88f0997e3ead3b451a440d183d688085be2d709b534", [:mix], [], "hexpm"}, - "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [], [], "hexpm"}, - "mix_test_watch": {:hex, :mix_test_watch, "0.5.0", "2c322d119a4795c3431380fca2bca5afa4dc07324bd3c0b9f6b2efbdd99f5ed3", [], [{:fs, "~> 0.9.1", [hex: :fs, repo: "hexpm", optional: false]}], "hexpm"}, - "phoenix": {:hex, :phoenix, "1.3.0", "1c01124caa1b4a7af46f2050ff11b267baa3edb441b45dbf243e979cd4c5891b", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, + "json_ld": {:hex, :json_ld, "0.2.2", "d21845319a45fd474c161f534e3b430eebc11840e7be97750c3a2a717549895c", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:rdf, "~> 0.4", [hex: :rdf, repo: "hexpm", optional: false]}], "hexpm"}, + "jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm"}, + "littlefinger": {:hex, :littlefinger, "0.1.0", "5d3720bebd65d6a2051c31ca45f28b2d452d25aeeb8adb0a8f87013868bb0e7e", [:mix], [{:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, "~> 3.1", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, + "mime": {:hex, :mime, "1.3.0", "5e8d45a39e95c650900d03f897fbf99ae04f60ab1daa4a34c7a20a5151b7a5fe", [:mix], [], "hexpm"}, + "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, + "mix_test_watch": {:hex, :mix_test_watch, "0.6.0", "5e206ed04860555a455de2983937efd3ce79f42bd8536fc6b900cc286f5bb830", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm"}, + "parse_trans": {:hex, :parse_trans, "3.2.0", "2adfa4daf80c14dc36f522cf190eb5c4ee3e28008fc6394397c16f62a26258c2", [:rebar3], [], "hexpm"}, + "phoenix": {:hex, :phoenix, "1.3.2", "2a00d751f51670ea6bc3f2ba4e6eb27ecb8a2c71e7978d9cd3e5de5ccf7378bd", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, "phoenix_ecto": {:hex, :phoenix_ecto, "3.3.0", "702f6e164512853d29f9d20763493f2b3bcfcb44f118af2bc37bb95d0801b480", [:mix], [{:ecto, "~> 2.1", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, - "phoenix_html": {:hex, :phoenix_html, "2.10.5", "4f9df6b0fb7422a9440a73182a566cb9cbe0e3ffe8884ef9337ccf284fc1ef0a", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, - "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.1.3", "1d178429fc8950b12457d09c6afec247bfe1fcb6f36209e18fbb0221bdfe4d41", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.0 or ~> 1.2 or ~> 1.3", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm"}, + "phoenix_html": {:hex, :phoenix_html, "2.11.2", "86ebd768258ba60a27f5578bec83095bdb93485d646fc4111db8844c316602d6", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.1.5", "8d4c9b1ef9ca82deee6deb5a038d6d8d7b34b9bb909d99784a49332e0d15b3dc", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.0 or ~> 1.2 or ~> 1.3", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.0.2", "bfa7fd52788b5eaa09cb51ff9fcad1d9edfeb68251add458523f839392f034c1", [:mix], [], "hexpm"}, - "phoenix_swoosh": {:hex, :phoenix_swoosh, "0.2.0", "a7e0b32077cd6d2323ae15198839b05d9caddfa20663fd85787479e81f89520e", [], [{:phoenix, "~> 1.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.2", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:swoosh, "~> 0.1", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm"}, - "plug": {:hex, :plug, "1.4.3", "236d77ce7bf3e3a2668dc0d32a9b6f1f9b1f05361019946aae49874904be4aed", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1", [hex: :cowboy, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm"}, + "plug": {:hex, :plug, "1.5.1", "1ff35bdecfb616f1a2b1c935ab5e4c47303f866cb929d2a76f0541e553a58165", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.3", [hex: :cowboy, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm"}, "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"}, - "postgrex": {:hex, :postgrex, "0.13.3", "c277cfb2a9c5034d445a722494c13359e361d344ef6f25d604c2353185682bfc", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"}, + "postgrex": {:hex, :postgrex, "0.13.5", "3d931aba29363e1443da167a4b12f06dcd171103c424de15e5f3fc2ba3e6d9c5", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"}, "ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [:rebar3], [], "hexpm"}, + "rdf": {:hex, :rdf, "0.4.1", "8c879a091cc2a6035cc6e955186948a15477c9cce4a7ca61a54f38f7259ff396", [:mix], [], "hexpm"}, "rsa_ex": {:hex, :rsa_ex, "0.2.1", "5c2c278270ba2bc7beeb268cc9f6e37f976b81011631a5111b86fb8528785a3f", [:mix], [], "hexpm"}, - "slugger": {:hex, :slugger, "0.2.0", "7c609e6eee6dbb44e7b0db07982932356cab476f00fc8d73320cdc50d7efa18e", [], [], "hexpm"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [], [], "hexpm"}, - "swoosh": {:hex, :swoosh, "0.11.0", "5317c3df2708d14f6ce53aa96b38233aa73ff67c41fac26d8aacc733c116d7a4", [], [{:cowboy, "~> 1.0", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.12", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}, {:poison, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, - "timex": {:hex, :timex, "3.1.24", "d198ae9783ac807721cca0c5535384ebdf99da4976be8cefb9665a9262a1e9e3", [], [{:combine, "~> 0.7", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"}, - "timex_ecto": {:hex, :timex_ecto, "3.2.1", "461140751026e1ca03298fab628f78ab189e78784175f5e301eefa034ee530aa", [], [{:ecto, "~> 2.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm"}, - "tzdata": {:hex, :tzdata, "0.5.14", "56f05ea3dd87db946966ab3c7168c0b35025c7ee0e9b4fc130a04631f5611eb1", [], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [], [], "hexpm"}, + "slugger": {:hex, :slugger, "0.2.0", "7c609e6eee6dbb44e7b0db07982932356cab476f00fc8d73320cdc50d7efa18e", [:mix], [], "hexpm"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"}, + "timex": {:hex, :timex, "3.3.0", "e0695aa0ddb37d460d93a2db34d332c2c95a40c27edf22fbfea22eb8910a9c8d", [: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", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"}, + "timex_ecto": {:hex, :timex_ecto, "3.3.0", "d5bdef09928e7a60f10a0baa47ce653f29b43d6fee87b30b236b216d0e36b98d", [:mix], [{:ecto, "~> 2.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm"}, + "tzdata": {:hex, :tzdata, "0.5.16", "13424d3afc76c68ff607f2df966c0ab4f3258859bbe3c979c9ed1606135e7352", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"}, "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm"}, } diff --git a/priv/repo/migrations/20180430071244_remove_uri.exs b/priv/repo/migrations/20180430071244_remove_uri.exs new file mode 100644 index 000000000..32c8a9a3d --- /dev/null +++ b/priv/repo/migrations/20180430071244_remove_uri.exs @@ -0,0 +1,23 @@ +defmodule Eventos.Repo.Migrations.RemoveUri do + use Ecto.Migration + + def up do + alter table("accounts") do + remove :uri + end + + alter table("groups") do + remove :uri + end + end + + def down do + alter table("accounts") do + add :uri, :string, null: false, default: "https://" + end + + alter table("groups") do + add :uri, :string, null: false, default: "https://" + end + end +end diff --git a/priv/repo/migrations/20180515091106_add_url_field_to_events.exs b/priv/repo/migrations/20180515091106_add_url_field_to_events.exs new file mode 100644 index 000000000..f7a3f16bc --- /dev/null +++ b/priv/repo/migrations/20180515091106_add_url_field_to_events.exs @@ -0,0 +1,15 @@ +defmodule Eventos.Repo.Migrations.AddUrlFieldToEvents do + use Ecto.Migration + + def up do + alter table("events") do + add :url, :string, null: false, default: "https://" + end + end + + def down do + alter table("events") do + remove :url + end + end +end diff --git a/priv/repo/migrations/20180515100237_create_comments.exs b/priv/repo/migrations/20180515100237_create_comments.exs new file mode 100644 index 000000000..c33ca55a4 --- /dev/null +++ b/priv/repo/migrations/20180515100237_create_comments.exs @@ -0,0 +1,17 @@ +defmodule Eventos.Repo.Migrations.CreateComments do + use Ecto.Migration + + def change do + create table(:comments) do + add :url, :string + add :text, :text + + add :account_id, references(:accounts, on_delete: :nothing), null: false + add :event_id, references(:events, on_delete: :nothing) + add :in_reply_to_comment_id, references(:categories, on_delete: :nothing) + add :origin_comment_id, references(:addresses, on_delete: :delete_all) + + timestamps() + end + end +end diff --git a/priv/repo/migrations/20180515142835_alter_event_timestamps_to_date_time_with_time_zone.exs b/priv/repo/migrations/20180515142835_alter_event_timestamps_to_date_time_with_time_zone.exs new file mode 100644 index 000000000..2f5ce1c08 --- /dev/null +++ b/priv/repo/migrations/20180515142835_alter_event_timestamps_to_date_time_with_time_zone.exs @@ -0,0 +1,17 @@ +defmodule Eventos.Repo.Migrations.AlterEventTimestampsToDateTimeWithTimeZone do + use Ecto.Migration + + def up do + alter table("events") do + modify :inserted_at, :utc_datetime + modify :updated_at, :utc_datetime + end + end + + def down do + alter table("events") do + modify :inserted_at, :naive_datetime + modify :updated_at, :naive_datetime + end + end +end diff --git a/priv/repo/migrations/20180515145438_add_local_attribute_to_events_and_comments.exs b/priv/repo/migrations/20180515145438_add_local_attribute_to_events_and_comments.exs new file mode 100644 index 000000000..3555b2c54 --- /dev/null +++ b/priv/repo/migrations/20180515145438_add_local_attribute_to_events_and_comments.exs @@ -0,0 +1,23 @@ +defmodule Eventos.Repo.Migrations.AddLocalAttributeToEventsAndComments do + use Ecto.Migration + + def up do + alter table("events") do + add :local, :boolean, null: false, default: true + end + + alter table("comments") do + add :local, :boolean, null: false, default: true + end + end + + def down do + alter table("events") do + remove :local + end + + alter table("comments") do + remove :local + end + end +end diff --git a/test/eventos/events/events_test.exs b/test/eventos/events/events_test.exs index 7a052bed3..4ee82cf36 100644 --- a/test/eventos/events/events_test.exs +++ b/test/eventos/events/events_test.exs @@ -540,4 +540,66 @@ defmodule Eventos.EventsTest do assert %Ecto.Changeset{} = Events.change_track(track) end end + + describe "comments" do + alias Eventos.Events.Comment + + @valid_attrs %{text: "some text", url: "some url"} + @update_attrs %{text: "some updated text", url: "some updated url"} + @invalid_attrs %{text: nil, url: nil} + + def comment_fixture(attrs \\ %{}) do + {:ok, comment} = + attrs + |> Enum.into(@valid_attrs) + |> Events.create_comment() + + comment + end + + test "list_comments/0 returns all comments" do + comment = comment_fixture() + assert Events.list_comments() == [comment] + end + + test "get_comment!/1 returns the comment with given id" do + comment = comment_fixture() + assert Events.get_comment!(comment.id) == comment + end + + test "create_comment/1 with valid data creates a comment" do + assert {:ok, %Comment{} = comment} = Events.create_comment(@valid_attrs) + assert comment.text == "some text" + assert comment.url == "some url" + end + + test "create_comment/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Events.create_comment(@invalid_attrs) + end + + test "update_comment/2 with valid data updates the comment" do + comment = comment_fixture() + assert {:ok, comment} = Events.update_comment(comment, @update_attrs) + assert %Comment{} = comment + assert comment.text == "some updated text" + assert comment.url == "some updated url" + end + + test "update_comment/2 with invalid data returns error changeset" do + comment = comment_fixture() + assert {:error, %Ecto.Changeset{}} = Events.update_comment(comment, @invalid_attrs) + assert comment == Events.get_comment!(comment.id) + end + + test "delete_comment/1 deletes the comment" do + comment = comment_fixture() + assert {:ok, %Comment{}} = Events.delete_comment(comment) + assert_raise Ecto.NoResultsError, fn -> Events.get_comment!(comment.id) end + end + + test "change_comment/1 returns a comment changeset" do + comment = comment_fixture() + assert %Ecto.Changeset{} = Events.change_comment(comment) + end + end end diff --git a/test/eventos/service/activitypub/activitypub_test.exs b/test/eventos/service/activitypub/activitypub_test.exs new file mode 100644 index 000000000..6b480d9eb --- /dev/null +++ b/test/eventos/service/activitypub/activitypub_test.exs @@ -0,0 +1,81 @@ +defmodule Eventos.Service.Activitypub.ActivitypubTest do + + use Eventos.DataCase + + import Eventos.Factory + + alias Eventos.Events + alias Eventos.Accounts.Account + alias Eventos.Service.ActivityPub + alias Eventos.Activity + + describe "fetching account from it's url" do + test "returns an account" do + assert {:ok, %Account{username: "tcit@framapiaf.org"} = account} = ActivityPub.make_account_from_nickname("tcit@framapiaf.org") + end + end + + describe "create activities" do + test "removes doubled 'to' recipients" do + account = insert(:account) + + {:ok, activity} = + ActivityPub.create(%{ + to: ["user1", "user1", "user2"], + actor: account, + context: "", + object: %{} + }) + + assert activity.data["to"] == ["user1", "user2"] + assert activity.actor == account.url + assert activity.recipients == ["user1", "user2"] + end + end + + describe "fetching an object" do + test "it fetches an object" do + {:ok, object} = + ActivityPub.fetch_event_from_url("https://social.tcit.fr/@tcit/99908779444618462") + + {:ok, object_again} = + ActivityPub.fetch_event_from_url("https://social.tcit.fr/@tcit/99908779444618462") + + assert object == object_again + end + end + + describe "deletion" do + test "it creates a delete activity and deletes the original event" do + event = insert(:event) + event = Events.get_event_full_by_url!(event.url) + {:ok, delete} = ActivityPub.delete(event) + + assert delete.data["type"] == "Delete" + assert delete.data["actor"] == event.organizer_account.url + assert delete.data["object"] == event.url + + assert Events.get_event_by_url!(event.url) == nil + end + end + + describe "update" do + test "it creates an update activity with the new user data" do + account = insert(:account) + account_data = EventosWeb.ActivityPub.UserView.render("account.json", %{account: account}) + + {:ok, update} = + ActivityPub.update(%{ + actor: account_data["url"], + to: [account.url <> "/followers"], + cc: [], + object: account_data + }) + + assert update.data["actor"] == account.url + assert update.data["to"] == [account.url <> "/followers"] + assert update.data["object"]["id"] == account_data["id"] + assert update.data["object"]["type"] == account_data["type"] + end + end +end diff --git a/test/eventos/service/web_finger/web_finger_test.exs b/test/eventos/service/web_finger/web_finger_test.exs new file mode 100644 index 000000000..5be1b3344 --- /dev/null +++ b/test/eventos/service/web_finger/web_finger_test.exs @@ -0,0 +1,59 @@ +defmodule Eventos.Service.WebFingerTest do + use Eventos.DataCase + alias Eventos.Service.WebFinger + import Eventos.Factory + + describe "host meta" do + test "returns a link to the xml lrdd" do + host_info = WebFinger.host_meta() + + assert String.contains?(host_info, EventosWeb.Endpoint.url()) + end + end + + describe "incoming webfinger request" do + test "works for fqns" do + account = insert(:account) + + {:ok, result} = + WebFinger.webfinger("#{account.username}@#{EventosWeb.Endpoint.host()}", "JSON") + assert is_map(result) + end + + test "works for urls" do + account = insert(:account) + + {:ok, result} = WebFinger.webfinger(account.url, "JSON") + assert is_map(result) + end + end + + describe "fingering" do + + test "a mastodon account" do + account = "tcit@social.tcit.fr" + + assert {:ok, %{"subject" => "acct:" <> account, "url" => "https://social.tcit.fr/users/tcit"}} = WebFinger.finger(account) + end + + test "a pleroma account" do + account = "@lain@pleroma.soykaf.com" + + assert {:ok, %{"subject" => "acct:" <> account, "url" => "https://pleroma.soykaf.com/users/lain"}} = WebFinger.finger(account) + end + + test "a peertube account" do + account = "framasoft@framatube.org" + + assert {:ok, %{"subject" => "acct:" <> account, "url" => "https://framatube.org/accounts/framasoft"}} = WebFinger.finger(account) + end + + test "a friendica account" do + # Hasn't any ActivityPub + account = "lain@squeet.me" + + assert {:ok, %{"subject" => "acct:" <> account} = data} = WebFinger.finger(account) + refute Map.has_key?(data, "url") + end + end +end diff --git a/test/eventos_web/controllers/activity_pub_controller_test.exs b/test/eventos_web/controllers/activity_pub_controller_test.exs new file mode 100644 index 000000000..976a9191f --- /dev/null +++ b/test/eventos_web/controllers/activity_pub_controller_test.exs @@ -0,0 +1,139 @@ +defmodule EventosWeb.ActivityPubControllerTest do + use EventosWeb.ConnCase + import Eventos.Factory + alias EventosWeb.ActivityPub.{AccountView, ObjectView} + alias Eventos.{Repo, Accounts, Accounts.Account} + alias Eventos.Activity + import Logger + + describe "/@:username" do + test "it returns a json representation of the account", %{conn: conn} do + account = insert(:account) + + conn = + conn + |> put_req_header("accept", "application/activity+json") + |> get("/@#{account.username}") + + account = Accounts.get_account!(account.id) + + assert json_response(conn, 200) == AccountView.render("account.json", %{account: account}) + Logger.error(inspect AccountView.render("account.json", %{account: account})) + end + end + + describe "/@username/slug" do + test "it returns a json representation of the object", %{conn: conn} do + event = insert(:event) + {slug, parts} = List.pop_at(String.split(event.url, "/"), -1) + "@" <> username = List.last(parts) + + conn = + conn + |> put_req_header("accept", "application/activity+json") + |> get("/@#{username}/#{slug}") + + assert json_response(conn, 200) == ObjectView.render("event.json", %{event: event}) + Logger.error(inspect ObjectView.render("event.json", %{event: event})) + end + end + +# describe "/accounts/:username/inbox" do +# test "it inserts an incoming activity into the database", %{conn: conn} do +# data = File.read!("test/fixtures/mastodon-post-activity.json") |> Poison.decode!() +# +# conn = +# conn +# |> assign(:valid_signature, true) +# |> put_req_header("content-type", "application/activity+json") +# |> post("/inbox", data) +# +# assert "ok" == json_response(conn, 200) +# :timer.sleep(500) +# assert Activity.get_by_ap_id(data["id"]) +# end +# end + +# describe "/accounts/:nickname/followers" do +# test "it returns the followers in a collection", %{conn: conn} do +# user = insert(:user) +# user_two = insert(:user) +# User.follow(user, user_two) +# +# result = +# conn +# |> get("/users/#{user_two.nickname}/followers") +# |> json_response(200) +# +# assert result["first"]["orderedItems"] == [user.ap_id] +# end +# +# test "it works for more than 10 users", %{conn: conn} do +# user = insert(:user) +# +# Enum.each(1..15, fn _ -> +# other_user = insert(:user) +# User.follow(other_user, user) +# end) +# +# result = +# conn +# |> get("/users/#{user.nickname}/followers") +# |> json_response(200) +# +# assert length(result["first"]["orderedItems"]) == 10 +# assert result["first"]["totalItems"] == 15 +# assert result["totalItems"] == 15 +# +# result = +# conn +# |> get("/users/#{user.nickname}/followers?page=2") +# |> json_response(200) +# +# assert length(result["orderedItems"]) == 5 +# assert result["totalItems"] == 15 +# end +# end +# +# describe "/users/:nickname/following" do +# test "it returns the following in a collection", %{conn: conn} do +# user = insert(:user) +# user_two = insert(:user) +# User.follow(user, user_two) +# +# result = +# conn +# |> get("/users/#{user.nickname}/following") +# |> json_response(200) +# +# assert result["first"]["orderedItems"] == [user_two.ap_id] +# end +# +# test "it works for more than 10 users", %{conn: conn} do +# user = insert(:user) +# +# Enum.each(1..15, fn _ -> +# user = Repo.get(User, user.id) +# other_user = insert(:user) +# User.follow(user, other_user) +# end) +# +# result = +# conn +# |> get("/users/#{user.nickname}/following") +# |> json_response(200) +# +# assert length(result["first"]["orderedItems"]) == 10 +# assert result["first"]["totalItems"] == 15 +# assert result["totalItems"] == 15 +# +# result = +# conn +# |> get("/users/#{user.nickname}/following?page=2") +# |> json_response(200) +# +# assert length(result["orderedItems"]) == 5 +# assert result["totalItems"] == 15 +# end +# end +end diff --git a/test/eventos_web/controllers/comment_controller_test.exs b/test/eventos_web/controllers/comment_controller_test.exs new file mode 100644 index 000000000..6b532d5ed --- /dev/null +++ b/test/eventos_web/controllers/comment_controller_test.exs @@ -0,0 +1,81 @@ +defmodule EventosWeb.CommentControllerTest do + use EventosWeb.ConnCase + + alias Eventos.Events + alias Eventos.Events.Comment + + @create_attrs %{text: "some text", url: "some url"} + @update_attrs %{text: "some updated text", url: "some updated url"} + @invalid_attrs %{text: nil, url: nil} + + def fixture(:comment) do + {:ok, comment} = Events.create_comment(@create_attrs) + comment + end + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + describe "index" do + test "lists all comments", %{conn: conn} do + conn = get conn, comment_path(conn, :index) + assert json_response(conn, 200)["data"] == [] + end + end + + describe "create comment" do + test "renders comment when data is valid", %{conn: conn} do + conn = post conn, comment_path(conn, :create), comment: @create_attrs + assert %{"id" => id} = json_response(conn, 201)["data"] + + conn = get conn, comment_path(conn, :show, id) + assert json_response(conn, 200)["data"] == %{ + "id" => id, + "text" => "some text", + "url" => "some url"} + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post conn, comment_path(conn, :create), comment: @invalid_attrs + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "update comment" do + setup [:create_comment] + + test "renders comment when data is valid", %{conn: conn, comment: %Comment{id: id} = comment} do + conn = put conn, comment_path(conn, :update, comment), comment: @update_attrs + assert %{"id" => ^id} = json_response(conn, 200)["data"] + + conn = get conn, comment_path(conn, :show, id) + assert json_response(conn, 200)["data"] == %{ + "id" => id, + "text" => "some updated text", + "url" => "some updated url"} + end + + test "renders errors when data is invalid", %{conn: conn, comment: comment} do + conn = put conn, comment_path(conn, :update, comment), comment: @invalid_attrs + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "delete comment" do + setup [:create_comment] + + test "deletes chosen comment", %{conn: conn, comment: comment} do + conn = delete conn, comment_path(conn, :delete, comment) + assert response(conn, 204) + assert_error_sent 404, fn -> + get conn, comment_path(conn, :show, comment) + end + end + end + + defp create_comment(_) do + comment = fixture(:comment) + {:ok, comment: comment} + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex index 14a8d4a38..dcc8a7311 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -16,12 +16,12 @@ defmodule Eventos.Factory do def account_factory do {:ok, {_, pubkey}} = RsaEx.generate_keypair("4096") + username = sequence("thomas") %Eventos.Accounts.Account{ - username: sequence("Thomas"), + username: username, domain: nil, public_key: pubkey, - uri: "https://", - url: "https://" + url: EventosWeb.Endpoint.url() <> "/@#{username}" } end @@ -46,15 +46,19 @@ defmodule Eventos.Factory do end def event_factory do + account = build(:account) + slug = sequence("my-event") + %Eventos.Events.Event{ title: sequence("MyEvent"), - slug: sequence("my-event"), + slug: slug, description: "My desc", begins_on: nil, ends_on: nil, - organizer_account: build(:account), + organizer_account: account, category: build(:category), - address: build(:address) + address: build(:address), + url: EventosWeb.Endpoint.url() <> "/@" <> account.username <> "/" <> slug } end @@ -79,7 +83,6 @@ defmodule Eventos.Factory do description: "My group", suspended: false, url: "https://", - uri: "https://", address: build(:address) } end