diff --git a/.credo.exs b/.credo.exs index d53bbceb..f349c380 100644 --- a/.credo.exs +++ b/.credo.exs @@ -123,7 +123,6 @@ {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, {Credo.Check.Warning.IExPry, []}, {Credo.Check.Warning.IoInspect, []}, - {Credo.Check.Warning.LazyLogging, []}, {Credo.Check.Warning.OperationOnSameValues, []}, {Credo.Check.Warning.OperationWithConstantResult, []}, {Credo.Check.Warning.RaiseInsideRescue, []}, @@ -147,11 +146,17 @@ {Credo.Check.Refactor.DoubleBooleanNegation, false}, {Credo.Check.Refactor.VariableRebinding, false}, {Credo.Check.Warning.MapGetUnsafePass, false}, - {Credo.Check.Warning.UnsafeToAtom, false} + {Credo.Check.Warning.UnsafeToAtom, false}, # # Custom checks can be created using `mix credo.gen.check`. # + + # + # Removed checks + # + {Credo.Check.Warning.LazyLogging, false}, + {Credo.Check.Refactor.MapInto, false}, ] } ] diff --git a/lib/mobilizon/actors/actor.ex b/lib/mobilizon/actors/actor.ex index b3709829..a5feaf5c 100644 --- a/lib/mobilizon/actors/actor.ex +++ b/lib/mobilizon/actors/actor.ex @@ -24,7 +24,7 @@ defmodule Mobilizon.Actors.Actor do alias Mobilizon.Actors alias Mobilizon.Users.User alias Mobilizon.Actors.{Actor, Follower, Member} - alias Mobilizon.Events.Event + alias Mobilizon.Events.{Event, FeedToken} import Ecto.Query import Mobilizon.Ecto @@ -58,6 +58,7 @@ defmodule Mobilizon.Actors.Actor do has_many(:organized_events, Event, foreign_key: :organizer_actor_id) many_to_many(:memberships, Actor, join_through: Member) belongs_to(:user, User) + has_many(:feed_tokens, FeedToken, foreign_key: :actor_id) timestamps() end diff --git a/lib/mobilizon/actors/actors.ex b/lib/mobilizon/actors/actors.ex index e1ad1446..06ecd20c 100644 --- a/lib/mobilizon/actors/actors.ex +++ b/lib/mobilizon/actors/actors.ex @@ -9,7 +9,6 @@ defmodule Mobilizon.Actors do alias Mobilizon.Repo alias Mobilizon.Actors.{Actor, Bot, Member, Follower} - alias Mobilizon.Users.User alias Mobilizon.Service.ActivityPub # import Exgravatar @@ -505,17 +504,6 @@ defmodule Mobilizon.Actors do [entry] |> :public_key.pem_encode() |> String.trim_trailing() end - @doc """ - Register user - """ - @spec register(map()) :: {:ok, User.t()} | {:error, String.t()} - def register(%{email: _email, password: _password} = args) do - with {:ok, %User{} = user} <- - %User{} |> User.registration_changeset(args) |> Mobilizon.Repo.insert() do - {:ok, user} - end - end - @doc """ Create a new person actor """ @@ -525,8 +513,13 @@ defmodule Mobilizon.Actors do pem = [entry] |> :public_key.pem_encode() |> String.trim_trailing() args = Map.put(args, :keys, pem) - actor = Mobilizon.Actors.Actor.registration_changeset(%Mobilizon.Actors.Actor{}, args) - Mobilizon.Repo.insert(actor) + with {:ok, %Actor{} = person} <- + %Actor{} + |> Actor.registration_changeset(args) + |> Repo.insert() do + Mobilizon.Events.create_feed_token(%{"user_id" => args["user_id"], "actor_id" => person.id}) + {:ok, person} + end end def register_bot_account(%{name: name, summary: summary}) do diff --git a/lib/mobilizon/events/events.ex b/lib/mobilizon/events/events.ex index 61263efc..42ae4b48 100644 --- a/lib/mobilizon/events/events.ex +++ b/lib/mobilizon/events/events.ex @@ -9,6 +9,7 @@ defmodule Mobilizon.Events do alias Mobilizon.Repo alias Mobilizon.Events.{Event, Comment, Participant} alias Mobilizon.Actors.Actor + alias Mobilizon.Users.User alias Mobilizon.Addresses.Address def data() do @@ -577,7 +578,7 @@ defmodule Mobilizon.Events do ## Examples - iex> list_participants_for_event(someuuid) + iex> list_participants_for_event(some_uuid) [%Participant{}, ...] """ @@ -594,6 +595,32 @@ defmodule Mobilizon.Events do ) end + @doc """ + Returns the list of participations for an actor. + + Default behaviour is to not return :not_approved participants + + ## Examples + + iex> list_participants_for_actor(%Actor{}) + [%Participant{}, ...] + + """ + def list_event_participations_for_actor(%Actor{id: id}, page \\ nil, limit \\ nil) do + Repo.all( + from( + e in Event, + join: p in Participant, + join: a in Actor, + on: p.actor_id == a.id, + on: p.event_id == e.id, + where: a.id == ^id and p.role != ^:not_approved, + preload: [:tags] + ) + |> paginate(page, limit) + ) + end + @doc """ Returns the list of organizers participants for an event. @@ -1119,4 +1146,143 @@ defmodule Mobilizon.Events do def change_comment(%Comment{} = comment) do Comment.changeset(comment, %{}) end + + alias Mobilizon.Events.FeedToken + + @doc """ + Gets a single feed token. + + ## Examples + + iex> get_feed_token("123") + {:ok, %FeedToken{}} + + iex> get_feed_token("456") + {:error, nil} + + """ + def get_feed_token(token) do + from( + tk in FeedToken, + where: tk.token == ^token, + preload: [:actor, :user] + ) + |> Repo.one() + end + + @doc """ + Gets a single feed token. + + Raises `Ecto.NoResultsError` if the FeedToken does not exist. + + ## Examples + + iex> get_feed_token!(123) + %FeedToken{} + + iex> get_feed_token!(456) + ** (Ecto.NoResultsError) + + """ + def get_feed_token!(token) do + from( + tk in FeedToken, + where: tk.token == ^token, + preload: [:actor, :user] + ) + |> Repo.one!() + end + + @doc """ + Get feed tokens for an user + """ + @spec get_feed_tokens(User.t()) :: list(FeedTokens.t()) + def get_feed_tokens(%User{id: id}) do + from( + tk in FeedToken, + where: tk.user_id == ^id, + preload: [:actor, :user] + ) + |> Repo.all() + end + + @doc """ + Get feed tokens for an actor + """ + @spec get_feed_tokens(Actor.t()) :: list(FeedTokens.t()) + def get_feed_tokens(%Actor{id: id, domain: nil}) do + from( + tk in FeedToken, + where: tk.actor_id == ^id, + preload: [:actor, :user] + ) + |> Repo.all() + end + + @doc """ + Creates a feed token. + + ## Examples + + iex> create_feed_token(%{field: value}) + {:ok, %FeedToken{}} + + iex> create_feed_token(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_feed_token(attrs \\ %{}) do + attrs = Map.put(attrs, "token", Ecto.UUID.generate()) + + %FeedToken{} + |> FeedToken.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a feed token. + + ## Examples + + iex> update_feed_token(feed_token, %{field: new_value}) + {:ok, %FeedToken{}} + + iex> update_feed_token(feed_token, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_feed_token(%FeedToken{} = feed_token, attrs) do + feed_token + |> FeedToken.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a FeedToken. + + ## Examples + + iex> delete_feed_token(feed_token) + {:ok, %FeedToken{}} + + iex> delete_feed_token(feed_token) + {:error, %Ecto.Changeset{}} + + """ + def delete_feed_token(%FeedToken{} = feed_token) do + Repo.delete(feed_token) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking feed_token changes. + + ## Examples + + iex> change_feed_token(feed_token) + %Ecto.Changeset{source: %FeedToken{}} + + """ + def change_feed_token(%FeedToken{} = feed_token) do + FeedToken.changeset(feed_token, %{}) + end end diff --git a/lib/mobilizon/events/feed_token.ex b/lib/mobilizon/events/feed_token.ex new file mode 100644 index 00000000..f4b55e47 --- /dev/null +++ b/lib/mobilizon/events/feed_token.ex @@ -0,0 +1,26 @@ +defmodule Mobilizon.Events.FeedToken do + @moduledoc """ + Represents a Token for a Feed of events + """ + use Ecto.Schema + import Ecto.Changeset + alias Mobilizon.Events.FeedToken + alias Mobilizon.Actors.Actor + alias Mobilizon.Users.User + + @primary_key false + schema "feed_tokens" do + field(:token, Ecto.UUID, primary_key: true) + belongs_to(:actor, Actor) + belongs_to(:user, User) + + timestamps(updated_at: false) + end + + @doc false + def changeset(%FeedToken{} = feed_token, attrs) do + feed_token + |> Ecto.Changeset.cast(attrs, [:token, :actor_id, :user_id]) + |> validate_required([:token, :user_id]) + end +end diff --git a/lib/mobilizon/users/user.ex b/lib/mobilizon/users/user.ex index 4828e7ec..d1bdae75 100644 --- a/lib/mobilizon/users/user.ex +++ b/lib/mobilizon/users/user.ex @@ -15,6 +15,7 @@ defmodule Mobilizon.Users.User do alias Mobilizon.Actors.Actor alias Mobilizon.Users.User alias Mobilizon.Service.EmailChecker + alias Mobilizon.Events.FeedToken schema "users" do field(:email, :string) @@ -28,6 +29,7 @@ defmodule Mobilizon.Users.User do field(:confirmation_token, :string) field(:reset_password_sent_at, :utc_datetime) field(:reset_password_token, :string) + has_many(:feed_tokens, FeedToken, foreign_key: :user_id) timestamps() end diff --git a/lib/mobilizon/users/users.ex b/lib/mobilizon/users/users.ex index 719e824c..1628cc67 100644 --- a/lib/mobilizon/users/users.ex +++ b/lib/mobilizon/users/users.ex @@ -21,6 +21,18 @@ defmodule Mobilizon.Users do queryable end + @doc """ + Register user + """ + @spec register(map()) :: {:ok, User.t()} | {:error, String.t()} + def register(%{email: _email, password: _password} = args) do + with {:ok, %User{} = user} <- + %User{} |> User.registration_changeset(args) |> Mobilizon.Repo.insert() do + Mobilizon.Events.create_feed_token(%{"user_id" => user.id}) + {:ok, user} + end + end + @doc """ Gets an user by it's email diff --git a/lib/mobilizon_web/controllers/feed_controller.ex b/lib/mobilizon_web/controllers/feed_controller.ex index 816baee4..cd4dbfa2 100644 --- a/lib/mobilizon_web/controllers/feed_controller.ex +++ b/lib/mobilizon_web/controllers/feed_controller.ex @@ -45,4 +45,32 @@ defmodule MobilizonWeb.FeedController do |> send_file(404, "priv/static/index.html") end end + + def going(conn, %{"token" => token, "format" => "ics"}) do + with {status, data} when status in [:ok, :commit] <- + Cachex.fetch(:ics, "token_" <> token) do + conn + |> put_resp_content_type("text/calendar") + |> send_resp(200, data) + else + _err -> + conn + |> put_resp_content_type("text/html") + |> send_file(404, "priv/static/index.html") + end + end + + def going(conn, %{"token" => token, "format" => "atom"}) do + with {status, data} when status in [:ok, :commit] <- + Cachex.fetch(:feed, "token_" <> token) do + conn + |> put_resp_content_type("application/atom+xml") + |> send_resp(200, data) + else + _err -> + conn + |> put_resp_content_type("text/html") + |> send_file(404, "priv/static/index.html") + end + end end diff --git a/lib/mobilizon_web/resolvers/feed_token.ex b/lib/mobilizon_web/resolvers/feed_token.ex new file mode 100644 index 00000000..02ad34bc --- /dev/null +++ b/lib/mobilizon_web/resolvers/feed_token.ex @@ -0,0 +1,77 @@ +defmodule MobilizonWeb.Resolvers.FeedToken do + @moduledoc """ + Handles the feed tokens-related GraphQL calls + """ + require Logger + alias Mobilizon.Users.User + alias Mobilizon.Events + alias Mobilizon.Events.FeedToken + + @doc """ + Create an feed token for an user and a defined actor + """ + @spec create_feed_token(any(), map(), map()) :: {:ok, FeedToken.t()} | {:error, String.t()} + def create_feed_token(_parent, %{actor_id: actor_id}, %{ + context: %{current_user: %User{id: id} = user} + }) do + with {:is_owned, true, _actor} <- User.owns_actor(user, actor_id), + {:ok, feed_token} <- Events.create_feed_token(%{"user_id" => id, "actor_id" => actor_id}) do + {:ok, feed_token} + else + {:is_owned, false} -> + {:error, "Actor id is not owned by authenticated user"} + end + end + + @doc """ + Create an feed token for an user + """ + @spec create_feed_token(any(), map(), map()) :: {:ok, FeedToken.t()} + def create_feed_token(_parent, %{}, %{ + context: %{current_user: %User{id: id}} + }) do + with {:ok, feed_token} <- Events.create_feed_token(%{"user_id" => id}) do + {:ok, feed_token} + end + end + + @spec create_feed_token(any(), map(), map()) :: {:error, String.t()} + def create_feed_token(_parent, _args, %{}) do + {:error, "You are not allowed to create a feed token if not connected"} + end + + @doc """ + Delete a feed token + """ + @spec delete_feed_token(any(), map(), map()) :: {:ok, map()} | {:error, String.t()} + def delete_feed_token(_parent, %{token: token}, %{ + context: %{current_user: %User{id: id} = _user} + }) do + with {:ok, token} <- Ecto.UUID.cast(token), + {:no_token, %FeedToken{actor: actor, user: %User{} = user} = feed_token} <- + {:no_token, Events.get_feed_token(token)}, + {:token_from_user, true} <- {:token_from_user, id == user.id}, + {:ok, _} <- Events.delete_feed_token(feed_token) do + res = %{user: %{id: id}} + res = if is_nil(actor), do: res, else: Map.put(res, :actor, %{id: actor.id}) + {:ok, res} + else + {:error, nil} -> + {:error, "No such feed token"} + + :error -> + {:error, "Token is not a valid UUID"} + + {:no_token, _} -> + {:error, "Token does not exist"} + + {:token_from_user, false} -> + {:error, "You don't have permission to delete this token"} + end + end + + @spec delete_feed_token(any(), map(), map()) :: {:error, String.t()} + def delete_feed_token(_parent, _args, %{}) do + {:error, "You are not allowed to delete a feed token if not connected"} + end +end diff --git a/lib/mobilizon_web/resolvers/user.ex b/lib/mobilizon_web/resolvers/user.ex index df9c8669..d746ce81 100644 --- a/lib/mobilizon_web/resolvers/user.ex +++ b/lib/mobilizon_web/resolvers/user.ex @@ -70,7 +70,7 @@ defmodule MobilizonWeb.Resolvers.User do """ @spec create_user(any(), map(), any()) :: tuple() def create_user(_parent, args, _resolution) do - with {:ok, %User{} = user} <- Actors.register(args) do + with {:ok, %User{} = user} <- Users.register(args) do Activation.send_confirmation_email(user) {:ok, user} end diff --git a/lib/mobilizon_web/router.ex b/lib/mobilizon_web/router.ex index 2a862732..1b16c9c7 100644 --- a/lib/mobilizon_web/router.ex +++ b/lib/mobilizon_web/router.ex @@ -64,6 +64,7 @@ defmodule MobilizonWeb.Router do get("/@:name/feed/:format", FeedController, :actor) get("/events/:uuid/export/:format", FeedController, :event) + get("/events/going/:token/:format", FeedController, :going) end scope "/", MobilizonWeb do diff --git a/lib/mobilizon_web/schema.ex b/lib/mobilizon_web/schema.ex index 65cebddc..9d140130 100644 --- a/lib/mobilizon_web/schema.ex +++ b/lib/mobilizon_web/schema.ex @@ -4,7 +4,7 @@ defmodule MobilizonWeb.Schema do """ use Absinthe.Schema - alias Mobilizon.{Actors, Events} + alias Mobilizon.{Actors, Events, Users} alias Mobilizon.Actors.{Actor, Follower, Member} alias Mobilizon.Events.{Event, Comment, Participant} @@ -104,6 +104,7 @@ defmodule MobilizonWeb.Schema do loader = Dataloader.new() |> Dataloader.add_source(Actors, Actors.data()) + |> Dataloader.add_source(Users, Users.data()) |> Dataloader.add_source(Events, Events.data()) Map.put(ctx, :loader, loader) @@ -144,6 +145,7 @@ defmodule MobilizonWeb.Schema do import_fields(:comment_mutations) import_fields(:participant_mutations) import_fields(:member_mutations) + import_fields(:feed_token_mutations) # @desc "Upload a picture" # field :upload_picture, :picture do diff --git a/lib/mobilizon_web/schema/actors/person.ex b/lib/mobilizon_web/schema/actors/person.ex index 2271c26f..8fe4c8dd 100644 --- a/lib/mobilizon_web/schema/actors/person.ex +++ b/lib/mobilizon_web/schema/actors/person.ex @@ -8,6 +8,8 @@ defmodule MobilizonWeb.Schema.Actors.PersonType do alias MobilizonWeb.Resolvers import MobilizonWeb.Schema.Utils + import_types(MobilizonWeb.Schema.Events.FeedTokenType) + @desc """ Represents a person identity """ @@ -41,6 +43,11 @@ defmodule MobilizonWeb.Schema.Actors.PersonType do field(:followersCount, :integer, description: "Number of followers for this actor") field(:followingCount, :integer, description: "Number of actors following this actor") + field(:feed_tokens, list_of(:feed_token), + resolve: dataloader(Events), + description: "A list of the feed tokens for this person" + ) + # This one should have a privacy setting field(:organized_events, list_of(:event), resolve: dataloader(Events), diff --git a/lib/mobilizon_web/schema/events/feed_token.ex b/lib/mobilizon_web/schema/events/feed_token.ex new file mode 100644 index 00000000..7be90490 --- /dev/null +++ b/lib/mobilizon_web/schema/events/feed_token.ex @@ -0,0 +1,51 @@ +defmodule MobilizonWeb.Schema.Events.FeedTokenType do + @moduledoc """ + Schema representation for Participant + """ + use Absinthe.Schema.Notation + import Absinthe.Resolution.Helpers, only: [dataloader: 1] + alias MobilizonWeb.Resolvers + alias Mobilizon.Users + alias Mobilizon.Actors + + @desc "Represents a participant to an event" + object :feed_token do + field( + :actor, + :actor, + resolve: dataloader(Actors), + description: "The event which the actor participates in" + ) + + field( + :user, + :user, + resolve: dataloader(Users), + description: "The actor that participates to the event" + ) + + field(:token, :string, description: "The role of this actor at this event") + end + + @desc "Represents a deleted feed_token" + object :deleted_feed_token do + field(:user, :deleted_object) + field(:actor, :deleted_object) + end + + object :feed_token_mutations do + @desc "Create a Feed Token" + field :create_feed_token, :feed_token do + arg(:actor_id, :integer) + + resolve(&Resolvers.FeedToken.create_feed_token/3) + end + + @desc "Delete a feed token" + field :delete_feed_token, :deleted_feed_token do + arg(:token, non_null(:string)) + + resolve(&Resolvers.FeedToken.delete_feed_token/3) + end + end +end diff --git a/lib/mobilizon_web/schema/user.ex b/lib/mobilizon_web/schema/user.ex index 24ec29ff..86bbf8d7 100644 --- a/lib/mobilizon_web/schema/user.ex +++ b/lib/mobilizon_web/schema/user.ex @@ -3,6 +3,8 @@ defmodule MobilizonWeb.Schema.UserType do Schema representation for User """ use Absinthe.Schema.Notation + import Absinthe.Resolution.Helpers, only: [dataloader: 1] + alias Mobilizon.Events alias MobilizonWeb.Resolvers.User import MobilizonWeb.Schema.Utils @@ -36,6 +38,11 @@ defmodule MobilizonWeb.Schema.UserType do field(:reset_password_token, :string, description: "The token sent when requesting password token" ) + + field(:feed_tokens, list_of(:feed_token), + resolve: dataloader(Events), + description: "A list of the feed tokens for this user" + ) end @desc "Users list" diff --git a/lib/service/export/feed.ex b/lib/service/export/feed.ex index f1a8f801..ea5d8931 100644 --- a/lib/service/export/feed.ex +++ b/lib/service/export/feed.ex @@ -3,14 +3,17 @@ defmodule Mobilizon.Service.Export.Feed do Serve Atom Syndication Feeds """ + alias Mobilizon.Users.User + alias Mobilizon.Users alias Mobilizon.Actors alias Mobilizon.Actors.Actor alias Mobilizon.Events - alias Mobilizon.Events.Event + alias Mobilizon.Events.{Event, FeedToken} alias Atomex.{Feed, Entry} import MobilizonWeb.Gettext alias MobilizonWeb.Router.Helpers, as: Routes alias MobilizonWeb.Endpoint + require Logger @version Mix.Project.config()[:version] def version(), do: @version @@ -25,6 +28,16 @@ defmodule Mobilizon.Service.Export.Feed do end end + @spec create_cache(String.t()) :: {:commit, String.t()} | {:ignore, any()} + def create_cache("token_" <> token) do + with {:ok, res} <- fetch_events_from_token(token) do + {:commit, res} + else + err -> + {:ignore, err} + end + end + @spec fetch_actor_event_feed(String.t()) :: String.t() defp fetch_actor_event_feed(name) do with %Actor{} = actor <- Actors.get_local_actor_by_name(name), @@ -37,17 +50,22 @@ defmodule Mobilizon.Service.Export.Feed do end # Build an atom feed from actor and it's public events - @spec build_actor_feed(Actor.t(), list()) :: String.t() - defp build_actor_feed(%Actor{} = actor, events) do + @spec build_actor_feed(Actor.t(), list(), boolean()) :: String.t() + defp build_actor_feed(%Actor{} = actor, events, public \\ true) do display_name = Actor.display_name(actor) self_url = Routes.feed_url(Endpoint, :actor, actor.preferred_username, "atom") |> URI.decode() + title = + if public, + do: "%{actor}'s public events feed on Mobilizon", + else: "%{actor}'s private events feed on Mobilizon" + # Title uses default instance language feed = Feed.new( self_url, DateTime.utc_now(), - gettext("%{actor}'s public events feed", actor: display_name) + Gettext.gettext(MobilizonWeb.Gettext, title, actor: display_name) ) |> Feed.author(display_name, uri: actor.url) |> Feed.link(self_url, rel: "self") @@ -86,8 +104,51 @@ defmodule Mobilizon.Service.Export.Feed do Entry.build(entry) else {:error, _html, error_messages} -> - require Logger Logger.error("Unable to produce HTML for Markdown", details: inspect(error_messages)) end end + + @spec fetch_events_from_token(String.t()) :: String.t() + defp fetch_events_from_token(token) do + with %FeedToken{actor: actor, user: %User{} = user} <- Events.get_feed_token(token) do + case actor do + %Actor{} = actor -> + events = fetch_identity_going_to_events(actor) + {:ok, build_actor_feed(actor, events, false)} + + nil -> + with actors <- Users.get_actors_for_user(user), + events <- + actors + |> Enum.map(&Events.list_event_participations_for_actor/1) + |> Enum.concat() do + {:ok, build_user_feed(events, user, token)} + end + end + end + end + + defp fetch_identity_going_to_events(%Actor{} = actor) do + with events <- Events.list_event_participations_for_actor(actor) do + events + end + end + + # Build an atom feed from actor and it's public events + @spec build_user_feed(list(), User.t(), String.t()) :: String.t() + defp build_user_feed(events, %User{email: email}, token) do + self_url = Routes.feed_url(Endpoint, :going, token, "atom") |> URI.decode() + + # Title uses default instance language + Feed.new( + self_url, + DateTime.utc_now(), + gettext("Feed for %{email} on Mobilizon", email: email) + ) + |> Feed.link(self_url, rel: "self") + |> Feed.generator("Mobilizon", uri: "https://joinmobilizon.org", version: version()) + |> Feed.entries(Enum.map(events, &get_entry/1)) + |> Feed.build() + |> Atomex.generate_document() + end end diff --git a/lib/service/export/icalendar.ex b/lib/service/export/icalendar.ex index 3467c3c6..8089fdbf 100644 --- a/lib/service/export/icalendar.ex +++ b/lib/service/export/icalendar.ex @@ -3,10 +3,12 @@ defmodule Mobilizon.Service.Export.ICalendar do Export an event to iCalendar format """ - alias Mobilizon.Events.Event + alias Mobilizon.Events.{Event, FeedToken} alias Mobilizon.Events alias Mobilizon.Actors.Actor alias Mobilizon.Actors + alias Mobilizon.Users.User + alias Mobilizon.Users @doc """ Export a public event to iCalendar format. @@ -47,6 +49,13 @@ defmodule Mobilizon.Service.Export.ICalendar do end end + @spec export_private_actor(Actor.t()) :: String.t() + def export_private_actor(%Actor{} = actor) do + with events <- Events.list_event_participations_for_actor(actor) do + {:ok, %ICalendar{events: events |> Enum.map(&do_export_event/1)} |> ICalendar.to_ics()} + end + end + @doc """ Create cache for an actor """ @@ -72,4 +81,36 @@ defmodule Mobilizon.Service.Export.ICalendar do {:ignore, err} end end + + @doc """ + Create cache for an actor + """ + def create_cache("token_" <> token) do + with {:ok, res} <- fetch_events_from_token(token) do + {:commit, res} + else + err -> + {:ignore, err} + end + end + + @spec fetch_events_from_token(String.t()) :: String.t() + defp fetch_events_from_token(token) do + with %FeedToken{actor: actor, user: %User{} = user} <- Events.get_feed_token(token) do + case actor do + %Actor{} = actor -> + export_private_actor(actor) + + nil -> + with actors <- Users.get_actors_for_user(user), + events <- + actors + |> Enum.map(&Events.list_event_participations_for_actor/1) + |> Enum.concat() do + {:ok, + %ICalendar{events: events |> Enum.map(&do_export_event/1)} |> ICalendar.to_ics()} + end + end + end + end end diff --git a/priv/repo/migrations/20190307133518_feed_token_table.exs b/priv/repo/migrations/20190307133518_feed_token_table.exs new file mode 100644 index 00000000..9e1459da --- /dev/null +++ b/priv/repo/migrations/20190307133518_feed_token_table.exs @@ -0,0 +1,13 @@ +defmodule Mobilizon.Repo.Migrations.FeedTokenTable do + use Ecto.Migration + + def change do + create table(:feed_tokens, primary_key: false) do + add(:token, Ecto.UUID.type(), primary_key: true) + add(:actor_id, references(:actors, on_delete: :delete_all), null: true) + add(:user_id, references(:users, on_delete: :delete_all), null: false) + + timestamps(updated_at: false) + end + end +end diff --git a/test/mobilizon/users/users_test.exs b/test/mobilizon/users/users_test.exs index 9284b23f..a7ebceeb 100644 --- a/test/mobilizon/users/users_test.exs +++ b/test/mobilizon/users/users_test.exs @@ -1,7 +1,6 @@ defmodule Mobilizon.UsersTest do use Mobilizon.DataCase - alias Mobilizon.Actors alias Mobilizon.Users alias Mobilizon.Users.User import Mobilizon.Factory @@ -25,7 +24,7 @@ defmodule Mobilizon.UsersTest do # There's no create_user/1, just register/1 test "register/1 with valid data creates a user" do - assert {:ok, %User{email: email} = user} = Actors.register(@valid_attrs) + assert {:ok, %User{email: email} = user} = Users.register(@valid_attrs) assert email == @valid_attrs.email end @@ -38,7 +37,7 @@ defmodule Mobilizon.UsersTest do email: {"can't be blank", [validation: :required]} ], valid?: false - }} = Actors.register(@invalid_attrs) + }} = Users.register(@invalid_attrs) end test "update_user/2 with valid data updates the user" do @@ -67,7 +66,7 @@ defmodule Mobilizon.UsersTest do @email "email@domain.tld" @password "password" test "authenticate/1 checks the user's password" do - {:ok, %User{} = user} = Actors.register(%{email: @email, password: @password}) + {:ok, %User{} = user} = Users.register(%{email: @email, password: @password}) assert {:ok, _, _} = Users.authenticate(%{user: user, password: @password}) @@ -76,7 +75,7 @@ defmodule Mobilizon.UsersTest do end test "get_user_by_email/1 finds an user by it's email" do - {:ok, %User{email: email} = user} = Actors.register(%{email: @email, password: @password}) + {:ok, %User{email: email} = user} = Users.register(%{email: @email, password: @password}) assert email == @email {:ok, %User{id: id}} = Users.get_user_by_email(@email) @@ -85,7 +84,7 @@ defmodule Mobilizon.UsersTest do end test "get_user_by_email/1 finds an activated user by it's email" do - {:ok, %User{} = user} = Actors.register(%{email: @email, password: @password}) + {:ok, %User{} = user} = Users.register(%{email: @email, password: @password}) {:ok, %User{id: id}} = Users.get_user_by_email(@email, false) assert id == user.id diff --git a/test/mobilizon_web/controllers/feed_controller_test.exs b/test/mobilizon_web/controllers/feed_controller_test.exs index 79fa1fdf..fb050458 100644 --- a/test/mobilizon_web/controllers/feed_controller_test.exs +++ b/test/mobilizon_web/controllers/feed_controller_test.exs @@ -24,7 +24,7 @@ defmodule MobilizonWeb.FeedControllerTest do {:ok, feed} = ElixirFeedParser.parse(conn.resp_body) - assert feed.title == actor.preferred_username <> "'s public events feed" + assert feed.title == actor.preferred_username <> "'s public events feed on Mobilizon" [entry1, entry2] = entries = feed.entries @@ -139,4 +139,151 @@ defmodule MobilizonWeb.FeedControllerTest do assert entry1.categories == [event1.category, tag1.slug, tag2.slug] end end + + describe "/events/going/:token/atom" do + test "it returns an atom feed of all events for all identities for an user token", %{ + conn: conn + } do + user = insert(:user) + actor1 = insert(:actor, user: user) + actor2 = insert(:actor, user: user) + event1 = insert(:event) + event2 = insert(:event) + insert(:participant, event: event1, actor: actor1) + insert(:participant, event: event2, actor: actor2) + feed_token = insert(:feed_token, user: user, actor: nil) + + conn = + conn + |> get( + Routes.feed_url(Endpoint, :going, feed_token.token, "atom") + |> URI.decode() + ) + + assert response(conn, 200) =~ "" + assert response_content_type(conn, :xml) =~ "charset=utf-8" + + {:ok, feed} = ElixirFeedParser.parse(conn.resp_body) + + assert feed.title == "Feed for #{user.email} on Mobilizon" + + entries = feed.entries + + Enum.each(entries, fn entry -> + assert entry.title in [event1.title, event2.title] + end) + end + + test "it returns an atom feed of all events a single identity for an actor token", %{ + conn: conn + } do + user = insert(:user) + actor1 = insert(:actor, user: user) + actor2 = insert(:actor, user: user) + event1 = insert(:event) + event2 = insert(:event) + insert(:participant, event: event1, actor: actor1) + insert(:participant, event: event2, actor: actor2) + feed_token = insert(:feed_token, user: user, actor: actor1) + + conn = + conn + |> put_req_header("accept", "application/atom+xml") + |> get( + Routes.feed_url(Endpoint, :going, feed_token.token, "atom") + |> URI.decode() + ) + + assert response(conn, 200) =~ "" + assert response_content_type(conn, :xml) =~ "charset=utf-8" + + {:ok, feed} = ElixirFeedParser.parse(conn.resp_body) + + assert feed.title == "#{actor1.preferred_username}'s private events feed on Mobilizon" + + [entry] = feed.entries + assert entry.title == event1.title + end + + test "it returns 404 for an not existing feed", %{conn: conn} do + conn = + conn + |> get( + Routes.feed_url(Endpoint, :going, "not existing", "atom") + |> URI.decode() + ) + + assert response(conn, 404) + end + end + + describe "/events/going/:token/ics" do + test "it returns an ical feed of all events for all identities for an user token", %{ + conn: conn + } do + user = insert(:user) + actor1 = insert(:actor, user: user) + actor2 = insert(:actor, user: user) + event1 = insert(:event) + event2 = insert(:event) + insert(:participant, event: event1, actor: actor1) + insert(:participant, event: event2, actor: actor2) + feed_token = insert(:feed_token, user: user, actor: nil) + + conn = + conn + |> put_req_header("accept", "text/calendar") + |> get( + Routes.feed_url(Endpoint, :going, feed_token.token, "ics") + |> URI.decode() + ) + + assert response(conn, 200) =~ "BEGIN:VCALENDAR" + assert response_content_type(conn, :calendar) =~ "charset=utf-8" + + entries = ExIcal.parse(conn.resp_body) + + Enum.each(entries, fn entry -> + assert entry.summary in [event1.title, event2.title] + end) + end + + test "it returns an ical feed of all events a single identity for an actor token", %{ + conn: conn + } do + user = insert(:user) + actor1 = insert(:actor, user: user) + actor2 = insert(:actor, user: user) + event1 = insert(:event) + event2 = insert(:event) + insert(:participant, event: event1, actor: actor1) + insert(:participant, event: event2, actor: actor2) + feed_token = insert(:feed_token, user: user, actor: actor1) + + conn = + conn + |> put_req_header("accept", "text/calendar") + |> get( + Routes.feed_url(Endpoint, :going, feed_token.token, "ics") + |> URI.decode() + ) + + assert response(conn, 200) =~ "BEGIN:VCALENDAR" + assert response_content_type(conn, :calendar) =~ "charset=utf-8" + + [entry1] = ExIcal.parse(conn.resp_body) + assert entry1.summary == event1.title + end + + test "it returns 404 for an not existing feed", %{conn: conn} do + conn = + conn + |> get( + Routes.feed_url(Endpoint, :going, "not existing", "ics") + |> URI.decode() + ) + + assert response(conn, 404) + end + end end diff --git a/test/mobilizon_web/resolvers/feed_token_resolver_test.exs b/test/mobilizon_web/resolvers/feed_token_resolver_test.exs new file mode 100644 index 00000000..8c5d122c --- /dev/null +++ b/test/mobilizon_web/resolvers/feed_token_resolver_test.exs @@ -0,0 +1,333 @@ +defmodule MobilizonWeb.Resolvers.FeedTokenResolverTest do + use MobilizonWeb.ConnCase + alias MobilizonWeb.AbsintheHelpers + import Mobilizon.Factory + + setup %{conn: conn} do + user = insert(:user) + actor = insert(:actor, user: user, preferred_username: "test") + insert(:actor, user: user) + + {:ok, conn: conn, actor: actor, user: user} + end + + describe "Feed Token Resolver" do + test "create_feed_token/3 should create a feed token", %{conn: conn, user: user} do + actor2 = insert(:actor, user: user) + + mutation = """ + mutation { + createFeedToken( + actor_id: #{actor2.id}, + ) { + token, + actor { + id + }, + user { + id + } + } + } + """ + + res = + conn + |> auth_conn(user) + |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + + assert json_response(res, 200)["errors"] == nil + token = json_response(res, 200)["data"]["createFeedToken"]["token"] + assert is_binary(token) + # TODO: Investigate why user id is a string when actor id is a number + assert json_response(res, 200)["data"]["createFeedToken"]["user"]["id"] == + to_string(user.id) + + assert json_response(res, 200)["data"]["createFeedToken"]["actor"]["id"] == actor2.id + + # The token is present for the user + query = """ + { + loggedUser { + feedTokens { + token + } + } + } + """ + + res = + conn + |> auth_conn(user) + |> get("/api", AbsintheHelpers.query_skeleton(query, "loggedUser")) + + assert json_response(res, 200)["data"]["loggedUser"] == + %{ + "feedTokens" => [%{"token" => token}] + } + + # But not for this identity + query = """ + { + loggedPerson { + feedTokens { + token + } + } + } + """ + + res = + conn + |> auth_conn(user) + |> get("/api", AbsintheHelpers.query_skeleton(query, "loggedPerson")) + + assert json_response(res, 200)["data"]["loggedPerson"] == + %{ + "feedTokens" => [] + } + + mutation = """ + mutation { + createFeedToken { + token, + user { + id + } + } + } + """ + + res = + conn + |> auth_conn(user) + |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + + assert json_response(res, 200)["errors"] == nil + token2 = json_response(res, 200)["data"]["createFeedToken"]["token"] + assert is_binary(token2) + assert is_nil(json_response(res, 200)["data"]["createFeedToken"]["actor"]) + + assert json_response(res, 200)["data"]["createFeedToken"]["user"]["id"] == + to_string(user.id) + + # The token is present for the user + query = """ + { + loggedUser { + feedTokens { + token + } + } + } + """ + + res = + conn + |> auth_conn(user) + |> get("/api", AbsintheHelpers.query_skeleton(query, "loggedUser")) + + assert json_response(res, 200)["data"]["loggedUser"] == + %{ + "feedTokens" => [%{"token" => token}, %{"token" => token2}] + } + end + + test "create_feed_token/3 should check the actor is owned by the user", %{ + conn: conn, + user: user + } do + actor = insert(:actor) + + mutation = """ + mutation { + createFeedToken( + actor_id: #{actor.id} + ) { + token + } + } + """ + + res = + conn + |> auth_conn(user) + |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + + assert hd(json_response(res, 200)["errors"])["message"] =~ "not owned" + end + + test "delete_feed_token/3 should delete a feed token", %{ + conn: conn, + user: user, + actor: actor + } do + feed_token = insert(:feed_token, user: user, actor: actor) + + query = """ + { + loggedPerson { + feedTokens { + token + } + } + } + """ + + res = + conn + |> auth_conn(user) + |> get("/api", AbsintheHelpers.query_skeleton(query, "loggedPerson")) + + assert json_response(res, 200)["data"]["loggedPerson"] == + %{ + "feedTokens" => [ + %{ + "token" => feed_token.token + } + ] + } + + mutation = """ + mutation { + deleteFeedToken( + token: "#{feed_token.token}", + ) { + actor { + id + }, + user { + id + } + } + } + """ + + res = + conn + |> auth_conn(user) + |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + + assert json_response(res, 200)["errors"] == nil + assert json_response(res, 200)["data"]["deleteFeedToken"]["user"]["id"] == user.id + assert json_response(res, 200)["data"]["deleteFeedToken"]["actor"]["id"] == actor.id + + query = """ + { + loggedPerson { + feedTokens { + token + } + } + } + """ + + res = + conn + |> auth_conn(user) + |> get("/api", AbsintheHelpers.query_skeleton(query, "loggedPerson")) + + assert json_response(res, 200)["data"]["loggedPerson"] == + %{ + "feedTokens" => [] + } + end + + test "delete_feed_token/3 should check the user is logged in", %{conn: conn} do + mutation = """ + mutation { + deleteFeedToken( + token: "random", + ) { + actor { + id + } + } + } + """ + + res = + conn + |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + + assert hd(json_response(res, 200)["errors"])["message"] =~ "if not connected" + end + + test "delete_feed_token/3 should check the correct user is logged in", %{ + conn: conn, + user: user + } do + user2 = insert(:user) + feed_token = insert(:feed_token, user: user2) + + mutation = """ + mutation { + deleteFeedToken( + token: "#{feed_token.token}", + ) { + actor { + id + } + } + } + """ + + res = + conn + |> auth_conn(user) + |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + + assert hd(json_response(res, 200)["errors"])["message"] =~ "don't have permission" + end + + test "delete_feed_token/3 should check the token is a valid UUID", %{ + conn: conn, + user: user + } do + mutation = """ + mutation { + deleteFeedToken( + token: "really random" + ) { + actor { + id + } + } + } + """ + + res = + conn + |> auth_conn(user) + |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + + assert hd(json_response(res, 200)["errors"])["message"] =~ "Token is not a valid UUID" + end + + test "delete_feed_token/3 should check the token exists", %{ + conn: conn, + user: user + } do + uuid = Ecto.UUID.generate() + + mutation = """ + mutation { + deleteFeedToken( + token: "#{uuid}" + ) { + actor { + id + } + } + } + """ + + res = + conn + |> auth_conn(user) + |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + + assert hd(json_response(res, 200)["errors"])["message"] =~ "does not exist" + end + end +end diff --git a/test/mobilizon_web/resolvers/user_resolver_test.exs b/test/mobilizon_web/resolvers/user_resolver_test.exs index 18879596..2f2ee6e5 100644 --- a/test/mobilizon_web/resolvers/user_resolver_test.exs +++ b/test/mobilizon_web/resolvers/user_resolver_test.exs @@ -394,7 +394,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do describe "Resolver: Validate an user" do @valid_actor_params %{email: "test@test.tld", password: "testest"} test "test validate_user/3 validates an user", context do - {:ok, %User{} = user} = Actors.register(@valid_actor_params) + {:ok, %User{} = user} = Users.register(@valid_actor_params) mutation = """ mutation { @@ -443,7 +443,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do describe "Resolver: Resend confirmation emails" do test "test resend_confirmation_email/3 with valid email resends an validation email", context do - {:ok, %User{} = user} = Actors.register(%{email: "toto@tata.tld", password: "p4ssw0rd"}) + {:ok, %User{} = user} = Users.register(%{email: "toto@tata.tld", password: "p4ssw0rd"}) mutation = """ mutation { @@ -531,7 +531,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do describe "Resolver: Reset user's password" do test "test reset_password/3 with valid email", context do - {:ok, %User{} = user} = Actors.register(%{email: "toto@tata.tld", password: "p4ssw0rd"}) + {:ok, %User{} = user} = Users.register(%{email: "toto@tata.tld", password: "p4ssw0rd"}) %Actor{} = insert(:actor, user: user) {:ok, _email_sent} = ResetPassword.send_password_reset_email(user) %User{reset_password_token: reset_password_token} = Mobilizon.Users.get_user!(user.id) @@ -611,7 +611,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do describe "Resolver: Login an user" do test "test login_user/3 with valid credentials", context do - {:ok, %User{} = user} = Actors.register(%{email: "toto@tata.tld", password: "p4ssw0rd"}) + {:ok, %User{} = user} = Users.register(%{email: "toto@tata.tld", password: "p4ssw0rd"}) {:ok, %User{} = _user} = Users.update_user(user, %{ @@ -643,7 +643,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do end test "test login_user/3 with invalid password", context do - {:ok, %User{} = user} = Actors.register(%{email: "toto@tata.tld", password: "p4ssw0rd"}) + {:ok, %User{} = user} = Users.register(%{email: "toto@tata.tld", password: "p4ssw0rd"}) {:ok, %User{} = _user} = Users.update_user(user, %{ diff --git a/test/support/factory.ex b/test/support/factory.ex index d34e9d0b..3584b3d5 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -152,4 +152,14 @@ defmodule Mobilizon.Factory do role: :not_approved } end + + def feed_token_factory do + user = build(:user) + + %Mobilizon.Events.FeedToken{ + user: user, + actor: build(:actor, user: user), + token: Ecto.UUID.generate() + } + end end