Add backend and endpoints for Feed Tokens

Closes #19 #86 #87

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2019-03-08 12:25:06 +01:00
parent 98cce0c78a
commit 30a5811b36
11 changed files with 476 additions and 9 deletions

View File

@ -24,7 +24,7 @@ defmodule Mobilizon.Actors.Actor do
alias Mobilizon.Actors alias Mobilizon.Actors
alias Mobilizon.Users.User alias Mobilizon.Users.User
alias Mobilizon.Actors.{Actor, Follower, Member} alias Mobilizon.Actors.{Actor, Follower, Member}
alias Mobilizon.Events.Event alias Mobilizon.Events.{Event, FeedToken}
import Ecto.Query import Ecto.Query
import Mobilizon.Ecto import Mobilizon.Ecto
@ -58,6 +58,7 @@ defmodule Mobilizon.Actors.Actor do
has_many(:organized_events, Event, foreign_key: :organizer_actor_id) has_many(:organized_events, Event, foreign_key: :organizer_actor_id)
many_to_many(:memberships, Actor, join_through: Member) many_to_many(:memberships, Actor, join_through: Member)
belongs_to(:user, User) belongs_to(:user, User)
has_many(:feed_tokens, FeedToken, foreign_key: :actor_id)
timestamps() timestamps()
end end

View File

@ -577,7 +577,7 @@ defmodule Mobilizon.Events do
## Examples ## Examples
iex> list_participants_for_event(someuuid) iex> list_participants_for_event(some_uuid)
[%Participant{}, ...] [%Participant{}, ...]
""" """
@ -594,6 +594,32 @@ defmodule Mobilizon.Events do
) )
end 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 """ @doc """
Returns the list of organizers participants for an event. Returns the list of organizers participants for an event.
@ -1119,4 +1145,115 @@ defmodule Mobilizon.Events do
def change_comment(%Comment{} = comment) do def change_comment(%Comment{} = comment) do
Comment.changeset(comment, %{}) Comment.changeset(comment, %{})
end 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 end

View File

@ -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

View File

@ -15,6 +15,7 @@ defmodule Mobilizon.Users.User do
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Users.User alias Mobilizon.Users.User
alias Mobilizon.Service.EmailChecker alias Mobilizon.Service.EmailChecker
alias Mobilizon.Events.FeedToken
schema "users" do schema "users" do
field(:email, :string) field(:email, :string)
@ -28,6 +29,7 @@ defmodule Mobilizon.Users.User do
field(:confirmation_token, :string) field(:confirmation_token, :string)
field(:reset_password_sent_at, :utc_datetime) field(:reset_password_sent_at, :utc_datetime)
field(:reset_password_token, :string) field(:reset_password_token, :string)
has_many(:feed_tokens, FeedToken, foreign_key: :user_id)
timestamps() timestamps()
end end

View File

@ -45,4 +45,32 @@ defmodule MobilizonWeb.FeedController do
|> send_file(404, "priv/static/index.html") |> send_file(404, "priv/static/index.html")
end end
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 end

View File

@ -64,6 +64,7 @@ defmodule MobilizonWeb.Router do
get("/@:name/feed/:format", FeedController, :actor) get("/@:name/feed/:format", FeedController, :actor)
get("/events/:uuid/export/:format", FeedController, :event) get("/events/:uuid/export/:format", FeedController, :event)
get("/events/going/:token/:format", FeedController, :going)
end end
scope "/", MobilizonWeb do scope "/", MobilizonWeb do

View File

