Add anonymous and remote participations

This commit is contained in:
Thomas Citharel
2019-12-20 13:04:34 +01:00
parent 17e0b3968f
commit 2ed9050a90
135 changed files with 10141 additions and 2271 deletions

View File

@@ -426,19 +426,25 @@ defmodule Mobilizon.Federation.ActivityPub do
# TODO Refactor me for federation
with {:maximum_attendee_capacity, true} <-
{:maximum_attendee_capacity, check_attendee_capacity(event)},
role <-
additional
|> Map.get(:metadata, %{})
|> Map.get(:role, Mobilizon.Events.get_default_participant_role(event)),
{:ok, %Participant{} = participant} <-
Mobilizon.Events.create_participant(%{
role: :not_approved,
role: role,
event_id: event.id,
actor_id: actor.id,
url: Map.get(additional, :url)
url: Map.get(additional, :url),
metadata: Map.get(additional, :metadata)
}),
join_data <- Convertible.model_to_as(participant),
audience <-
Audience.calculate_to_and_cc_from_mentions(participant),
{:ok, activity} <- create_activity(Map.merge(join_data, audience), local),
:ok <- maybe_federate(activity) do
if event.local && Mobilizon.Events.get_default_participant_role(event) === :participant do
if event.local && Mobilizon.Events.get_default_participant_role(event) === :participant &&
role == :participant do
accept(
:join,
participant,
@@ -464,19 +470,24 @@ defmodule Mobilizon.Federation.ActivityPub do
end
end
def leave(object, actor, local \\ true)
def leave(object, actor, local \\ true, additional \\ %{})
# TODO: If we want to use this for exclusion we need to have an extra field
# for the actor that excluded the participant
def leave(
%Event{id: event_id, url: event_url} = _event,
%Actor{id: actor_id, url: actor_url} = _actor,
local
local,
additional
) do
with {:only_organizer, false} <-
{:only_organizer, Participant.is_not_only_organizer(event_id, actor_id)},
{:ok, %Participant{} = participant} <-
Mobilizon.Events.get_participant(event_id, actor_id),
Mobilizon.Events.get_participant(
event_id,
actor_id,
Map.get(additional, :metadata, %{})
),
{:ok, %Participant{} = participant} <-
Events.delete_participant(participant),
leave_data <- %{
@@ -604,6 +615,7 @@ defmodule Mobilizon.Federation.ActivityPub do
digest = Signature.build_digest(json)
date = Signature.generate_date_header()
# request_target = Signature.generate_request_target("POST", path)
signature =
@@ -823,7 +835,7 @@ defmodule Mobilizon.Federation.ActivityPub do
@spec reject_join(Participant.t(), map()) :: {:ok, Participant.t(), Activity.t()} | any()
defp reject_join(%Participant{} = participant, additional) do
with {:ok, %Participant{} = participant} <-
Events.update_participant(participant, %{approved: false, role: :rejected}),
Events.update_participant(participant, %{role: :rejected}),
Absinthe.Subscription.publish(Endpoint, participant.actor,
event_person_participation_changed: participant.actor.id
),
@@ -908,6 +920,18 @@ defmodule Mobilizon.Federation.ActivityPub do
args
end
# Check that we can only allow anonymous participation if our instance allows it
{_, options} =
Map.get_and_update(
Map.get(args, :options, %{anonymous_participation: false}),
:anonymous_participation,
fn value ->
{value, value && Mobilizon.Config.anonymous_participation?()}
end
)
args = Map.put(args, :options, options)
Map.update(args, :tags, [], &ConverterUtils.fetch_tags/1)
end

View File

@@ -17,8 +17,6 @@ defmodule Mobilizon.Federation.ActivityPub.Relay do
alias Mobilizon.GraphQL.API.Follows
alias Mobilizon.Web.Endpoint
require Logger
def init do
@@ -30,7 +28,7 @@ defmodule Mobilizon.Federation.ActivityPub.Relay do
@spec get_actor() :: Actor.t() | {:error, Ecto.Changeset.t()}
def get_actor do
with {:ok, %Actor{} = actor} <-
Actors.get_or_create_instance_actor_by_url("#{Endpoint.url()}/relay") do
Actors.get_or_create_internal_actor("relay") do
actor
end
end

View File

@@ -73,6 +73,10 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
"joinMode" => %{
"@id" => "mz:joinMode",
"@type" => "mz:joinModeType"
},
"anonymousParticipationEnabled" => %{
"@id" => "mz:anonymousParticipationEnabled",
"@type" => "sc:Boolean"
}
}
]

View File

@@ -121,6 +121,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
"maximumAttendeeCapacity" => event.options.maximum_attendee_capacity,
"repliesModerationOption" => event.options.comment_moderation,
"commentsEnabled" => event.options.comment_moderation == :allow_all,
"anonymousParticipationEnabled" => event.options.anonymous_participation,
# "draft" => event.draft,
"ical:status" => event.status |> to_string |> String.upcase(),
"id" => event.url,
@@ -142,6 +143,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
defp get_options(object) do
%{
maximum_attendee_capacity: object["maximumAttendeeCapacity"],
anonymous_participation: object["anonymousParticipationEnabled"],
comment_moderation:
Map.get(
object,

View File

@@ -10,11 +10,9 @@ defmodule Mobilizon.Federation.WebFinger do
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.WebFinger.XmlBuilder
alias Mobilizon.Web.Endpoint
alias Mobilizon.Web.Router.Helpers, as: Routes
require Jason
require Logger
@@ -69,6 +67,10 @@ defmodule Mobilizon.Federation.WebFinger do
"rel" => "https://webfinger.net/rel/profile-page/",
"type" => "text/html",
"href" => actor.url
},
%{
"rel" => "http://ostatus.org/schema/1.0/subscribe",
"template" => "#{Routes.page_url(Endpoint, :interact, uri: nil)}{uri}"
}
]
}

View File

