Merge branch 'feature/going-feeds' into 'master'

Add backend and endpoints for Feed Tokens

Closes #89, #88, #87, #86 et #19

See merge request framasoft/mobilizon!91
This commit is contained in:
Thomas Citharel 2019-03-08 18:59:55 +01:00
commit 39311d2564
23 changed files with 1020 additions and 38 deletions

View File

@ -123,7 +123,6 @@
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
{Credo.Check.Warning.IExPry, []}, {Credo.Check.Warning.IExPry, []},
{Credo.Check.Warning.IoInspect, []}, {Credo.Check.Warning.IoInspect, []},
{Credo.Check.Warning.LazyLogging, []},
{Credo.Check.Warning.OperationOnSameValues, []}, {Credo.Check.Warning.OperationOnSameValues, []},
{Credo.Check.Warning.OperationWithConstantResult, []}, {Credo.Check.Warning.OperationWithConstantResult, []},
{Credo.Check.Warning.RaiseInsideRescue, []}, {Credo.Check.Warning.RaiseInsideRescue, []},
@ -147,11 +146,17 @@
{Credo.Check.Refactor.DoubleBooleanNegation, false}, {Credo.Check.Refactor.DoubleBooleanNegation, false},
{Credo.Check.Refactor.VariableRebinding, false}, {Credo.Check.Refactor.VariableRebinding, false},
{Credo.Check.Warning.MapGetUnsafePass, 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`. # Custom checks can be created using `mix credo.gen.check`.
# #
#
# Removed checks
#
{Credo.Check.Warning.LazyLogging, false},
{Credo.Check.Refactor.MapInto, false},
] ]
} }
] ]

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

@ -9,7 +9,6 @@ defmodule Mobilizon.Actors do
alias Mobilizon.Repo alias Mobilizon.Repo
alias Mobilizon.Actors.{Actor, Bot, Member, Follower} alias Mobilizon.Actors.{Actor, Bot, Member, Follower}
alias Mobilizon.Users.User
alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub
# import Exgravatar # import Exgravatar
@ -505,17 +504,6 @@ defmodule Mobilizon.Actors do
[entry] |> :public_key.pem_encode() |> String.trim_trailing() [entry] |> :public_key.pem_encode() |> String.trim_trailing()
end 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 """ @doc """
Create a new person actor Create a new person actor
""" """
@ -525,8 +513,13 @@ defmodule Mobilizon.Actors do
pem = [entry] |> :public_key.pem_encode() |> String.trim_trailing() pem = [entry] |> :public_key.pem_encode() |> String.trim_trailing()
args = Map.put(args, :keys, pem) args = Map.put(args, :keys, pem)
actor = Mobilizon.Actors.Actor.registration_changeset(%Mobilizon.Actors.Actor{}, args) with {:ok, %Actor{} = person} <-
Mobilizon.Repo.insert(actor) %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 end
def register_bot_account(%{name: name, summary: summary}) do def register_bot_account(%{name: name, summary: summary}) do

View File

@ -9,6 +9,7 @@ defmodule Mobilizon.Events do
alias Mobilizon.Repo alias Mobilizon.Repo
alias Mobilizon.Events.{Event, Comment, Participant} alias Mobilizon.Events.{Event, Comment, Participant}
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Users.User
alias Mobilizon.Addresses.Address alias Mobilizon.Addresses.Address
def data() do def data() do
@ -577,7 +578,7 @@ defmodule Mobilizon.Events do
## Examples ## Examples
iex> list_participants_for_event(someuuid) iex> list_participants_for_event(some_uuid)
[%Participant{}, ...] [%Participant{}, ...]
""" """
@ -594,6 +595,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 +1146,143 @@ 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 """
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 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_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

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

@ -21,6 +21,18 @@ defmodule Mobilizon.Users do
queryable queryable
end 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 """ @doc """
Gets an user by it's email Gets an user by it's email

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

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

View File

@ -70,7 +70,7 @@ defmodule MobilizonWeb.Resolvers.User do
""" """
@spec create_user(any(), map(), any()) :: tuple() @spec create_user(any(), map(), any()) :: tuple()
def create_user(_parent, args, _resolution) do 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) Activation.send_confirmation_email(user)
{:ok, user} {:ok, user}
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

@ -4,7 +4,7 @@ defmodule MobilizonWeb.Schema do
""" """
use Absinthe.Schema use Absinthe.Schema
alias Mobilizon.{Actors, Events} alias Mobilizon.{Actors, Events, Users}
alias Mobilizon.Actors.{Actor, Follower, Member} alias Mobilizon.Actors.{Actor, Follower, Member}
alias Mobilizon.Events.{Event, Comment, Participant} alias Mobilizon.Events.{Event, Comment, Participant}
@ -104,6 +104,7 @@ defmodule MobilizonWeb.Schema do
loader = loader =
Dataloader.new() Dataloader.new()
|> Dataloader.add_source(Actors, Actors.data()) |> Dataloader.add_source(Actors, Actors.data())
|> Dataloader.add_source(Users, Users.data())
|> Dataloader.add_source(Events, Events.data()) |> Dataloader.add_source(Events, Events.data())
Map.put(ctx, :loader, loader) Map.put(ctx, :loader, loader)
@ -144,6 +145,7 @@ defmodule MobilizonWeb.Schema do
import_fields(:comment_mutations) import_fields(:comment_mutations)
import_fields(:participant_mutations) import_fields(:participant_mutations)
import_fields(:member_mutations) import_fields(:member_mutations)
import_fields(:feed_token_mutations)
# @desc "Upload a picture" # @desc "Upload a picture"
# field :upload_picture, :picture do # field :upload_picture, :picture do

View File

@ -8,6 +8,8 @@ defmodule MobilizonWeb.Schema.Actors.PersonType do
alias MobilizonWeb.Resolvers alias MobilizonWeb.Resolvers
import MobilizonWeb.Schema.Utils import MobilizonWeb.Schema.Utils
import_types(MobilizonWeb.Schema.Events.FeedTokenType)
@desc """ @desc """
Represents a person identity 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(:followersCount, :integer, description: "Number of followers for this actor")
field(:followingCount, :integer, description: "Number of actors following 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 # This one should have a privacy setting
field(:organized_events, list_of(:event), field(:organized_events, list_of(:event),
resolve: dataloader(Events), resolve: dataloader(Events),

View File

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

View File

@ -3,6 +3,8 @@ defmodule MobilizonWeb.Schema.UserType do
Schema representation for User Schema representation for User
""" """
use Absinthe.Schema.Notation use Absinthe.Schema.Notation
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.Events
alias MobilizonWeb.Resolvers.User alias MobilizonWeb.Resolvers.User
import MobilizonWeb.Schema.Utils import MobilizonWeb.Schema.Utils
@ -36,6 +38,11 @@ defmodule MobilizonWeb.Schema.UserType do
field(:reset_password_token, :string, field(:reset_password_token, :string,
description: "The token sent when requesting password token" 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 end
@desc "Users list" @desc "Users list"

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

View File

@ -1,7 +1,6 @@
defmodule Mobilizon.UsersTest do defmodule Mobilizon.UsersTest do
use Mobilizon.DataCase use Mobilizon.DataCase
alias Mobilizon.Actors
alias Mobilizon.Users alias Mobilizon.Users
alias Mobilizon.Users.User alias Mobilizon.Users.User
import Mobilizon.Factory import Mobilizon.Factory
@ -25,7 +24,7 @@ defmodule Mobilizon.UsersTest do
# There's no create_user/1, just register/1 # There's no create_user/1, just register/1
test "register/1 with valid data creates a user" do 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 assert email == @valid_attrs.email
end end
@ -38,7 +37,7 @@ defmodule Mobilizon.UsersTest do
email: {"can't be blank", [validation: :required]} email: {"can't be blank", [validation: :required]}
], ],
valid?: false valid?: false
}} = Actors.register(@invalid_attrs) }} = Users.register(@invalid_attrs)
end end
test "update_user/2 with valid data updates the user" do test "update_user/2 with valid data updates the user" do
@ -67,7 +66,7 @@ defmodule Mobilizon.UsersTest do
@email "email@domain.tld" @email "email@domain.tld"
@password "password" @password "password"
test "authenticate/1 checks the user's password" do 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}) assert {:ok, _, _} = Users.authenticate(%{user: user, password: @password})
@ -76,7 +75,7 @@ defmodule Mobilizon.UsersTest do
end end
test "get_user_by_email/1 finds an user by it's email" do 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 assert email == @email
{:ok, %User{id: id}} = Users.get_user_by_email(@email) {:ok, %User{id: id}} = Users.get_user_by_email(@email)
@ -85,7 +84,7 @@ defmodule Mobilizon.UsersTest do
end end
test "get_user_by_email/1 finds an activated user by it's email" do 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) {:ok, %User{id: id}} = Users.get_user_by_email(@email, false)
assert id == user.id assert id == user.id

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

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

View File

@ -394,7 +394,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do
describe "Resolver: Validate an user" do describe "Resolver: Validate an user" do
@valid_actor_params %{email: "test@test.tld", password: "testest"} @valid_actor_params %{email: "test@test.tld", password: "testest"}
test "test validate_user/3 validates an user", context do 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 = """
mutation { mutation {
@ -443,7 +443,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do
describe "Resolver: Resend confirmation emails" do describe "Resolver: Resend confirmation emails" do
test "test resend_confirmation_email/3 with valid email resends an validation email", test "test resend_confirmation_email/3 with valid email resends an validation email",
context do 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 = """
mutation { mutation {
@ -531,7 +531,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do
describe "Resolver: Reset user's password" do describe "Resolver: Reset user's password" do
test "test reset_password/3 with valid email", context 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) %Actor{} = insert(:actor, user: user)
{:ok, _email_sent} = ResetPassword.send_password_reset_email(user) {:ok, _email_sent} = ResetPassword.send_password_reset_email(user)
%User{reset_password_token: reset_password_token} = Mobilizon.Users.get_user!(user.id) %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 describe "Resolver: Login an user" do
test "test login_user/3 with valid credentials", context 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} = {:ok, %User{} = _user} =
Users.update_user(user, %{ Users.update_user(user, %{
@ -643,7 +643,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do
end end
test "test login_user/3 with invalid password", context do 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} = {:ok, %User{} = _user} =
Users.update_user(user, %{ Users.update_user(user, %{

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