From 91a3805a4729a41d4e28bdb80e5f22f1a7144b1e Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Wed, 30 May 2018 14:27:21 +0200 Subject: [PATCH] Search Signed-off-by: Thomas Citharel --- js/src/components/Account/Account.vue | 4 +- js/src/components/NavBar.vue | 67 +++++-- lib/eventos/actors/actor.ex | 8 +- lib/eventos/actors/actors.ex | 172 +++++++++++++++++- lib/eventos/actors/bot.ex | 25 +++ lib/eventos/events/events.ex | 22 ++- .../controllers/actor_controller.ex | 7 +- lib/eventos_web/controllers/bot_controller.ex | 46 +++++ .../controllers/event_controller.ex | 5 + .../controllers/search_controller.ex | 20 ++ lib/eventos_web/router.ex | 7 +- .../views/activity_pub/actor_view.ex | 5 +- .../views/activity_pub/object_view.ex | 9 +- lib/eventos_web/views/actor_view.ex | 2 + lib/eventos_web/views/bot_view.ex | 18 ++ lib/eventos_web/views/event_view.ex | 2 + lib/eventos_web/views/search_view.ex | 16 ++ lib/mix/tasks/create_bot.ex | 21 +++ lib/service/activity_pub/activity_pub.ex | 89 ++++++++- lib/service/web_finger/web_finger.ex | 2 +- mix.exs | 1 + mix.lock | 1 + .../20180522133844_add_bots_table.exs | 18 ++ test/eventos/actors/actors_test.exs | 67 +++++++ .../controllers/bot_controller_test.exs | 81 +++++++++ 25 files changed, 669 insertions(+), 46 deletions(-) create mode 100644 lib/eventos/actors/bot.ex create mode 100644 lib/eventos_web/controllers/bot_controller.ex create mode 100644 lib/eventos_web/controllers/search_controller.ex create mode 100644 lib/eventos_web/views/bot_view.ex create mode 100644 lib/eventos_web/views/search_view.ex create mode 100644 lib/mix/tasks/create_bot.ex create mode 100644 priv/repo/migrations/20180522133844_add_bots_table.exs create mode 100644 test/eventos/actors/actors_test.exs create mode 100644 test/eventos_web/controllers/bot_controller_test.exs diff --git a/js/src/components/Account/Account.vue b/js/src/components/Account/Account.vue index 6476cd540..1106be577 100644 --- a/js/src/components/Account/Account.vue +++ b/js/src/components/Account/Account.vue @@ -34,7 +34,7 @@
{{ actor.display_name }}
-
@{{ actor.username }}@{{ actor.server.address }}
+
@{{ actor.username }}@{{ actor.domain }}
@@ -179,7 +179,7 @@ export default { required: true, } }, - mounted() { + created() { this.fetchData(); }, watch: { diff --git a/js/src/components/NavBar.vue b/js/src/components/NavBar.vue index 33e40af37..e78649ed6 100644 --- a/js/src/components/NavBar.vue +++ b/js/src/components/NavBar.vue @@ -24,7 +24,22 @@ :items="searchElement.items" :search-input.sync="search" v-model="searchSelect" - > + > + + - {{ this.displayed_name }} + {{ this.displayed_name }} @@ -88,10 +103,12 @@ }, searchSelect(val) { console.log(val); - if (val.hasOwnProperty('addressLocality')) { + if (val.type === 'Event') { + this.$router.push({name: 'Event', params: { name: val.organizer.username, slug: val.slug }}); + } else if (val.type === 'Locality') { this.$router.push({name: 'EventList', params: {location: val.geohash}}); } else { - this.$router.push({name: 'Account', params: {id: val.id}}); + this.$router.push({name: 'Account', params: { name : this.username_with_domain(val) }}); } } }, @@ -101,35 +118,47 @@ }, }, methods: { + username_with_domain(actor) { + if (actor.type !== 'Event') { + return actor.username + (actor.domain === null ? '' : `@${actor.domain}`) + } + return actor.title; + }, getUser() { return this.$store.state.user === undefined ? false : this.$store.state.user; }, querySelections(searchTerm) { this.searchElement.loading = true; - eventFetch('/find/', this.$store, {method: 'POST', body: JSON.stringify({search: searchTerm})}) + eventFetch(`/search/${searchTerm}`, this.$store) .then(response => response.json()) .then((results) => { + console.log('results'); console.log(results); - const accountResults = results.accounts.map((result) => { - if (result.server) { - result.displayedText = `${result.username}@${result.server.address}`; + const accountResults = results.data.actors.map((result) => { + if (result.domain) { + result.displayedText = `${result.username}@${result.domain}`; } else { result.displayedText = result.username; } return result; }); - const cities = new Set(); - const placeResults = results.places.map((result) => { - result.displayedText = result.addressLocality; - return result; - }).filter((result) => { - if (cities.has(result.addressLocality)) { - return false; - } - cities.add(result.addressLocality); - return true; + + const eventsResults = results.data.events.map((result) => { + result.displayedText = result.title; + return result; }); - this.searchElement.items = accountResults.concat(placeResults); + // const cities = new Set(); + // const placeResults = results.places.map((result) => { + // result.displayedText = result.addressLocality; + // return result; + // }).filter((result) => { + // if (cities.has(result.addressLocality)) { + // return false; + // } + // cities.add(result.addressLocality); + // return true; + // }); + this.searchElement.items = accountResults.concat(eventsResults); this.searchElement.loading = false; }); } diff --git a/lib/eventos/actors/actor.ex b/lib/eventos/actors/actor.ex index 2e5371095..20d161451 100644 --- a/lib/eventos/actors/actor.ex +++ b/lib/eventos/actors/actor.ex @@ -77,14 +77,14 @@ defmodule Eventos.Actors.Actor do actor |> Ecto.Changeset.cast(attrs, [:url, :outbox_url, :inbox_url, :following_url, :followers_url, :type, :name, :domain, :summary, :preferred_username, :public_key, :private_key, :manually_approves_followers, :suspended]) |> validate_required([:preferred_username, :public_key, :suspended, :url]) - |> unique_constraint(:name, name: :actors_username_domain_index) + |> unique_constraint(:prefered_username, name: :actors_preferred_username_domain_index) end def registration_changeset(%Actor{} = actor, attrs) do actor - |> Ecto.Changeset.cast(attrs, [:name, :domain, :display_name, :description, :private_key, :public_key, :suspended, :url]) - |> validate_required([:preferred_username, :public_key, :suspended, :url]) - |> unique_constraint(:name) + |> Ecto.Changeset.cast(attrs, [:preferred_username, :domain, :name, :summary, :private_key, :public_key, :suspended, :url, :type]) + |> validate_required([:preferred_username, :public_key, :suspended, :url, :type]) + |> unique_constraint(:prefered_username, name: :actors_preferred_username_domain_index) 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])?)*$/ diff --git a/lib/eventos/actors/actors.ex b/lib/eventos/actors/actors.ex index 27aa9bb20..1e1748a52 100644 --- a/lib/eventos/actors/actors.ex +++ b/lib/eventos/actors/actors.ex @@ -196,7 +196,12 @@ defmodule Eventos.Actors do end def get_actor_by_name(name) do - Repo.get_by!(Actor, preferred_username: name) + actor = case String.split(name, "@") do + [name] -> + Repo.get_by(Actor, preferred_username: name) + [name, domain] -> + Repo.get_by(Actor, preferred_username: name, domain: domain) + end end def get_local_actor_by_name(name) do @@ -231,6 +236,44 @@ defmodule Eventos.Actors do end end + @doc """ + Find local users by it's username + """ + def find_local_by_username(username) do + actors = Repo.all from a in Actor, where: (ilike(a.preferred_username, ^like_sanitize(username)) or ilike(a.name, ^like_sanitize(username))) and is_nil(a.domain) + Repo.preload(actors, :organized_events) + end + + @doc """ + Find actors by their name or displayed name + """ + def find_actors_by_username(username) do + Repo.all from a in Actor, where: ilike(a.preferred_username, ^like_sanitize(username)) or ilike(a.name, ^like_sanitize(username)) + end + + @doc """ + Sanitize the LIKE queries + """ + defp like_sanitize(value) do + "%" <> String.replace(value, ~r/([\\%_])/, "\\1") <> "%" + 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 search(name) do + case find_actors_by_username(name) do # find already saved accounts + [] -> + with true <- Regex.match?(@email_regex, name), # no accounts found, let's test if it's an username@domain.tld + {:ok, actor} <- ActivityPub.find_or_make_actor_from_nickname(name) do # creating the actor in that case + {:ok, [actor]} + else + false -> {:ok, []} + {:error, err} -> {:error, err} # error fingering the actor + end + actors = [_|_] -> + {:ok, actors} # actors already saved found ! + end + end + @doc """ Get an user by email """ @@ -288,6 +331,32 @@ defmodule Eventos.Actors do end end + def register_bot_account(%{name: name, summary: summary}) do + key = :public_key.generate_key({:rsa, 2048, 65537}) + entry = :public_key.pem_entry_encode(:RSAPrivateKey, key) + pem = :public_key.pem_encode([entry]) |> String.trim_trailing() + + {:ok, rsa_priv_key} = ExPublicKey.generate_key() + {:ok, rsa_pub_key} = ExPublicKey.public_key_from_private_key(rsa_priv_key) + + actor = Eventos.Actors.Actor.registration_changeset(%Eventos.Actors.Actor{}, %{ + preferred_username: name, + domain: nil, + private_key: pem, + public_key: "toto", + url: EventosWeb.Endpoint.url() <> "/@" <> name, + summary: summary, + type: :Service + }) + + try do + Eventos.Repo.insert!(actor) + rescue + e in Ecto.InvalidChangesetError -> + {:error, e} + end + end + @doc """ Creates a user. @@ -450,4 +519,105 @@ defmodule Eventos.Actors do Member.changeset(member, %{}) end + + alias Eventos.Actors.Bot + + @doc """ + Returns the list of bots. + + ## Examples + + iex> list_bots() + [%Bot{}, ...] + + """ + def list_bots do + Repo.all(Bot) + end + + @doc """ + Gets a single bot. + + Raises `Ecto.NoResultsError` if the Bot does not exist. + + ## Examples + + iex> get_bot!(123) + %Bot{} + + iex> get_bot!(456) + ** (Ecto.NoResultsError) + + """ + def get_bot!(id), do: Repo.get!(Bot, id) + + @spec get_bot_by_actor(Actor.t) :: Bot.t + def get_bot_by_actor(%Actor{} = actor) do + Repo.get_by!(Bot, actor_id: actor.id) + end + + @doc """ + Creates a bot. + + ## Examples + + iex> create_bot(%{field: value}) + {:ok, %Bot{}} + + iex> create_bot(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_bot(attrs \\ %{}) do + %Bot{} + |> Bot.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a bot. + + ## Examples + + iex> update_bot(bot, %{field: new_value}) + {:ok, %Bot{}} + + iex> update_bot(bot, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_bot(%Bot{} = bot, attrs) do + bot + |> Bot.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a Bot. + + ## Examples + + iex> delete_bot(bot) + {:ok, %Bot{}} + + iex> delete_bot(bot) + {:error, %Ecto.Changeset{}} + + """ + def delete_bot(%Bot{} = bot) do + Repo.delete(bot) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking bot changes. + + ## Examples + + iex> change_bot(bot) + %Ecto.Changeset{source: %Bot{}} + + """ + def change_bot(%Bot{} = bot) do + Bot.changeset(bot, %{}) + end end diff --git a/lib/eventos/actors/bot.ex b/lib/eventos/actors/bot.ex new file mode 100644 index 000000000..702b47942 --- /dev/null +++ b/lib/eventos/actors/bot.ex @@ -0,0 +1,25 @@ +defmodule Eventos.Actors.Bot do + @moduledoc """ + Represents a local bot + """ + use Ecto.Schema + import Ecto.Changeset + alias Eventos.Actors.{Actor, User, Bot} + + + schema "bots" do + field :source, :string + field :type, :string, default: :ics + belongs_to :actor, Actor + belongs_to :user, User + + timestamps() + end + + @doc false + def changeset(bot, attrs) do + bot + |> cast(attrs, [:source, :type, :actor_id, :user_id]) + |> validate_required([:source]) + end +end diff --git a/lib/eventos/events/events.ex b/lib/eventos/events/events.ex index d245979ff..8d75f13f5 100644 --- a/lib/eventos/events/events.ex +++ b/lib/eventos/events/events.ex @@ -34,7 +34,7 @@ defmodule Eventos.Events do offset: ^start, preload: [:organizer_actor, :category, :sessions, :tracks, :tags, :participants, :address] events = Repo.all(query) - count_events = Repo.one(from e in Event, select: count(e.id)) + count_events = Repo.one(from e in Event, select: count(e.id), where: e.organizer_actor_id == ^actor_id) {:ok, events, count_events} end @@ -109,6 +109,21 @@ defmodule Eventos.Events do Repo.preload(event, [:organizer_actor, :category, :sessions, :tracks, :tags, :participants, :address]) end + @doc """ + Find events by name + """ + def find_events_by_name(name) do + events = Repo.all from a in Event, where: ilike(a.title, ^like_sanitize(name)) + Repo.preload(events, [:organizer_actor]) + end + + @doc """ + Sanitize the LIKE queries + """ + defp like_sanitize(value) do + "%" <> String.replace(value, ~r/([\\%_])/, "\\1") <> "%" + end + @doc """ Creates a event. @@ -205,6 +220,11 @@ defmodule Eventos.Events do """ def get_category!(id), do: Repo.get!(Category, id) + @spec get_category_by_title(String.t) :: tuple() + def get_category_by_title(title) when is_binary(title) do + Repo.get_by(Category, title: title) + end + @doc """ Creates a category. diff --git a/lib/eventos_web/controllers/actor_controller.ex b/lib/eventos_web/controllers/actor_controller.ex index b37c6c21e..67aded7a7 100644 --- a/lib/eventos_web/controllers/actor_controller.ex +++ b/lib/eventos_web/controllers/actor_controller.ex @@ -20,10 +20,11 @@ defmodule EventosWeb.ActorController do render(conn, "show.json", actor: actor) 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 search(conn, %{"name" => name}) do - with {:ok, actor} <- ActivityPub.make_actor_from_nickname(name) do - render(conn, "acccount_basic.json", actor: actor) - else + case Actors.search(name) do # find already saved accounts + {:ok, actors} -> + render(conn, "index.json", actors: actors) {:error, err} -> json(conn, err) end end diff --git a/lib/eventos_web/controllers/bot_controller.ex b/lib/eventos_web/controllers/bot_controller.ex new file mode 100644 index 000000000..95119d421 --- /dev/null +++ b/lib/eventos_web/controllers/bot_controller.ex @@ -0,0 +1,46 @@ +defmodule EventosWeb.BotController do + use EventosWeb, :controller + + alias Eventos.Actors + alias Eventos.Actors.Bot + + action_fallback EventosWeb.FallbackController + + def index(conn, _params) do + bots = Actors.list_bots() + render(conn, "index.json", bots: bots) + end + + def create(conn, %{"bot" => bot_params}) do + with user <- Guardian.Plug.current_resource, + bot_params <- Map.put(bot_params, "user_id", user.id), + {:ok, actor } <- Actors.register_bot_account(%{name: bot_params["name"], summary: bot_params["summary"]}), + bot_params <- Map.put(bot_params, "actor_id", actor.id), + {:ok, %Bot{} = bot} <- Actors.create_bot(bot_params) do + conn + |> put_status(:created) + |> put_resp_header("location", bot_path(conn, :show, bot)) + |> render("show.json", bot: bot) + end + end + + def show(conn, %{"id" => id}) do + bot = Actors.get_bot!(id) + render(conn, "show.json", bot: bot) + end + + def update(conn, %{"id" => id, "bot" => bot_params}) do + bot = Actors.get_bot!(id) + + with {:ok, %Bot{} = bot} <- Actors.update_bot(bot, bot_params) do + render(conn, "show.json", bot: bot) + end + end + + def delete(conn, %{"id" => id}) do + bot = Actors.get_bot!(id) + with {:ok, %Bot{}} <- Actors.delete_bot(bot) do + send_resp(conn, :no_content, "") + end + end +end diff --git a/lib/eventos_web/controllers/event_controller.ex b/lib/eventos_web/controllers/event_controller.ex index 08882b5bf..3f1eeca99 100644 --- a/lib/eventos_web/controllers/event_controller.ex +++ b/lib/eventos_web/controllers/event_controller.ex @@ -35,6 +35,11 @@ defmodule EventosWeb.EventController do end end + def search(conn, %{"name" => name}) do + events = Events.find_events_by_name(name) + render(conn, "index.json", events: events) + end + def show(conn, %{"username" => username, "slug" => slug}) do event = Events.get_event_full_by_name_and_slug!(username, slug) render(conn, "show.json", event: event) diff --git a/lib/eventos_web/controllers/search_controller.ex b/lib/eventos_web/controllers/search_controller.ex new file mode 100644 index 000000000..bfdd1409f --- /dev/null +++ b/lib/eventos_web/controllers/search_controller.ex @@ -0,0 +1,20 @@ +defmodule EventosWeb.SearchController do + @moduledoc """ + Controller for Search + """ + use EventosWeb, :controller + + alias Eventos.Events + alias Eventos.Actors + + action_fallback EventosWeb.FallbackController + + def search(conn, %{"name" => name}) do + events = Events.find_events_by_name(name) + case Actors.search(name) do # find already saved accounts + {:ok, actors} -> + render(conn, "search.json", events: events, actors: actors) + {:error, err} -> json(conn, err) + end + end +end diff --git a/lib/eventos_web/router.ex b/lib/eventos_web/router.ex index b49c3ad6b..620c02658 100644 --- a/lib/eventos_web/router.ex +++ b/lib/eventos_web/router.ex @@ -39,11 +39,13 @@ defmodule EventosWeb.Router do post "/login", UserSessionController, :sign_in #resources "/groups", GroupController, only: [:index, :show] get "/events", EventController, :index + get "/events/search/:name", EventController, :search get "/events/:username/:slug", EventController, :show get "/events/:username/:slug/ics", EventController, :export_to_ics get "/events/:username/:slug/tracks", TrackController, :show_tracks_for_event get "/events/:username/:slug/sessions", SessionController, :show_sessions_for_event resources "/comments", CommentController, only: [:show] + get "/bots/:id", BotController, :view get "/actors", ActorController, :index get "/actors/search/:name", ActorController, :search @@ -53,6 +55,8 @@ defmodule EventosWeb.Router do resources "/sessions", SessionController, only: [:index, :show] resources "/tracks", TrackController, only: [:index, :show] resources "/addresses", AddressController, only: [:index, :show] + + get "/search/:name", SearchController, :search end end @@ -70,9 +74,10 @@ defmodule EventosWeb.Router do patch "/events/:username/:slug", EventController, :update put "/events/:username/:slug", EventController, :update delete "/events/:username/:slug", EventController, :delete - resources "/comments", CommentController, except: [:new, :edit] + resources "/comments", CommentController, except: [:new, :edit, :show] #post "/events/:id/request", EventRequestController, :create_for_event resources "/participant", ParticipantController + resources "/bots", BotController, except: [:new, :edit, :show] #resources "/requests", EventRequestController #resources "/groups", GroupController, except: [:index, :show] #post "/groups/:id/request", GroupRequestController, :create_for_group diff --git a/lib/eventos_web/views/activity_pub/actor_view.ex b/lib/eventos_web/views/activity_pub/actor_view.ex index 90e3948e9..b2ea02f3c 100644 --- a/lib/eventos_web/views/activity_pub/actor_view.ex +++ b/lib/eventos_web/views/activity_pub/actor_view.ex @@ -9,6 +9,7 @@ defmodule EventosWeb.ActivityPub.ActorView do alias Eventos.Service.ActivityPub alias Eventos.Service.ActivityPub.Transmogrifier alias Eventos.Service.ActivityPub.Utils + alias Eventos.Activity import Ecto.Query def render("actor.json", %{actor: actor}) do @@ -123,10 +124,10 @@ defmodule EventosWeb.ActivityPub.ActorView do end end - def render("activity.json", %{activity: activity}) do + def render("activity.json", %{activity: %Activity{local: local} = activity}) do %{ "id" => activity.data.url <> "/activity", - "type" => "Create", + "type" => if local do "Create" else "Announce" end, "actor" => activity.data.organizer_actor.url, "published" => Timex.now(), "to" => ["https://www.w3.org/ns/activitystreams#Public"], diff --git a/lib/eventos_web/views/activity_pub/object_view.ex b/lib/eventos_web/views/activity_pub/object_view.ex index 40147ca6a..6073ef683 100644 --- a/lib/eventos_web/views/activity_pub/object_view.ex +++ b/lib/eventos_web/views/activity_pub/object_view.ex @@ -1,5 +1,6 @@ defmodule EventosWeb.ActivityPub.ObjectView do use EventosWeb, :view + alias EventosWeb.ActivityPub.ObjectView alias Eventos.Service.ActivityPub.Transmogrifier @base %{ "@context" => [ @@ -22,7 +23,7 @@ defmodule EventosWeb.ActivityPub.ObjectView do "type" => "Event", "id" => event.url, "name" => event.title, - "category" => %{"title" => event.category.title}, + "category" => render_one(event.category, ObjectView, "category.json", as: :category), "content" => event.description, "mediaType" => "text/markdown", "published" => Timex.format!(event.inserted_at, "{ISO:Extended}"), @@ -32,6 +33,10 @@ defmodule EventosWeb.ActivityPub.ObjectView do end def render("category.json", %{category: category}) do - category + %{"title" => category.title} + end + + def render("category.json", %{category: nil}) do + nil end end diff --git a/lib/eventos_web/views/actor_view.ex b/lib/eventos_web/views/actor_view.ex index a58d0fdf6..788d9e07b 100644 --- a/lib/eventos_web/views/actor_view.ex +++ b/lib/eventos_web/views/actor_view.ex @@ -23,6 +23,7 @@ defmodule EventosWeb.ActorView do domain: actor.domain, display_name: actor.name, description: actor.summary, + type: actor.type, # public_key: actor.public_key, suspended: actor.suspended, url: actor.url, @@ -35,6 +36,7 @@ defmodule EventosWeb.ActorView do domain: actor.domain, display_name: actor.name, description: actor.summary, + type: actor.type, # public_key: actor.public_key, suspended: actor.suspended, url: actor.url, diff --git a/lib/eventos_web/views/bot_view.ex b/lib/eventos_web/views/bot_view.ex new file mode 100644 index 000000000..4074279c8 --- /dev/null +++ b/lib/eventos_web/views/bot_view.ex @@ -0,0 +1,18 @@ +defmodule EventosWeb.BotView do + use EventosWeb, :view + alias EventosWeb.BotView + + def render("index.json", %{bots: bots}) do + %{data: render_many(bots, BotView, "bot.json")} + end + + def render("show.json", %{bot: bot}) do + %{data: render_one(bot, BotView, "bot.json")} + end + + def render("bot.json", %{bot: bot}) do + %{id: bot.id, + source: bot.source, + type: bot.type} + end +end diff --git a/lib/eventos_web/views/event_view.ex b/lib/eventos_web/views/event_view.ex index 70b859f2c..20b86fd77 100644 --- a/lib/eventos_web/views/event_view.ex +++ b/lib/eventos_web/views/event_view.ex @@ -34,6 +34,7 @@ defmodule EventosWeb.EventView do organizer: %{ username: event.organizer_actor.preferred_username }, + type: "Event", } end @@ -46,6 +47,7 @@ defmodule EventosWeb.EventView do organizer: render_one(event.organizer_actor, ActorView, "acccount_basic.json"), participants: render_many(event.participants, ActorView, "show_basic.json"), address: render_one(event.address, AddressView, "address.json"), + type: "Event", } end end diff --git a/lib/eventos_web/views/search_view.ex b/lib/eventos_web/views/search_view.ex new file mode 100644 index 000000000..2946af4a7 --- /dev/null +++ b/lib/eventos_web/views/search_view.ex @@ -0,0 +1,16 @@ +defmodule EventosWeb.SearchView do + @moduledoc """ + View for Events + """ + use EventosWeb, :view + alias EventosWeb.{EventView, ActorView, GroupView, AddressView} + + def render("search.json", %{events: events, actors: actors}) do + %{ + data: %{ + events: render_many(events, EventView, "event_simple.json"), + actors: render_many(actors, ActorView, "acccount_basic.json"), + } + } + end +end diff --git a/lib/mix/tasks/create_bot.ex b/lib/mix/tasks/create_bot.ex new file mode 100644 index 000000000..a2d6aec31 --- /dev/null +++ b/lib/mix/tasks/create_bot.ex @@ -0,0 +1,21 @@ +defmodule Mix.Tasks.CreateBot do + use Mix.Task + alias Eventos.Actors + alias Eventos.Actors.Bot + alias Eventos.Repo + import Logger + + @shortdoc "Register user" + def run([email, name, summary, type, url]) do + Mix.Task.run("app.start") + + with user <- Actors.find_by_email(email), + actor <- Actors.register_bot_account(%{name: name, summary: summary}), + {:ok, %Bot{} = bot} <- Actors.create_bot(%{"type" => type, "source" => url, "actor_id" => actor.id, "user_id" => user.id}) do + bot + + else + e -> Logger.error(inspect e) + end + end +end diff --git a/lib/service/activity_pub/activity_pub.ex b/lib/service/activity_pub/activity_pub.ex index 6b6f6b74b..9a645c447 100644 --- a/lib/service/activity_pub/activity_pub.ex +++ b/lib/service/activity_pub/activity_pub.ex @@ -1,6 +1,6 @@ defmodule Eventos.Service.ActivityPub do alias Eventos.Events - alias Eventos.Events.Event + alias Eventos.Events.{Event, Category} alias Eventos.Service.ActivityPub.Transmogrifier alias Eventos.Service.WebFinger alias Eventos.Activity @@ -174,6 +174,15 @@ defmodule Eventos.Service.ActivityPub do end end + @spec find_or_make_actor_from_nickname(String.t) :: tuple() + def find_or_make_actor_from_nickname(nickname) do + with %Actor{} = actor <- Actors.get_actor_by_name(nickname) do + {:ok, actor} + else + nil -> make_actor_from_nickname(nickname) + end + end + def make_actor_from_nickname(nickname) do with {:ok, %{"url" => url}} when not is_nil(url) <- WebFinger.finger(nickname) do make_actor_from_url(url) @@ -288,19 +297,39 @@ defmodule Eventos.Service.ActivityPub do end @spec fetch_public_activities_for_actor(Actor.t, integer(), integer()) :: list() - def fetch_public_activities_for_actor(%Actor{} = actor, page \\ 10, limit \\ 10) do - {:ok, events, total} = Events.get_events_for_actor(actor, page, limit) - activities = Enum.map(events, fn event -> - {:ok, activity} = event_to_activity(event) - activity - end) - {activities, total} + def fetch_public_activities_for_actor(%Actor{} = actor, page \\ 1, limit \\ 10) do + case actor.type do + :Person -> + {:ok, events, total} = Events.get_events_for_actor(actor, page, limit) + activities = Enum.map(events, fn event -> + {:ok, activity} = event_to_activity(event) + activity + end) + {activities, total} + :Service -> + bot = Actors.get_bot_by_actor(actor) + case bot.type do + "ics" -> + {:ok, %HTTPoison.Response{body: body} = _resp} = HTTPoison.get(bot.source) + ical_events = body + |> ExIcal.parse() + |> ExIcal.by_range(DateTime.utc_now(), DateTime.utc_now() |> Timex.shift(years: 1)) + activities = ical_events + |> Enum.chunk_every(limit) + |> Enum.at(page - 1) + |> Enum.map(fn event -> + {:ok, activity } = ical_event_to_activity(event, actor, bot.source) + activity + end) + {activities, length(ical_events)} + end + end end - defp event_to_activity(%Event{} = event) do + defp event_to_activity(%Event{} = event, local \\ true) do activity = %Activity{ data: event, - local: true, + local: local, actor: event.organizer_actor.url, recipients: ["https://www.w3.org/ns/activitystreams#Public"] } @@ -309,4 +338,44 @@ defmodule Eventos.Service.ActivityPub do #stream_out(activity) {:ok, activity} end + + defp ical_event_to_activity(%ExIcal.Event{} = ical_event, %Actor{} = actor, source) do + # Logger.debug(inspect ical_event) + # TODO : refactor me ! + category = if is_nil ical_event.categories do + nil + else + ical_category = ical_event.categories |> hd() |> String.downcase() + case ical_category |> Events.get_category_by_title() do + nil -> case Events.create_category(%{"title" => ical_category}) do + {:ok, %Category{} = category} -> category + _ -> nil + end + category -> category + end + end + + {:ok, event} = Events.create_event(%{ + begins_on: ical_event.start, + ends_on: ical_event.end, + inserted_at: ical_event.stamp, + updated_at: ical_event.stamp, + description: ical_event.description |> sanitize_ical_event_strings, + title: ical_event.summary |> sanitize_ical_event_strings, + organizer_actor: actor, + category: category, + }) + + event_to_activity(event, false) + end + + defp sanitize_ical_event_strings(string) when is_binary(string) do + string + |> String.replace(~s"\r\n", "") + |> String.replace(~s"\\,", ",") + end + + defp sanitize_ical_event_strings(nil) do + nil + end end diff --git a/lib/service/web_finger/web_finger.ex b/lib/service/web_finger/web_finger.ex index 50532888c..d518378e1 100644 --- a/lib/service/web_finger/web_finger.ex +++ b/lib/service/web_finger/web_finger.ex @@ -81,7 +81,7 @@ defmodule Eventos.Service.WebFinger do address = "http://#{domain}/.well-known/webfinger?resource=acct:#{actor}" Logger.debug(inspect address) - with response <- HTTPoison.get!(address, [Accept: "application/json, application/activity+json, application/jrd+json"],follow_redirect: true), + with {:ok, %HTTPoison.Response{} = response} <- HTTPoison.get(address, [Accept: "application/json, application/activity+json, application/jrd+json"],follow_redirect: true), %{status_code: status_code, body: body} when status_code in 200..299 <- response do {:ok, doc} = Jason.decode(body) webfinger_from_json(doc) diff --git a/mix.exs b/mix.exs index 2c3dfb416..2ed6514be 100644 --- a/mix.exs +++ b/mix.exs @@ -66,6 +66,7 @@ defmodule Eventos.Mixfile do {:ex_crypto, "~> 0.9.0"}, {:http_sign, "~> 0.1.1"}, {:ecto_enum, "~> 1.0"}, + {:ex_ical, github: "tcitworld/ex_ical", branch: "usable"}, # 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 795a6c5da..7ce0c7032 100644 --- a/mix.lock +++ b/mix.lock @@ -19,6 +19,7 @@ "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_ical": {:git, "https://github.com/tcitworld/ex_ical.git", "e2facc514ee2ce99331b6a351c00667a9b28dd01", [branch: "usable"]}, "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"}, diff --git a/priv/repo/migrations/20180522133844_add_bots_table.exs b/priv/repo/migrations/20180522133844_add_bots_table.exs new file mode 100644 index 000000000..234f01a54 --- /dev/null +++ b/priv/repo/migrations/20180522133844_add_bots_table.exs @@ -0,0 +1,18 @@ +defmodule Eventos.Repo.Migrations.AddBotsTable do + use Ecto.Migration + + def up do + create table(:bots) do + add :source, :string, null: false + add :type, :string, default: "ics" + add :actor_id, references(:actors, on_delete: :delete_all), null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + end + + def down do + drop table(:bots) + end +end diff --git a/test/eventos/actors/actors_test.exs b/test/eventos/actors/actors_test.exs new file mode 100644 index 000000000..556a99640 --- /dev/null +++ b/test/eventos/actors/actors_test.exs @@ -0,0 +1,67 @@ +defmodule Eventos.ActorsTest do + use Eventos.DataCase + + alias Eventos.Actors + + describe "bots" do + alias Eventos.Actors.Bot + + @valid_attrs %{source: "some source", type: "some type"} + @update_attrs %{source: "some updated source", type: "some updated type"} + @invalid_attrs %{source: nil, type: nil} + + def bot_fixture(attrs \\ %{}) do + {:ok, bot} = + attrs + |> Enum.into(@valid_attrs) + |> Actors.create_bot() + + bot + end + + test "list_bots/0 returns all bots" do + bot = bot_fixture() + assert Actors.list_bots() == [bot] + end + + test "get_bot!/1 returns the bot with given id" do + bot = bot_fixture() + assert Actors.get_bot!(bot.id) == bot + end + + test "create_bot/1 with valid data creates a bot" do + assert {:ok, %Bot{} = bot} = Actors.create_bot(@valid_attrs) + assert bot.source == "some source" + assert bot.type == "some type" + end + + test "create_bot/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Actors.create_bot(@invalid_attrs) + end + + test "update_bot/2 with valid data updates the bot" do + bot = bot_fixture() + assert {:ok, bot} = Actors.update_bot(bot, @update_attrs) + assert %Bot{} = bot + assert bot.source == "some updated source" + assert bot.type == "some updated type" + end + + test "update_bot/2 with invalid data returns error changeset" do + bot = bot_fixture() + assert {:error, %Ecto.Changeset{}} = Actors.update_bot(bot, @invalid_attrs) + assert bot == Actors.get_bot!(bot.id) + end + + test "delete_bot/1 deletes the bot" do + bot = bot_fixture() + assert {:ok, %Bot{}} = Actors.delete_bot(bot) + assert_raise Ecto.NoResultsError, fn -> Actors.get_bot!(bot.id) end + end + + test "change_bot/1 returns a bot changeset" do + bot = bot_fixture() + assert %Ecto.Changeset{} = Actors.change_bot(bot) + end + end +end diff --git a/test/eventos_web/controllers/bot_controller_test.exs b/test/eventos_web/controllers/bot_controller_test.exs new file mode 100644 index 000000000..a3170c531 --- /dev/null +++ b/test/eventos_web/controllers/bot_controller_test.exs @@ -0,0 +1,81 @@ +defmodule EventosWeb.BotControllerTest do + use EventosWeb.ConnCase + + alias Eventos.Actors + alias Eventos.Actors.Bot + + @create_attrs %{source: "some source", type: "some type"} + @update_attrs %{source: "some updated source", type: "some updated type"} + @invalid_attrs %{source: nil, type: nil} + + def fixture(:bot) do + {:ok, bot} = Actors.create_bot(@create_attrs) + bot + end + + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + describe "index" do + test "lists all bots", %{conn: conn} do + conn = get conn, bot_path(conn, :index) + assert json_response(conn, 200)["data"] == [] + end + end + + describe "create bot" do + test "renders bot when data is valid", %{conn: conn} do + conn = post conn, bot_path(conn, :create), bot: @create_attrs + assert %{"id" => id} = json_response(conn, 201)["data"] + + conn = get conn, bot_path(conn, :show, id) + assert json_response(conn, 200)["data"] == %{ + "id" => id, + "source" => "some source", + "type" => "some type"} + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post conn, bot_path(conn, :create), bot: @invalid_attrs + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "update bot" do + setup [:create_bot] + + test "renders bot when data is valid", %{conn: conn, bot: %Bot{id: id} = bot} do + conn = put conn, bot_path(conn, :update, bot), bot: @update_attrs + assert %{"id" => ^id} = json_response(conn, 200)["data"] + + conn = get conn, bot_path(conn, :show, id) + assert json_response(conn, 200)["data"] == %{ + "id" => id, + "source" => "some updated source", + "type" => "some updated type"} + end + + test "renders errors when data is invalid", %{conn: conn, bot: bot} do + conn = put conn, bot_path(conn, :update, bot), bot: @invalid_attrs + assert json_response(conn, 422)["errors"] != %{} + end + end + + describe "delete bot" do + setup [:create_bot] + + test "deletes chosen bot", %{conn: conn, bot: bot} do + conn = delete conn, bot_path(conn, :delete, bot) + assert response(conn, 204) + assert_error_sent 404, fn -> + get conn, bot_path(conn, :show, bot) + end + end + end + + defp create_bot(_) do + bot = fixture(:bot) + {:ok, bot: bot} + end +end