@@ -4,22 +4,26 @@ defmodule Mobilizon.GraphQL.API.Participations do
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Events
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.Activity
alias Mobilizon.Web.Email.Participation
@spec join(Event.t(), Actor.t()) :: {:ok, Participant.t()}
def join(%Event{id: event_id} = event, %Actor{id: actor_id} = actor) do
with {:error, :participant_not_found} <- Mobilizon.Events.get_participant(event_id, actor_id),
{:ok, activity, participant} <- ActivityPub.join(event, actor, true) do
@spec join(Event.t(), Actor.t(), map()) :: {:ok, Activity.t(), Participant.t()}
def join(%Event{id: event_id} = event, %Actor{id: actor_id} = actor, args \\ %{}) do
with {:error, :participant_not_found} <-
Mobilizon.Events.get_participant(event_id, actor_id, args),
{:ok, activity, participant} <-
ActivityPub.join(event, actor, Map.get(args, :local, true), %{metadata: args}) do
{:ok, activity, participant}
end
end
def leave(%Event{} = event, %Actor{} = actor) do
with {:ok, activity, participant} <- ActivityPub.leave(event, actor, true) do
@spec leave(Event.t(), Actor.t()) :: {:ok, Activity.t(), Participant.t()}
def leave(%Event{} = event, %Actor{} = actor, args \\ %{}) do
with {:ok, activity, participant} <-
ActivityPub.leave(event, actor, Map.get(args, :local, true), %{metadata: args}) do
{:ok, activity, participant}
end
end
@@ -27,14 +31,23 @@ defmodule Mobilizon.GraphQL.API.Participations do
@doc """
Update participation status
"""
def update(%Participant{} = participation, %Actor{} = moderator, :participant) do
accept(participation, moderator)
@spec update(Participant.t(), Actor.t(), atom()) :: {:ok, Activity.t(), Participant.t()}
def update(%Participant{} = participation, %Actor{} = moderator, :participant),
do: accept(participation, moderator)
@spec update(Participant.t(), Actor.t(), atom()) :: {:ok, Activity.t(), Participant.t()}
def update(%Participant{} = participation, %Actor{} = _moderator, :not_approved) do
with {:ok, %Participant{} = participant} <-
Events.update_participant(participation, %{role: :not_approved}) do
{:ok, nil, participant}
end
end
def update(%Participant{} = participation, %Actor{} = moderator, :rejected) do
reject(participation, moderator)
end
@spec update(Participant.t(), Actor.t(), atom()) :: {:ok, Activity.t(), Participant.t()}
def update(%Participant{} = participation, %Actor{} = moderator, :rejected),
do: reject(participation, moderator)
@spec accept(Participant.t(), Actor.t()) :: {:ok, Activity.t(), Participant.t()}
defp accept(
%Participant{} = participation,
%Actor{} = moderator
@@ -51,6 +64,7 @@ defmodule Mobilizon.GraphQL.API.Participations do
end
end
@spec reject(Participant.t(), Actor.t()) :: {:ok, Activity.t(), Participant.t()}
defp reject(
%Participant{} = participation,
%Actor{} = moderator

View File

@@ -5,18 +5,18 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
import Mobilizon.Users.Guards
alias Mobilizon.Actors
alias Mobilizon.{Actors, Admin, Config, Events}
alias Mobilizon.Actors.Actor
alias Mobilizon.Admin.ActionLog
alias Mobilizon.Events
alias Mobilizon.Admin.{ActionLog, Setting}
alias Mobilizon.Config
alias Mobilizon.Events.{Comment, Event}
alias Mobilizon.Events.{Comment, Event}
alias Mobilizon.Federation.ActivityPub.Relay
alias Mobilizon.Reports.{Note, Report}
alias Mobilizon.Service.Statistics
alias Mobilizon.Storage.Page
alias Mobilizon.Users.User
alias Mobilizon.Federation.ActivityPub.Relay
def list_action_logs(
_parent,
%{page: page, limit: limit},
@@ -132,6 +132,43 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
{:error, "You need to be logged-in and an administrator to access dashboard statistics"}
end
def get_settings(_parent, _args, %{
context: %{current_user: %User{role: role}}
})
when is_admin(role) do
{:ok,
%{
instance_description: Config.instance_description(),
instance_name: Config.instance_name(),
registrations_open: Config.instance_registrations_open?(),
instance_terms: Config.instance_terms(),
instance_terms_type: Config.instance_terms_type(),
instance_terms_url: Config.instance_terms_url()
}}
end
def get_settings(_parent, _args, _resolution) do
{:error, "You need to be logged-in and an administrator to access admin settings"}
end
def save_settings(_parent, args, %{
context: %{current_user: %User{role: role}}
})
when is_admin(role) do
with {:ok, res} <- Admin.save_settings("instance", args) do
res =
res |> Enum.map(fn {key, %Setting{value: value}} -> {key, value} end) |> Enum.into(%{})
Config.clear_config_cache()
{:ok, res}
end
end
def save_settings(_parent, _args, _resolution) do
{:error, "You need to be logged-in and an administrator to save admin settings"}
end
def list_relay_followers(
_parent,
%{page: page, limit: limit},

View File

@@ -25,25 +25,82 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do
_ -> nil
end
{:ok,
%{
name: Config.instance_name(),
registrations_open: Config.instance_registrations_open?(),
registrations_whitelist: Config.instance_registrations_whitelist?(),
demo_mode: Config.instance_demo_mode?(),
description: Config.instance_description(),
location: location,
country_code: country_code,
geocoding: %{
provider: Config.instance_geocoding_provider(),
autocomplete: Config.instance_geocoding_autocomplete()
},
maps: %{
tiles: %{
endpoint: Config.instance_maps_tiles_endpoint(),
attribution: Config.instance_maps_tiles_attribution()
}
}
}}
data = Map.merge(config_cache(), %{location: location, country_code: country_code})
{:ok, data}
end
def terms(_parent, %{locale: locale}, _resolution) do
type = Config.instance_terms_type()
{url, body_html} =
case type do
"URL" -> {Config.instance_terms_url(), nil}
"DEFAULT" -> {nil, Config.generate_terms(locale)}
_ -> {nil, Config.instance_terms(locale)}
end
{:ok, %{body_html: body_html, type: type, url: url}}
end
defp config_cache do
case Cachex.fetch(:config, "full_config", fn _key ->
case build_config_cache() do
value when not is_nil(value) -> {:commit, value}
err -> {:ignore, err}
end
end) do
{status, value} when status in [:ok, :commit] -> value
_err -> nil
end
end
defp build_config_cache do
%{
name: Config.instance_name(),
registrations_open: Config.instance_registrations_open?(),
registrations_whitelist: Config.instance_registrations_whitelist?(),
demo_mode: Config.instance_demo_mode?(),
description: Config.instance_description(),
anonymous: %{
participation: %{
allowed: Config.anonymous_participation?(),
validation: %{
email: %{
enabled: Config.anonymous_participation_email_required?(),
confirmation_required:
Config.anonymous_event_creation_email_confirmation_required?()
},
captcha: %{
enabled: Config.anonymous_event_creation_email_captcha_required?()
}
}
},
event_creation: %{
allowed: Config.anonymous_event_creation?(),
validation: %{
email: %{
enabled: Config.anonymous_event_creation_email_required?(),
confirmation_required:
Config.anonymous_event_creation_email_confirmation_required?()
},
captcha: %{
enabled: Config.anonymous_event_creation_email_captcha_required?()
}
}
},
actor_id: Config.anonymous_actor_id()
},
geocoding: %{
provider: Config.instance_geocoding_provider(),
autocomplete: Config.instance_geocoding_autocomplete()
},
maps: %{
tiles: %{
endpoint: Config.instance_maps_tiles_endpoint(),
attribution: Config.instance_maps_tiles_attribution()
}
}
}
end
end

View File

@@ -5,7 +5,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
alias Mobilizon.{Actors, Admin, Events}
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.{Event, EventParticipantStats, Participant}
alias Mobilizon.Events.{Event, EventParticipantStats}
alias Mobilizon.Users.User
alias Mobilizon.GraphQL.API
@@ -19,7 +19,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
def list_events(_parent, %{page: page, limit: limit}, _resolution)
when limit < @event_max_limit do
{:ok, Mobilizon.Events.list_events(page, limit)}
{:ok, Events.list_events(page, limit)}
end
def list_events(_parent, %{page: _page, limit: _limit}, _resolution) do
@@ -31,7 +31,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
%{uuid: uuid},
%{context: %{current_user: %User{id: user_id}}} = _resolution
) do
case {:has_event, Mobilizon.Events.get_own_event_by_uuid_with_preload(uuid, user_id)} do
case {:has_event, Events.get_own_event_by_uuid_with_preload(uuid, user_id)} do
{:has_event, %Event{} = event} ->
{:ok, Map.put(event, :organizer_actor, Person.proxify_pictures(event.organizer_actor))}
@@ -45,7 +45,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
end
def find_event(parent, %{uuid: uuid} = args, resolution) do
case {:has_event, Mobilizon.Events.get_public_event_by_uuid_with_preload(uuid)} do
case {:has_event, Events.get_public_event_by_uuid_with_preload(uuid)} do
{:has_event, %Event{} = event} ->
{:ok, Map.put(event, :organizer_actor, Person.proxify_pictures(event.organizer_actor))}
@@ -65,7 +65,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
with {:is_owned, %Actor{} = _actor} <- User.owns_actor(user, actor_id),
# Check that moderator has right
{:actor_approve_permission, true} <-
{:actor_approve_permission, Mobilizon.Events.moderator_for_event?(event_id, actor_id)} do
{:actor_approve_permission, Events.moderator_for_event?(event_id, actor_id)} do
roles =
case roles do
"" ->
@@ -78,7 +78,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
|> Enum.map(&String.to_existing_atom/1)
end
{:ok, Mobilizon.Events.list_participants_for_event(event_id, roles, page, limit)}
{:ok, Events.list_participants_for_event(event_id, roles, page, limit)}
else
{:is_owned, nil} ->
{:error, "Moderator Actor ID is not owned by authenticated user"}
@@ -142,118 +142,6 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
defp uniq_events(events), do: Enum.uniq_by(events, fn event -> event.uuid end)
@doc """
Join an event for an actor
"""
def actor_join_event(
_parent,
%{actor_id: actor_id, event_id: event_id},
%{context: %{current_user: user}}
) do
with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
{:has_event, {:ok, %Event{} = event}} <-
{:has_event, Mobilizon.Events.get_event_with_preload(event_id)},
{:error, :participant_not_found} <- Mobilizon.Events.get_participant(event_id, actor_id),
{:ok, _activity, participant} <- API.Participations.join(event, actor),
participant <-
participant
|> Map.put(:event, event)
|> Map.put(:actor, Person.proxify_pictures(actor)) do
{:ok, participant}
else
{:maximum_attendee_capacity, _} ->
{:error, "The event has already reached its maximum capacity"}
{:has_event, _} ->
{:error, "Event with this ID #{inspect(event_id)} doesn't exist"}
{:is_owned, nil} ->
{:error, "Actor id is not owned by authenticated user"}
{:error, :event_not_found} ->
{:error, "Event id not found"}
{:ok, %Participant{}} ->
{:error, "You are already a participant of this event"}
end
end
def actor_join_event(_parent, _args, _resolution) do
{:error, "You need to be logged-in to join an event"}
end
@doc """
Leave an event for an actor
"""
def actor_leave_event(
_parent,
%{actor_id: actor_id, event_id: event_id},
%{context: %{current_user: user}}
) do
with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
{:has_event, {:ok, %Event{} = event}} <-
{:has_event, Mobilizon.Events.get_event_with_preload(event_id)},
{:ok, _activity, _participant} <- API.Participations.leave(event, actor) do
{:ok, %{event: %{id: event_id}, actor: %{id: actor_id}}}
else
{:has_event, _} ->
{:error, "Event with this ID #{inspect(event_id)} doesn't exist"}
{:is_owned, nil} ->
{:error, "Actor id is not owned by authenticated user"}
{:only_organizer, true} ->
{:error, "You can't leave event because you're the only event creator participant"}
{:error, :participant_not_found} ->
{:error, "Participant not found"}
end
end
def actor_leave_event(_parent, _args, _resolution) do
{:error, "You need to be logged-in to leave an event"}
end
def update_participation(
_parent,
%{id: participation_id, moderator_actor_id: moderator_actor_id, role: new_role},
%{context: %{current_user: user}}
) do
# Check that moderator provided is rightly authenticated
with {:is_owned, moderator_actor} <- User.owns_actor(user, moderator_actor_id),
# Check that participation already exists
{:has_participation, %Participant{role: old_role} = participation} <-
{:has_participation, Mobilizon.Events.get_participant(participation_id)},
{:same_role, false} <- {:same_role, new_role == old_role},
# Check that moderator has right
{:actor_approve_permission, true} <-
{:actor_approve_permission,
Mobilizon.Events.moderator_for_event?(participation.event.id, moderator_actor_id)},
{:ok, _activity, participation} <-
API.Participations.update(participation, moderator_actor, new_role) do
{:ok, participation}
else
{:is_owned, nil} ->
{:error, "Moderator Actor ID is not owned by authenticated user"}
{:has_participation, %Participant{role: role, id: id}} ->
{:error,
"Participant #{id} can't be approved since it's already a participant (with role #{role})"}
{:has_participation, nil} ->
{:error, "Participant not found"}
{:actor_approve_permission, _} ->
{:error, "Provided moderator actor ID doesn't have permission on this event"}
{:same_role, true} ->
{:error, "Participant already has role #{new_role}"}
{:error, :participant_not_found} ->
{:error, "Participant not found"}
end
end
@doc """
Create an event
"""

View File

@@ -0,0 +1,262 @@
defmodule Mobilizon.GraphQL.Resolvers.Participant do
@moduledoc """
Handles the participation-related GraphQL calls.
"""
alias Mobilizon.{Actors, Config, Crypto, Events}
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.GraphQL.API.Participations
alias Mobilizon.GraphQL.Resolvers.Person
alias Mobilizon.Users.User
alias Mobilizon.Web.Email
alias Mobilizon.Web.Email.Checker
require Logger
@doc """
Join an event for an regular actor
"""
def actor_join_event(
_parent,
%{actor_id: actor_id, event_id: event_id},
%{context: %{current_user: %User{} = user}}
) do
case User.owns_actor(user, actor_id) do
{:is_owned, %Actor{} = actor} ->
do_actor_join_event(actor, event_id)
_ ->
{:error, "Actor id is not owned by authenticated user"}
end
end
@doc """
Join an event for an anonymous actor
"""
def actor_join_event(
_parent,
%{actor_id: actor_id, event_id: event_id} = args,
_resolution
) do
with {:has_event, {:ok, %Event{} = event}} <-
{:has_event, Mobilizon.Events.get_event_with_preload(event_id)},
{:anonymous_participation_enabled, true} <-
{:anonymous_participation_enabled,
event.local == true && Config.anonymous_participation?() &&
event.options.anonymous_participation == true},
{:anonymous_actor_id, true} <-
{:anonymous_actor_id, to_string(Config.anonymous_actor_id()) == actor_id},
{:email_required, true} <-
{:email_required,
Config.anonymous_participation_email_required?() &&
args |> Map.get(:email) |> valid_email?()},
{:confirmation_token, {confirmation_token, role}} <-
{:confirmation_token,
if(Config.anonymous_participation_email_confirmation_required?(),
do: {Crypto.random_string(30), :not_confirmed},
else: {nil, :participant}
)},
# We only federate if the participation is not to be confirmed later
args <-
args
|> Map.put(:confirmation_token, confirmation_token)
|> Map.put(:cancellation_token, Crypto.random_string(30))
|> Map.put(:role, role)
|> Map.put(:local, role == :participant),
{:actor_not_found, %Actor{} = actor} <-
{:actor_not_found, Actors.get_actor_with_preload(actor_id)},
{:ok, %Participant{} = participant} <- do_actor_join_event(actor, event_id, args) do
if Config.anonymous_participation_email_required?() &&
Config.anonymous_participation_email_confirmation_required?() do
args
|> Map.get(:email)
|> Email.Participation.anonymous_participation_confirmation(participant)
|> Email.Mailer.deliver_later()
end
{:ok, participant}
else
{:error, err} ->
{:error, err}
{:has_event, _} ->
{:error, "Event with this ID #{inspect(event_id)} doesn't exist"}
{:anonymous_participation_enabled, false} ->
{:error, "Anonymous participation is not enabled"}
{:anonymous_actor_id, false} ->
{:error, "Actor ID provided is not the anonymous actor one"}
{:email_required, _} ->
{:error, "A valid email is required by your instance"}
{:actor_not_found, _} ->
Logger.error(
"The actor ID \"#{actor_id}\" provided by configuration doesn't match any actor in database"
)
{:error, "Internal Error"}
end
end
def actor_join_event(_parent, _args, _resolution) do
{:error, "You need to be logged-in to join an event"}
end
@spec do_actor_join_event(Actor.t(), integer | String.t(), map()) ::
{:ok, Participant.t()} | {:error, String.t()}
defp do_actor_join_event(actor, event_id, args \\ %{}) do
with {:has_event, {:ok, %Event{} = event}} <-
{:has_event, Events.get_event_with_preload(event_id)},
{:ok, _activity, participant} <- Participations.join(event, actor, args),
%Participant{} = participant <-
participant
|> Map.put(:event, event)
|> Map.put(:actor, Person.proxify_pictures(actor)) do
{:ok, participant}
else
{:maximum_attendee_capacity, _} ->
{:error, "The event has already reached its maximum capacity"}
{:has_event, _} ->
{:error, "Event with this ID #{inspect(event_id)} doesn't exist"}
{:error, :event_not_found} ->
{:error, "Event id not found"}
{:ok, %Participant{}} ->
{:error, "You are already a participant of this event"}
end
end
@doc """
Leave an event for an actor
"""
def actor_leave_event(
_parent,
%{actor_id: actor_id, event_id: event_id, token: token},
_resolution
) do
with {:anonymous_participation_enabled, true} <-
{:anonymous_participation_enabled, Config.anonymous_participation?()},
{:anonymous_actor_id, true} <-
{:anonymous_actor_id, to_string(Config.anonymous_actor_id()) == actor_id},
{:has_event, {:ok, %Event{} = event}} <-
{:has_event, Mobilizon.Events.get_event_with_preload(event_id)},
%Actor{} = actor <- Actors.get_actor_with_preload(actor_id),
{:ok, _activity, %Participant{id: participant_id} = _participant} <-
Participations.leave(event, actor, %{local: false, cancellation_token: token}) do
{:ok, %{event: %{id: event_id}, actor: %{id: actor_id}, id: participant_id}}
else
{:has_event, _} ->
{:error, "Event with this ID #{inspect(event_id)} doesn't exist"}
{:is_owned, nil} ->
{:error, "Actor id is not owned by authenticated user"}
{:only_organizer, true} ->
{:error, "You can't leave event because you're the only event creator participant"}
{:error, :participant_not_found} ->
{:error, "Participant not found"}
end
end
def actor_leave_event(
_parent,
%{actor_id: actor_id, event_id: event_id},
%{context: %{current_user: user}}
) do
with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
{:has_event, {:ok, %Event{} = event}} <-
{:has_event, Events.get_event_with_preload(event_id)},
{:ok, _activity, _participant} <- Participations.leave(event, actor) do
{:ok, %{event: %{id: event_id}, actor: %{id: actor_id}}}
else
{:has_event, _} ->
{:error, "Event with this ID #{inspect(event_id)} doesn't exist"}
{:is_owned, nil} ->
{:error, "Actor id is not owned by authenticated user"}
{:only_organizer, true} ->
{:error, "You can't leave event because you're the only event creator participant"}
{:error, :participant_not_found} ->
{:error, "Participant not found"}
end
end
def actor_leave_event(_parent, _args, _resolution) do
{:error, "You need to be logged-in to leave an event"}
end
def update_participation(
_parent,
%{id: participation_id, moderator_actor_id: moderator_actor_id, role: new_role},
%{
context: %{
current_user: user
}
}
) do
# Check that moderator provided is rightly authenticated
with {:is_owned, moderator_actor} <- User.owns_actor(user, moderator_actor_id),
# Check that participation already exists
{:has_participation, %Participant{role: old_role} = participation} <-
{:has_participation, Events.get_participant(participation_id)},
{:same_role, false} <- {:same_role, new_role == old_role},
# Check that moderator has right
{:actor_approve_permission, true} <-
{:actor_approve_permission,
Events.moderator_for_event?(participation.event.id, moderator_actor_id)},
{:ok, _activity, participation} <-
Participations.update(participation, moderator_actor, new_role) do
{:ok, participation}
else
{:is_owned, nil} ->
{:error, "Moderator Actor ID is not owned by authenticated user"}
{:has_participation, nil} ->
{:error, "Participant not found"}
{:actor_approve_permission, _} ->
{:error, "Provided moderator actor ID doesn't have permission on this event"}
{:same_role, true} ->
{:error, "Participant already has role #{new_role}"}
{:error, :participant_not_found} ->
{:error, "Participant not found"}
end
end
@spec confirm_participation_from_token(map(), map(), map()) ::
{:ok, Participant.t()} | {:error, String.t()}
def confirm_participation_from_token(
_parent,
%{confirmation_token: confirmation_token},
_context
) do
with {:has_participant,
%Participant{actor: actor, role: :not_confirmed, event: event} = participant} <-
{:has_participant, Events.get_participant_by_confirmation_token(confirmation_token)},
default_role <- Events.get_default_participant_role(event),
{:ok, _activity, %Participant{} = participant} <-
Participations.update(participant, actor, default_role) do
{:ok, participant}
else
{:has_participant, _} ->
{:error, "This token is invalid"}
end
end
@spec valid_email?(String.t() | nil) :: boolean
defp valid_email?(email) when is_nil(email), do: false
defp valid_email?(email) when is_bitstring(email) do
email
|> String.trim()
|> Checker.valid?()
end
end

View File

@@ -60,6 +60,21 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
field(:number_of_reports, :integer, description: "The number of current opened reports")
end
object :admin_settings do
field(:instance_name, :string)
field(:instance_description, :string)
field(:instance_terms, :string)
field(:instance_terms_type, :instance_terms_type)
field(:instance_terms_url, :string)
field(:registrations_open, :boolean)
end
enum :instance_terms_type do
value(:url, as: "URL")
value(:default, as: "DEFAULT")
value(:custom, as: "CUSTOM")
end
object :admin_queries do
@desc "Get the list of action logs"
field :action_logs, type: list_of(:action_log) do
@@ -72,6 +87,10 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
resolve(&Admin.get_dashboard/3)
end
field :admin_settings, type: :admin_settings do
resolve(&Admin.get_settings/3)
end
field :relay_followers, type: :paginated_follower_list do
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
@@ -115,5 +134,16 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
resolve(&Admin.reject_subscription/3)
end
field :save_admin_settings, type: :admin_settings do
arg(:instance_name, :string)
arg(:instance_description, :string)
arg(:instance_terms, :string)
arg(:instance_terms_type, :instance_terms_type)
arg(:instance_terms_url, :string)
arg(:registrations_open, :boolean)
resolve(&Admin.save_settings/3)
end
end
end

View File

@@ -19,6 +19,18 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do
field(:location, :lonlat)
field(:geocoding, :geocoding)
field(:maps, :maps)
field(:anonymous, :anonymous)
field(:terms, :terms, description: "The instance's terms") do
arg(:locale, :string, default_value: "en")
resolve(&Config.terms/3)
end
end
object :terms do
field(:url, :string)
field(:type, :instance_terms_type)
field(:body_html, :string)
end
object :lonlat do
@@ -41,6 +53,50 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do
field(:attribution, :string)
end
object :anonymous do
field(:participation, :anonymous_participation)
field(:event_creation, :anonymous_event_creation)
field(:actor_id, :id)
end
object :anonymous_participation do
field(:allowed, :boolean)
field(:validation, :anonymous_participation_validation)
end
object :anonymous_participation_validation do
field(:email, :anonymous_participation_validation_email)
field(:captcha, :anonymous_participation_validation_captcha)
end
object :anonymous_participation_validation_email do
field(:enabled, :boolean)
field(:confirmation_required, :boolean)
end
object :anonymous_participation_validation_captcha do
field(:enabled, :boolean)
end
object :anonymous_event_creation do
field(:allowed, :boolean)
field(:validation, :anonymous_event_creation_validation)
end
object :anonymous_event_creation_validation do
field(:email, :anonymous_event_creation_validation_email)
field(:captcha, :anonymous_event_creation_validation_captcha)
end
object :anonymous_event_creation_validation_email do
field(:enabled, :boolean)
field(:confirmation_required, :boolean)
end
object :anonymous_event_creation_validation_captcha do
field(:enabled, :boolean)
end
object :config_queries do
@desc "Get the instance config"
field :config, :config do

View File

@@ -122,6 +122,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
)
field(:not_approved, :integer, description: "The number of not approved participants")
field(:not_confirmed, :integer, description: "The number of not confirmed participants")
field(:rejected, :integer, description: "The number of rejected participants")
field(:participant, :integer,
@@ -177,6 +178,10 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
description: "Whether or not to show the number of remaining seats for this event"
)
field(:anonymous_participation, :boolean,
description: "Whether or not to allow anonymous participation (if the server allows it)"
)
field(:offers, list_of(:event_offer), description: "The list of offers to show for this event")
field(:participation_conditions, list_of(:event_participation_condition),
@@ -211,6 +216,11 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
description: "Whether or not to show the number of remaining seats for this event"
)
field(:anonymous_participation, :boolean,
default_value: false,
description: "Whether or not to allow anonymous participation (if the server allows it)"
)
field(:offers, list_of(:event_offer_input),
description: "The list of offers to show for this event"
)

View File

@@ -8,7 +8,7 @@ defmodule Mobilizon.GraphQL.Schema.Events.ParticipantType do
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.{Actors, Events}
alias Mobilizon.GraphQL.Resolvers.Event
alias Mobilizon.GraphQL.Resolvers.Participant
@desc "Represents a participant to an event"
object :participant do
@@ -29,10 +29,21 @@ defmodule Mobilizon.GraphQL.Schema.Events.ParticipantType do
)
field(:role, :participant_role_enum, description: "The role of this actor at this event")
field(:metadata, :participant_metadata,
description: "The metadata associated to this participant"
)
end
object :participant_metadata do
field(:cancellation_token, :string,
description: "The eventual token to leave an event when user is anonymous"
)
end
enum :participant_role_enum do
value(:not_approved)
value(:not_confirmed)
value(:participant)
value(:moderator)
value(:administrator)
@@ -52,16 +63,18 @@ defmodule Mobilizon.GraphQL.Schema.Events.ParticipantType do
field :join_event, :participant do
arg(:event_id, non_null(:id))
arg(:actor_id, non_null(:id))
arg(:email, :string)
resolve(&Event.actor_join_event/3)
resolve(&Participant.actor_join_event/3)
end
@desc "Leave an event"
field :leave_event, :deleted_participant do
arg(:event_id, non_null(:id))
arg(:actor_id, non_null(:id))
arg(:token, :string)
resolve(&Event.actor_leave_event/3)
resolve(&Participant.actor_leave_event/3)
end
@desc "Accept a participation"
@@ -70,7 +83,13 @@ defmodule Mobilizon.GraphQL.Schema.Events.ParticipantType do
arg(:role, non_null(:participant_role_enum))
arg(:moderator_actor_id, non_null(:id))
resolve(&Event.update_participation/3)
resolve(&Participant.update_participation/3)
end
@desc "Confirm a participation"
field :confirm_participation, :participant do
arg(:confirmation_token, non_null(:string))
resolve(&Participant.confirm_participation_from_token/3)
end
end
end

View File

@@ -20,6 +20,7 @@ defmodule Mobilizon do
@name Mix.Project.config()[:name]
@version Mix.Project.config()[:version]
@env Mix.env()
@spec named_version :: String.t()
def named_version, do: "#{@name} #{@version}"
@@ -34,21 +35,23 @@ defmodule Mobilizon do
@spec start(:normal | {:takeover, node} | {:failover, node}, term) ::
{:ok, pid} | {:ok, pid, term} | {:error, term}
def start(_type, _args) do
children = [
# supervisors
Storage.Repo,
Web.Endpoint,
{Absinthe.Subscription, [Web.Endpoint]},
{Oban, Application.get_env(:mobilizon, Oban)},
# workers
Guardian.DB.Token.SweeperServer,
ActivityPub.Federator,
cachex_spec(:feed, 2500, 60, 60, &Feed.create_cache/1),
cachex_spec(:ics, 2500, 60, 60, &ICalendar.create_cache/1),
cachex_spec(:statistics, 10, 60, 60),
cachex_spec(:activity_pub, 2500, 3, 15),
internal_actor()
]
children =
[
# supervisors
Storage.Repo,
Web.Endpoint,
{Absinthe.Subscription, [Web.Endpoint]},
{Oban, Application.get_env(:mobilizon, Oban)},
# workers
Guardian.DB.Token.SweeperServer,
ActivityPub.Federator,
cachex_spec(:feed, 2500, 60, 60, &Feed.create_cache/1),
cachex_spec(:ics, 2500, 60, 60, &ICalendar.create_cache/1),
cachex_spec(:statistics, 10, 60, 60),
cachex_spec(:config, 10, 60, 60),
cachex_spec(:activity_pub, 2500, 3, 15)
] ++
task_children(@env)
Supervisor.start_link(children, strategy: :one_for_one, name: Mobilizon.Supervisor)
end
@@ -92,11 +95,22 @@ defmodule Mobilizon do
defp fallback_options(nil), do: []
defp fallback_options(fallback), do: [fallback: fallback(default: fallback)]
defp internal_actor do
defp task_children(:test), do: []
defp task_children(_), do: [relay_actor(), anonymous_actor()]
defp relay_actor do
%{
id: :internal_actor_init,
id: :relay_actor_init,
start: {Task, :start_link, [&ActivityPub.Relay.init/0]},
restart: :temporary
}
end
defp anonymous_actor do
%{
id: :anonymous_actor_init,
start: {Task, :start_link, [&Mobilizon.Config.anonymous_actor_id/0]},
restart: :temporary
}
end
end

View File

@@ -97,20 +97,6 @@ defmodule Mobilizon.Actors.Actor do
@remote_actor_creation_attrs @remote_actor_creation_required_attrs ++
@remote_actor_creation_optional_attrs
@relay_creation_attrs [
:type,
:name,
:summary,
:url,
:keys,
:preferred_username,
:domain,
:inbox_url,
:followers_url,
:following_url,
:shared_inbox_url
]
@group_creation_required_attrs [:url, :outbox_url, :inbox_url, :type, :preferred_username]
@group_creation_optional_attrs [:shared_inbox_url, :name, :domain, :summary]
@group_creation_attrs @group_creation_required_attrs ++ @group_creation_optional_attrs
@@ -277,16 +263,6 @@ defmodule Mobilizon.Actors.Actor do
|> validate_format(:preferred_username, ~r/[a-z0-9_]+/)
end
@doc """
Changeset for relay creation.
"""
@spec relay_creation_changeset(map) :: Ecto.Changeset.t()
def relay_creation_changeset(attrs) do
relay_creation_attrs = build_relay_creation_attrs(attrs)
cast(%__MODULE__{}, relay_creation_attrs, @relay_creation_attrs)
end
@doc """
Changeset for group creation
"""
@@ -349,6 +325,10 @@ defmodule Mobilizon.Actors.Actor do
def build_url(username, :inbox, _args), do: "#{build_url(username, :page)}/inbox"
# Relay has a special URI
def build_url("relay", :page, _args),
do: Endpoint |> Routes.activity_pub_url(:relay) |> URI.decode()
def build_url(preferred_username, :page, args) do
Endpoint
|> Routes.page_url(:actor, preferred_username, args)
@@ -362,24 +342,40 @@ defmodule Mobilizon.Actors.Actor do
|> URI.decode()
end
@spec build_relay_creation_attrs(map) :: map
defp build_relay_creation_attrs(%{url: url, preferred_username: preferred_username}) do
%{
@spec build_relay_creation_attrs :: Ecto.Changeset.t()
def build_relay_creation_attrs do
data = %{
"name" => Config.get([:instance, :name], "Mobilizon"),
"summary" =>
Config.get(
[:instance, :description],
"An internal service actor for this Mobilizon instance"
),
"url" => url,
"keys" => Crypto.generate_rsa_2048_private_key(),
"preferred_username" => preferred_username,
"preferred_username" => "relay",
"domain" => nil,
"inbox_url" => "#{Endpoint.url()}/inbox",
"followers_url" => "#{url}/followers",
"following_url" => "#{url}/following",
"shared_inbox_url" => "#{Endpoint.url()}/inbox",
"type" => :Application
}
%__MODULE__{}
|> Ecto.Changeset.cast(data, @attrs)
|> build_urls()
|> put_change(:inbox_url, "#{Endpoint.url()}/inbox")
end
@spec build_anonymous_actor_creation_attrs :: Ecto.Changeset.t()
def build_anonymous_actor_creation_attrs do
data = %{
"name" => "Mobilizon Anonymous Actor",
"summary" => "A fake person for anonymous participations",
"keys" => Crypto.generate_rsa_2048_private_key(),
"preferred_username" => "anonymous",
"domain" => nil,
"type" => :Person
}
%__MODULE__{}
|> Ecto.Changeset.cast(data, @attrs)
|> build_urls()
end
end

View File

@@ -499,17 +499,22 @@ defmodule Mobilizon.Actors do
|> Repo.insert()
end
@spec get_or_create_instance_actor_by_url(String.t(), String.t()) ::
{:ok, Actor.t()} | {:error, Ecto.Changeset.t()}
def get_or_create_instance_actor_by_url(url, preferred_username \\ "relay") do
case get_actor_by_url(url) do
@spec get_or_create_internal_actor(String.t()) :: {:ok, Actor.t()}
def get_or_create_internal_actor(username) do
case username |> Actor.build_url(:page) |> get_actor_by_url() do
{:ok, %Actor{} = actor} ->
{:ok, actor}
_ ->
%{url: url, preferred_username: preferred_username}
|> Actor.relay_creation_changeset()
|> Repo.insert()
case username do
"anonymous" ->
Actor.build_anonymous_actor_creation_attrs()
|> Repo.insert()
"relay" ->
Actor.build_relay_creation_attrs()
|> Repo.insert()
end
end
end

View File

@@ -9,6 +9,7 @@ defmodule Mobilizon.Admin do
alias Mobilizon.Actors.Actor
alias Mobilizon.{Admin, Users}
alias Mobilizon.Admin.ActionLog
alias Mobilizon.Admin.Setting
alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Users.User
@@ -18,6 +19,8 @@ defmodule Mobilizon.Admin do
"delete"
])
alias Ecto.Multi
@doc """
Creates a action_log.
"""
@@ -71,4 +74,48 @@ defmodule Mobilizon.Admin do
end
defp stringify_struct(struct), do: struct
def get_admin_setting_value(group, name, fallback \\ nil)
when is_bitstring(group) and is_bitstring(name) do
case Repo.get_by(Setting, group: group, name: name) do
nil -> fallback
%Setting{value: ""} -> fallback
%Setting{value: nil} -> fallback
%Setting{value: value} -> value
end
end
def set_admin_setting_value(group, name, value) do
Setting
|> Setting.changeset(%{group: group, name: name, value: value})
|> Repo.insert(on_conflict: :replace_all, conflict_target: [:group, :name])
end
def save_settings(group, args) do
Multi.new()
|> do_save_setting(group, args)
|> Repo.transaction()
end
defp do_save_setting(transaction, _group, args) when args == %{}, do: transaction
defp do_save_setting(transaction, group, args) do
key = hd(Map.keys(args))
{val, rest} = Map.pop(args, key)
transaction =
Multi.insert(
transaction,
key,
Setting.changeset(%Setting{}, %{
group: group,
name: Atom.to_string(key),
value: to_string(val)
}),
on_conflict: :replace_all,
conflict_target: [:group, :name]
)
do_save_setting(transaction, group, rest)
end
end

