diff --git a/lib/mobilizon/actors/actor.ex b/lib/mobilizon/actors/actor.ex index b3709829f..a5feaf5cd 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/events/events.ex b/lib/mobilizon/events/events.ex index 61263efc6..749db611a 100644 --- a/lib/mobilizon/events/events.ex +++ b/lib/mobilizon/events/events.ex @@ -577,7 +577,7 @@ defmodule Mobilizon.Events do ## Examples - iex> list_participants_for_event(someuuid) + iex> list_participants_for_event(some_uuid) [%Participant{}, ...] """ @@ -594,6 +594,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 +1145,115 @@ 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 """ + 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 + %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 000000000..6af9e7a89 --- /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_token" do + field(:token, :string, 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 4828e7ec9..d1bdae752 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_web/controllers/feed_controller.ex b/lib/mobilizon_web/controllers/feed_controller.ex index 816baee47..cd4dbfa2d 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/router.ex b/lib/mobilizon_web/router.ex index 2a8627323..1b16c9c76 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/service/export/feed.ex b/lib/service/export/feed.ex index f1a8f801c..ea5d89314 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 3467c3c6a..8089fdbf8 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 000000000..ce79de20a --- /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_token, primary_key: false) do + add(:token, :string, 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_web/controllers/feed_controller_test.exs b/test/mobilizon_web/controllers/feed_controller_test.exs index 79fa1fdf2..fb0504586 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/support/factory.ex b/test/support/factory.ex index d34e9d0b9..3584b3d5b 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