@ -3,14 +3,17 @@ defmodule Mobilizon.Service.Export.Feed do
Serve Atom Syndication Feeds Serve Atom Syndication Feeds
""" """
alias Mobilizon.Users.User
alias Mobilizon.Users
alias Mobilizon.Actors alias Mobilizon.Actors
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Events alias Mobilizon.Events
alias Mobilizon.Events.Event alias Mobilizon.Events.{Event, FeedToken}
alias Atomex.{Feed, Entry} alias Atomex.{Feed, Entry}
import MobilizonWeb.Gettext import MobilizonWeb.Gettext
alias MobilizonWeb.Router.Helpers, as: Routes alias MobilizonWeb.Router.Helpers, as: Routes
alias MobilizonWeb.Endpoint alias MobilizonWeb.Endpoint
require Logger
@version Mix.Project.config()[:version] @version Mix.Project.config()[:version]
def version(), do: @version def version(), do: @version
@ -25,6 +28,16 @@ defmodule Mobilizon.Service.Export.Feed do
end end
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() @spec fetch_actor_event_feed(String.t()) :: String.t()
defp fetch_actor_event_feed(name) do defp fetch_actor_event_feed(name) do
with %Actor{} = actor <- Actors.get_local_actor_by_name(name), with %Actor{} = actor <- Actors.get_local_actor_by_name(name),
@ -37,17 +50,22 @@ defmodule Mobilizon.Service.Export.Feed do
end end
# Build an atom feed from actor and it's public events # Build an atom feed from actor and it's public events
@spec build_actor_feed(Actor.t(), list()) :: String.t() @spec build_actor_feed(Actor.t(), list(), boolean()) :: String.t()
defp build_actor_feed(%Actor{} = actor, events) do defp build_actor_feed(%Actor{} = actor, events, public \\ true) do
display_name = Actor.display_name(actor) display_name = Actor.display_name(actor)
self_url = Routes.feed_url(Endpoint, :actor, actor.preferred_username, "atom") |> URI.decode() 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 # Title uses default instance language
feed = feed =
Feed.new( Feed.new(
self_url, self_url,
DateTime.utc_now(), 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.author(display_name, uri: actor.url)
|> Feed.link(self_url, rel: "self") |> Feed.link(self_url, rel: "self")
@ -86,8 +104,51 @@ defmodule Mobilizon.Service.Export.Feed do
Entry.build(entry) Entry.build(entry)
else else
{:error, _html, error_messages} -> {:error, _html, error_messages} ->
require Logger
Logger.error("Unable to produce HTML for Markdown", details: inspect(error_messages)) Logger.error("Unable to produce HTML for Markdown", details: inspect(error_messages))
end end
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 end

View File

@ -3,10 +3,12 @@ defmodule Mobilizon.Service.Export.ICalendar do
Export an event to iCalendar format Export an event to iCalendar format
""" """
alias Mobilizon.Events.Event alias Mobilizon.Events.{Event, FeedToken}
alias Mobilizon.Events alias Mobilizon.Events
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Actors alias Mobilizon.Actors
alias Mobilizon.Users.User
alias Mobilizon.Users
@doc """ @doc """
Export a public event to iCalendar format. Export a public event to iCalendar format.
@ -47,6 +49,13 @@ defmodule Mobilizon.Service.Export.ICalendar do
end end
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 """ @doc """
Create cache for an actor Create cache for an actor
""" """
@ -72,4 +81,36 @@ defmodule Mobilizon.Service.Export.ICalendar do
{:ignore, err} {:ignore, err}
end end
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 end

View File

@ -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

View File

@ -24,7 +24,7 @@ defmodule MobilizonWeb.FeedControllerTest do
{:ok, feed} = ElixirFeedParser.parse(conn.resp_body) {: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 [entry1, entry2] = entries = feed.entries
@ -139,4 +139,151 @@ defmodule MobilizonWeb.FeedControllerTest do
assert entry1.categories == [event1.category, tag1.slug, tag2.slug] assert entry1.categories == [event1.category, tag1.slug, tag2.slug]
end end
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) =~ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
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) =~ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
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 end

View File

@ -152,4 +152,14 @@ defmodule Mobilizon.Factory do
role: :not_approved role: :not_approved
} }
end 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 end