View File

@@ -0,0 +1,27 @@
defmodule Mobilizon.Admin.Setting do
@moduledoc """
A Key-Value settings table for basic settings
"""
use Ecto.Schema
import Ecto.Changeset
@required_attrs [:group, :name]
@optional_attrs [:value]
@attrs @required_attrs ++ @optional_attrs
schema "admin_settings" do
field(:group, :string)
field(:name, :string)
field(:value, :string)
timestamps()
end
@doc false
def changeset(setting, attrs) do
setting
|> cast(attrs, @attrs)
|> validate_required(@required_attrs)
|> unique_constraint(:group, name: :admin_settings_group_name_index)
end
end

View File

@@ -3,14 +3,44 @@ defmodule Mobilizon.Config do
Configuration wrapper.
"""
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
@spec instance_config :: keyword
def instance_config, do: Application.get_env(:mobilizon, :instance)
@spec instance_name :: String.t()
def instance_name, do: instance_config()[:name]
def instance_name,
do:
Mobilizon.Admin.get_admin_setting_value(
"instance",
"instance_name",
instance_config()[:name]
)
@spec instance_description :: String.t()
def instance_description, do: instance_config()[:description]
def instance_description,
do:
Mobilizon.Admin.get_admin_setting_value(
"instance",
"instance_description",
instance_config()[:description]
)
@spec instance_terms(String.t()) :: String.t()
def instance_terms(locale \\ "en") do
Mobilizon.Admin.get_admin_setting_value("instance", "instance_terms", generate_terms(locale))
end
@spec instance_terms :: String.t()
def instance_terms_type do
Mobilizon.Admin.get_admin_setting_value("instance", "instance_terms_type", "DEFAULT")
end
@spec instance_terms :: String.t()
def instance_terms_url do
Mobilizon.Admin.get_admin_setting_value("instance", "instance_terms_url")
end
@spec instance_version :: String.t()
def instance_version, do: Mix.Project.config()[:version]
@@ -19,7 +49,15 @@ defmodule Mobilizon.Config do
def instance_hostname, do: instance_config()[:hostname]
@spec instance_registrations_open? :: boolean
def instance_registrations_open?, do: to_boolean(instance_config()[:registrations_open])
def instance_registrations_open?,
do:
to_boolean(
Mobilizon.Admin.get_admin_setting_value(
"instance",
"registrations_open",
instance_config()[:registrations_open]
)
)
@spec instance_registrations_whitelist :: list(String.t())
def instance_registrations_whitelist, do: instance_config()[:registration_email_whitelist]
@@ -58,6 +96,51 @@ defmodule Mobilizon.Config do
def instance_maps_tiles_attribution,
do: Application.get_env(:mobilizon, :maps)[:tiles][:attribution]
@spec anonymous_participation? :: boolean
def anonymous_participation?,
do: Application.get_env(:mobilizon, :anonymous)[:participation][:allowed]
@spec anonymous_participation_email_required? :: boolean
def anonymous_participation_email_required?,
do: Application.get_env(:mobilizon, :anonymous)[:participation][:validation][:email][:enabled]
@spec anonymous_participation_email_confirmation_required? :: boolean
def anonymous_participation_email_confirmation_required?,
do:
Application.get_env(:mobilizon, :anonymous)[:participation][:validation][:email][
:confirmation_required
]
@spec anonymous_participation_email_captcha_required? :: boolean
def anonymous_participation_email_captcha_required?,
do:
Application.get_env(:mobilizon, :anonymous)[:participation][:validation][:captcha][:enabled]
@spec anonymous_event_creation? :: boolean
def anonymous_event_creation?,
do: Application.get_env(:mobilizon, :anonymous)[:event_creation][:allowed]
@spec anonymous_event_creation_email_required? :: boolean
def anonymous_event_creation_email_required?,
do:
Application.get_env(:mobilizon, :anonymous)[:event_creation][:validation][:email][:enabled]
@spec anonymous_event_creation_email_confirmation_required? :: boolean
def anonymous_event_creation_email_confirmation_required?,
do:
Application.get_env(:mobilizon, :anonymous)[:event_creation][:validation][:email][
:confirmation_required
]
@spec anonymous_event_creation_email_captcha_required? :: boolean
def anonymous_event_creation_email_captcha_required?,
do:
Application.get_env(:mobilizon, :anonymous)[:event_creation][:validation][:captcha][
:enabled
]
def anonymous_actor_id, do: get_cached_value(:anonymous_actor_id)
@spec get(module | atom) :: any
def get(key), do: get(key, nil)
@@ -99,4 +182,38 @@ defmodule Mobilizon.Config do
@spec to_boolean(boolean | String.t()) :: boolean
defp to_boolean(boolean), do: "true" == String.downcase("#{boolean}")
defp get_cached_value(key) do
case Cachex.fetch(:config, key, fn key ->
case create_cache(key) do
value when not is_nil(value) -> {:commit, value}
err -> {:ignore, err}
end
end) do
{status, value} when status in [:ok, :commit] -> value
_err -> nil
end
end
@spec create_cache(atom()) :: integer()
defp create_cache(:anonymous_actor_id) do
with {:ok, %Actor{id: actor_id}} <- Actors.get_or_create_internal_actor("anonymous") do
actor_id
end
end
def clear_config_cache do
Cachex.clear(:config)
end
def generate_terms(locale) do
import Mobilizon.Web.Gettext
put_locale(locale)
Phoenix.View.render_to_string(
Mobilizon.Web.APIView,
"terms.html",
[]
)
end
end

View File

@@ -17,6 +17,7 @@ defmodule Mobilizon.Events.EventOptions do
maximum_attendee_capacity: integer,
remaining_attendee_capacity: integer,
show_remaining_attendee_capacity: boolean,
anonymous_participation: boolean,
attendees: [String.t()],
program: String.t(),
comment_moderation: CommentModeration.t(),
@@ -31,6 +32,7 @@ defmodule Mobilizon.Events.EventOptions do
:maximum_attendee_capacity,
:remaining_attendee_capacity,
:show_remaining_attendee_capacity,
:anonymous_participation,
:attendees,
:program,
:comment_moderation,
@@ -45,6 +47,7 @@ defmodule Mobilizon.Events.EventOptions do
field(:maximum_attendee_capacity, :integer)
field(:remaining_attendee_capacity, :integer)
field(:show_remaining_attendee_capacity, :boolean)
field(:anonymous_participation, :boolean)
field(:attendees, {:array, :string})
field(:program, :string)
field(:comment_moderation, CommentModeration)

View File

@@ -8,6 +8,7 @@ defmodule Mobilizon.Events.EventParticipantStats do
@type t :: %__MODULE__{
not_approved: integer(),
not_confirmed: integer(),
rejected: integer(),
participant: integer(),
moderator: integer(),
@@ -17,6 +18,7 @@ defmodule Mobilizon.Events.EventParticipantStats do
@attrs [
:not_approved,
:not_confirmed,
:rejected,
:participant,
:moderator,
@@ -29,6 +31,7 @@ defmodule Mobilizon.Events.EventParticipantStats do
@derive Jason.Encoder
embedded_schema do
field(:not_approved, :integer, default: 0)
field(:not_confirmed, :integer, default: 0)
field(:rejected, :integer, default: 0)
field(:participant, :integer, default: 0)
field(:moderator, :integer, default: 0)
@@ -47,6 +50,7 @@ defmodule Mobilizon.Events.EventParticipantStats do
defp validate_stats(%Ecto.Changeset{} = changeset) do
changeset
|> validate_number(:not_approved, greater_than_or_equal_to: 0)
|> validate_number(:not_confirmed, greater_than_or_equal_to: 0)
|> validate_number(:rejected, greater_than_or_equal_to: 0)
|> validate_number(:participant, greater_than_or_equal_to: 0)
|> validate_number(:moderator, greater_than_or_equal_to: 0)

View File

@@ -76,6 +76,7 @@ defmodule Mobilizon.Events do
defenum(ParticipantRole, :participant_role, [
:not_approved,
:not_confirmed,
:rejected,
:participant,
:moderator,
@@ -661,9 +662,39 @@ defmodule Mobilizon.Events do
@doc """
Gets a single participation for an event and actor.
"""
@spec get_participant(integer | String.t(), integer | String.t()) ::
@spec get_participant(integer | String.t(), integer | String.t(), map()) ::
{:ok, Participant.t()} | {:error, :participant_not_found}
def get_participant(event_id, actor_id) do
def get_participant(event_id, actor_id, params \\ %{})
# This one if to check someone doesn't go to the same event twice
def get_participant(event_id, actor_id, %{email: email}) do
case Participant
|> where([p], event_id: ^event_id, actor_id: ^actor_id)
|> where([p], fragment("? ->>'email' = ?", p.metadata, ^email))
|> Repo.one() do
%Participant{} = participant ->
{:ok, participant}
nil ->
{:error, :participant_not_found}
end
end
# This one if for finding participants by their cancellation token when wanting to cancel a participation
def get_participant(event_id, actor_id, %{cancellation_token: cancellation_token}) do
case Participant
|> where([p], event_id: ^event_id, actor_id: ^actor_id)
|> where([p], fragment("? ->>'cancellation_token' = ?", p.metadata, ^cancellation_token))
|> Repo.one() do
%Participant{} = participant ->
{:ok, participant}
nil ->
{:error, :participant_not_found}
end
end
def get_participant(event_id, actor_id, %{}) do
case Repo.get_by(Participant, event_id: event_id, actor_id: actor_id) do
%Participant{} = participant ->
{:ok, participant}
@@ -673,6 +704,14 @@ defmodule Mobilizon.Events do
end
end
@spec get_participant_by_confirmation_token(String.t()) :: Participant.t()
def get_participant_by_confirmation_token(confirmation_token) do
Participant
|> where([p], fragment("? ->>'confirmation_token' = ?", p.metadata, ^confirmation_token))
|> preload([p], [:actor, :event])
|> Repo.one()
end
@doc """
Gets a single participation for an event and actor.
@@ -706,7 +745,7 @@ defmodule Mobilizon.Events do
@doc """
Returns the list of participants for an event.
Default behaviour is to not return :not_approved participants
Default behaviour is to not return :not_approved or :not_confirmed participants
"""
@spec list_participants_for_event(String.t(), list(atom()), integer | nil, integer | nil) ::
[Participant.t()]

View File

@@ -10,6 +10,7 @@ defmodule Mobilizon.Events.Participant do
alias Mobilizon.Actors.Actor
alias Mobilizon.Events
alias Mobilizon.Events.{Event, ParticipantRole}
alias Mobilizon.Web.Email.Checker
alias Mobilizon.Web.Endpoint
@@ -28,6 +29,12 @@ defmodule Mobilizon.Events.Participant do
field(:role, ParticipantRole, default: :participant)
field(:url, :string)
embeds_one :metadata, Metadata, on_replace: :delete do
field(:email, :string)
field(:confirmation_token, :string)
field(:cancellation_token, :string)
end
belongs_to(:event, Event, primary_key: true)
belongs_to(:actor, Actor, primary_key: true)
@@ -55,11 +62,18 @@ defmodule Mobilizon.Events.Participant do
def changeset(%__MODULE__{} = participant, attrs) do
participant
|> cast(attrs, @attrs)
|> cast_embed(:metadata, with: &metadata_changeset/2)
|> ensure_url()
|> validate_required(@required_attrs)
|> unique_constraint(:actor_id, name: :participants_event_id_actor_id_index)
end
defp metadata_changeset(schema, params) do
schema
|> cast(params, [:email, :confirmation_token, :cancellation_token])
|> Checker.validate_changeset()
end
# If there's a blank URL that's because we're doing the first insert
@spec ensure_url(Ecto.Changeset.t()) :: Ecto.Changeset.t()
defp ensure_url(%Ecto.Changeset{data: %__MODULE__{url: nil}} = changeset) do

View File

@@ -11,8 +11,7 @@ defmodule Mobilizon.Users.User do
alias Mobilizon.Crypto
alias Mobilizon.Events.FeedToken
alias Mobilizon.Users.UserRole
alias Mobilizon.Web.Email
alias Mobilizon.Web.Email.Checker
@type t :: %__MODULE__{
email: String.t(),
@@ -79,7 +78,7 @@ defmodule Mobilizon.Users.User do
|> cast(attrs, @attrs)
|> validate_required(@required_attrs)
|> unique_constraint(:email, message: "This email is already used.")
|> validate_email()
|> Checker.validate_changeset()
|> validate_length(:password, min: 6, max: 200, message: "The chosen password is too short.")
if Map.has_key?(attrs, :default_actor) do
@@ -171,25 +170,6 @@ defmodule Mobilizon.Users.User do
defp save_confirmation_token(%Ecto.Changeset{} = changeset), do: changeset
@spec validate_email(Ecto.Changeset.t()) :: Ecto.Changeset.t()
defp validate_email(%Ecto.Changeset{} = changeset) do
changeset = validate_length(changeset, :email, min: 3, max: 250)
case changeset do
%Ecto.Changeset{valid?: true, changes: %{email: email}} ->
case Email.Checker.valid?(email) do
false ->
add_error(changeset, :email, "Email doesn't fit required format")
true ->
changeset
end
_ ->
changeset
end
end
@spec hash_password(Ecto.Changeset.t()) :: Ecto.Changeset.t()
defp hash_password(%Ecto.Changeset{} = changeset) do
case changeset do

View File

@@ -2,7 +2,7 @@ defmodule Mobilizon.Web.GraphQLSocket do
use Phoenix.Socket
use Absinthe.Phoenix.Socket,
schema: Mobilizon.Web.Schema
schema: Mobilizon.GraphQL.Schema
alias Mobilizon.Users.User

View File

@@ -4,6 +4,9 @@ defmodule Mobilizon.Web.PageController do
"""
use Mobilizon.Web, :controller
alias Mobilizon.Events.{Comment, Event}
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Tombstone
alias Mobilizon.Web.Cache
plug(:put_layout, false)
@@ -13,40 +16,65 @@ defmodule Mobilizon.Web.PageController do
def actor(conn, %{"name" => name}) do
{status, actor} = Cache.get_local_actor_by_name(name)
render_or_error(conn, &ok_status?/2, status, :actor, actor)
render_or_error(conn, &ok_status?/3, status, :actor, actor)
end
def event(conn, %{"uuid" => uuid}) do
{status, event} = Cache.get_public_event_by_uuid_with_preload(uuid)
render_or_error(conn, &ok_status_and_is_visible?/2, status, :event, event)
render_or_error(conn, &checks?/3, status, :event, event)
end
def comment(conn, %{"uuid" => uuid}) do
{status, comment} = Cache.get_comment_by_uuid_with_preload(uuid)
render_or_error(conn, &ok_status_and_is_visible?/2, status, :comment, comment)
render_or_error(conn, &checks?/3, status, :comment, comment)
end
def interact(conn, %{"uri" => uri}) do
case ActivityPub.fetch_object_from_url(uri) do
{:ok, %Event{uuid: uuid}} -> redirect(conn, to: "/events/#{uuid}")
{:ok, %Comment{uuid: uuid}} -> redirect(conn, to: "/comments/#{uuid}")
_ -> {:error, :not_found}
end
end
defp render_or_error(conn, check_fn, status, object_type, object) do
if check_fn.(status, object) do
case object do
%Mobilizon.Tombstone{} ->
conn
|> put_status(:gone)
|> render(object_type, object: object)
case check_fn.(conn, status, object) do
true ->
case object do
%Tombstone{} ->
conn
|> put_status(:gone)
|> render(object_type, object: object)
_ ->
render(conn, object_type, object: object)
end
else
{:error, :not_found}
_ ->
render(conn, object_type, object: object)
end
:remote ->
redirect(conn, external: object.url)
false ->
{:error, :not_found}
end
end
defp is_visible?(%{visibility: v}), do: v in [:public, :unlisted]
defp is_visible?(%Mobilizon.Tombstone{}), do: true
defp is_visible?(%Tombstone{}), do: true
defp ok_status?(status), do: status in [:ok, :commit]
defp ok_status?(status, _), do: ok_status?(status)
defp ok_status?(_conn, status, _), do: ok_status?(status)
defp ok_status_and_is_visible?(status, o), do: ok_status?(status) and is_visible?(o)
defp ok_status_and_is_visible?(_conn, status, o),
do: ok_status?(status) and is_visible?(o)
defp checks?(conn, status, o) do
if ok_status_and_is_visible?(conn, status, o) do
if is_local?(o) == :remote && get_format(conn) == "activity-json", do: :remote, else: true
else
false
end
end
defp is_local?(%Event{local: local}), do: if(local, do: true, else: :remote)
defp is_local?(%Comment{local: local}), do: if(local, do: true, else: :remote)
end

View File

@@ -10,4 +10,19 @@ defmodule Mobilizon.Web.Email.Checker do
"""
@spec valid?(String.t()) :: boolean
def valid?(email), do: email =~ @email_regex
@spec validate_changeset(Ecto.Changeset.t(), atom()) :: Ecto.Changeset.t()
def validate_changeset(%Ecto.Changeset{} = changeset, key \\ :email) do
changeset = Ecto.Changeset.validate_length(changeset, :email, min: 3, max: 250)
case Ecto.Changeset.fetch_change(changeset, key) do
{:ok, email} ->
if valid?(email),
do: changeset,
else: Ecto.Changeset.add_error(changeset, :email, "Email doesn't fit required format")
:error ->
changeset
end
end
end

View File

@@ -2,26 +2,34 @@ defmodule Mobilizon.Web.Email.Participation do
@moduledoc """
Handles emails sent about participation.
"""
use Bamboo.Phoenix, view: Mobilizon.Web.EmailView
import Bamboo.Phoenix
import Mobilizon.Web.Gettext
alias Mobilizon.Actors.Actor
alias Mobilizon.Config
alias Mobilizon.Events.Participant
alias Mobilizon.Users
alias Mobilizon.Users.User
alias Mobilizon.Web.{Email, Gettext}
@doc """
Send emails to local user
"""
def send_emails_to_local_user(
%Participant{actor: %Actor{user_id: nil} = _actor} = _participation
),
do: :ok
%Participant{actor: %Actor{user_id: nil, id: actor_id} = _actor} = participation
) do
if actor_id == Config.anonymous_actor_id() do
%{email: email} = Map.get(participation, :metadata)
email
|> participation_updated(participation)
|> Email.Mailer.deliver_later()
end
:ok
end
@doc """
Send emails to local user
@@ -29,7 +37,7 @@ defmodule Mobilizon.Web.Email.Participation do
def send_emails_to_local_user(
%Participant{actor: %Actor{user_id: user_id} = _actor} = participation
) do
with %User{} = user <- Mobilizon.Users.get_user!(user_id) do
with %User{} = user <- Users.get_user!(user_id) do
user
|> participation_updated(participation)
|> Email.Mailer.deliver_later()
@@ -38,11 +46,21 @@ defmodule Mobilizon.Web.Email.Participation do
end
end
@spec participation_updated(User.t(), Participant.t(), String.t()) :: Bamboo.Email.t()
@spec participation_updated(String.t() | User.t(), Participant.t(), String.t()) ::
Bamboo.Email.t()
def participation_updated(user, participant, locale \\ "en")
@spec participation_updated(User.t(), Participant.t(), String.t()) :: Bamboo.Email.t()
def participation_updated(
%User{email: email},
%Participant{} = participant,
locale
),
do: participation_updated(email, participant, locale)
@spec participation_updated(String.t(), Participant.t(), String.t()) :: Bamboo.Email.t()
def participation_updated(
email,
%Participant{event: event, role: :rejected},
locale
) do
@@ -61,9 +79,9 @@ defmodule Mobilizon.Web.Email.Participation do
|> render(:event_participation_rejected)
end
@spec participation_updated(User.t(), Participant.t(), String.t()) :: Bamboo.Email.t()
@spec participation_updated(String.t(), Participant.t(), String.t()) :: Bamboo.Email.t()
def participation_updated(
%User{email: email},
email,
%Participant{event: event, role: :participant},
locale
) do
@@ -81,4 +99,26 @@ defmodule Mobilizon.Web.Email.Participation do
|> assign(:subject, subject)
|> render(:event_participation_approved)
end
@spec anonymous_participation_confirmation(String.t(), Participant.t(), String.t()) ::
Bamboo.Email.t()
def anonymous_participation_confirmation(
email,
%Participant{event: event, role: :not_confirmed} = participant,
locale \\ "en"
) do
Gettext.put_locale(locale)
subject =
gettext(
"Confirm your participation to event %{title}",
title: event.title
)
Email.base_email(to: email, subject: subject)
|> assign(:locale, locale)
|> assign(:participant, participant)
|> assign(:subject, subject)
|> render(:anonymous_participation_confirmation)
end
end

View File

@@ -58,8 +58,6 @@ defmodule Mobilizon.Web.Router do
)
end
## FEDERATION
scope "/.well-known", Mobilizon.Web do
pipe_through(:well_known)
@@ -110,8 +108,10 @@ defmodule Mobilizon.Web.Router do
end
## MOBILIZON
forward("/graphiql", Absinthe.Plug.GraphiQL, schema: Mobilizon.Web.Schema)
scope "/graphiql" do
pipe_through(:graphql)
forward("/", Absinthe.Plug.GraphiQL, schema: Mobilizon.GraphQL.Schema)
end
scope "/", Mobilizon.Web do
pipe_through(:browser)
@@ -125,6 +125,12 @@ defmodule Mobilizon.Web.Router do
# This is a hack to ease link generation into emails
get("/moderation/reports/:id", PageController, :index, as: "moderation_report")
get("/participation/email/confirm/:token", PageController, :index,
as: "participation_email_confirmation"
)
get("/interact", PageController, :interact)
end
scope "/proxy/", Mobilizon.Web do

View File

@@ -0,0 +1,163 @@
<h3><%= pgettext("terms", "What information do we collect?") %></h3>
<ul>
<li>
<em><%= pgettext("terms", "Basic account information") %></em>
<p><%= pgettext(
"terms",
"We collect information from you when you register on this server and gather data when you participate in the
platform by reading, writing, and interacting with content shared here. If you register on this server, you will
be asked to enter an e-mail address, a password and at least an username. Your e-mail address will be verified by
an email containing a unique link. If that link is visited, we know that you control the e-mail address. You may
also enter additional profile information such as a display name and biography, and upload a profile picture and
header image. The username, display name, biography, profile picture and header image are always listed publicly.
You may, however, visit this server without registering."
) %>
</p>
</li>
<li>
<em><%= pgettext("terms", "Published events and comments") %></em>
<p>
<%= pgettext(
"terms",
"Your events and comments are delivered to other instances that follow your own, meaning they are delivered to
different servers and copies are stored there. When you delete events or comments, this is likewise delivered to
these other instances. The action of joining an event is federated as well. Please keep in mind that the operators
of the server and any receiving server may view such messages, and that recipients may screenshot, copy or
otherwise re-share them."
) %>
<em><%= pgettext("terms", "Do not share any dangerous information over Mobilizon.") %></em>
</p>
</li>
<li>
<em><%= pgettext("terms", "IPs and other metadata") %></em>
<p>
<%=
pgettext(
"terms",
"We also may retain server logs which include the IP address of every request to our server."
)
%>
</p>
</li>
</ul>
<h3><%= pgettext("terms", "What do we use your information for?") %></h3>
<p><%=
pgettext(
"terms",
"Any of the information we collect from you may be used in the following ways:"
)
%></p>
<ul>
<li><%= pgettext("terms", "To provide the core functionality of Mobilizon. Depending on this instance's policy you may only be able to
interact with other people's content and post your own content if you are logged in.") %></li>
<li><%= pgettext("terms", "To aid moderation of the community, for example comparing your IP address with other known ones to determine ban
evasion or other violations.") %></li>
<li><%= pgettext("terms", "The email address you provide may be used to send you information, updates and notifications about other people
interacting with your content or sending you messages and to respond to inquiries, and/or other requests or
questions.") %></li>
</ul>
<h3 class="title"><%= pgettext("terms", "How do we protect your information?") %></h3>
<p>
<%=
pgettext(
"terms",
"We implement a variety of security measures to maintain the safety of your personal information when you enter,
submit, or access your personal information. Among other things, your browser session, as well as the traffic between
your applications and the API, are secured with SSL/TLS, and your password is hashed using a strong one-way
algorithm."
)
%>
</p>
<h3 class="title"><%= pgettext("terms", "What is our data retention policy?") %></h3>
<p><%= pgettext("terms", "We will make a good faith effort to:") %></p>
<ul>
<li><%=
pgettext(
"terms",
"Retain server logs containing the IP address of all requests to this server, in so far as such logs are kept, no more
than 90 days."
)%>
</li>
<li>
<%=
pgettext(
"terms",
"Retain the IP addresses associated with registered users no more than 12 months."
)
%>
</li>
</ul>
<p>
<%=
pgettext(
"terms",
"You can request and download an archive of your content, including your posts, media attachments, profile picture,
and header image."
)
%>
</p>
<p><%= pgettext("terms", "You may irreversibly delete your account at any time.") %></p>
<h3 class="title"><%= pgettext("terms", "Do we use cookies?") %></h3>
<p><%=
pgettext("terms", "We store the following information on your device when you connect:")
%>
</p>
<ul>
<li><%= pgettext("terms", "An internal user ID") %></li>
<li><%= pgettext("terms", "An internal ID for your current selected identity") %></li>
<li><%= pgettext("terms", "Tokens to authenticate you") %></li>
</ul>
<p><%= pgettext("terms", "If you delete these informations, you need to login again.") %></p>
<p><%=
pgettext(
"terms",
"If you're not connected, we don't store any information on your device, unless you participate in an event
anonymously. In that case we store the hash of the UUID and participation status in your browser so that we may
display participation status. Deleting these informations will only stop displaying participation status in your
browser."
)
%>
</p>
<em>
<%= pgettext("terms", "Note: These informations are stored in your localStorage and not your cookies.") %>
</em>
<h3 class="title"><%=
pgettext("terms", "Do we disclose any information to outside parties?")
%></h3>
<p>
<%=
pgettext(
"terms",
"We do not sell, trade, or otherwise transfer to outside parties your personally identifiable information. This
does not include trusted third parties who assist us in operating our site, conducting our business, or servicing
you, so long as those parties agree to keep this information confidential. We may also release your information
when we believe release is appropriate to comply with the law, enforce our site policies, or protect ours or
others rights, property, or safety."
)
%>
</p>
<p>
<%=
pgettext(
"terms",
"Your content may be downloaded by other servers in the network. Your content is delivered to the servers
following your instance, and direct messages are delivered to the servers of the recipients, in so far as these
recipients reside on a different server than this one."
)
%>
</p>
<h3 class="title"><%=
pgettext("terms", "Site usage by children")
%></h3>
<p><%= pgettext("terms", "If this server is in the EU or the EEA: Our site, products and services are all directed to people who are at least 16 years old. If you are under the age of 16, per the requirements of the GDPR (<a href=\"https://en.wikipedia.org/wiki/General_Data_Protection_Regulation\">General Data Protection Regulation</a>) do not use this site.") |> raw %></p>
<p><%= pgettext("terms", "If this server is in the USA: Our site, products and services are all directed to people who are at least 13 years old. If you are under the age of 13, per the requirements of COPPA (<a href=\"https://en.wikipedia.org/wiki/Children%27s_Online_Privacy_Protection_Act\">Children's Online Privacy Protection Act</a>) do not use this site.") |> raw %></p>
<p><%= pgettext("terms", "Law requirements can be different if this server is in another jurisdiction.") %></p>
<h3 class="title"><%=
pgettext("terms", "Changes to our Privacy Policy")
%></h3>
<p><%= pgettext("terms", "If we decide to change our privacy policy, we will post those changes on this page.") %></p>
<p><%= pgettext("terms", "This document is CC-BY-SA. It was last updated January 16, 2020.") %></p>
<p><%= pgettext("terms", "Originally adapted from the <a href=\"https://mastodon.social/terms\">Mastodon</a> and <a href=\"https://github.com/discourse/discourse\">Discourse</a> privacy policies.") |> raw %></p>

View File

@@ -0,0 +1,81 @@
<!-- HERO -->
<tr>
<td bgcolor="#424056" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">
<%= gettext "Participation confirmation" %>
</h1>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<!-- COPY BLOCK -->
<tr>
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<!-- COPY -->
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 0px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0;">
<%= gettext "You requested to participate in event %{title}", title: @participant.event.title %>
</p>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 40px 30px; color: #777777; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0">
<%= gettext "If you didn't request this email, you can simply ignore it." %>
</p>
</td>
</tr>
<!-- BULLETPROOF BUTTON -->
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 20px 30px 60px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;" bgcolor="#424056"><a href="<%= participation_email_confirmation_url(Mobilizon.Web.Endpoint, :index, @participant.metadata.confirmation_token) %>" target="_blank" style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #424056; display: inline-block;">
<%= gettext "Confirm my participation" %>
</a></td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 40px 30px; color: #777777; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 20px;" >
<p style="margin: 0">
<%= gettext "If you need to cancel your participation, just access the event page through link above and click on the participation button." %>
</p>
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>

View File

@@ -0,0 +1,11 @@
<%= gettext "Participation confirmation" %>
==
<%= gettext "You requested to participate in event %{title}.", title: @participant.event.title %>
<%= gettext "If you didn't request this email, you can simply ignore it." %>
<%= participation_email_confirmation_url(Mobilizon.Web.Endpoint, :index, @participant.metadata.confirmation_token) %>
<%= gettext "If you need to cancel your participation, just access the previous link and click on the participation button." %>

View File

@@ -8,7 +8,7 @@
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">
<%= gettext "All good!" %>
</h1>

View File

@@ -8,7 +8,7 @@
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">
<%= gettext "Sorry!" %>
</h1>

View File

@@ -8,7 +8,7 @@
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">
<%= gettext "Event updated!" %>
</h1>

View File

@@ -8,7 +8,7 @@
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">
<%= gettext "Trouble signing in?" %>
</h1>

View File

@@ -8,7 +8,7 @@
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">
<%= gettext "Nearly here!" %>
</h1>

View File

@@ -8,7 +8,7 @@
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; line-height: 48px;">
<h1 style="font-size: 48px; font-weight: 400; margin: 0;">
<%= gettext "New report on %{instance}", instance: @instance[:name] %>
</h1>

View File

@@ -0,0 +1,7 @@
defmodule Mobilizon.Web.APIView do
@moduledoc """
View for our the API terms
"""
use Mobilizon.Web, :view
import Mobilizon.Web.Gettext
end