Introduce group basic federation, event new page and notifications

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2020-02-18 08:57:00 +01:00
parent 300ef8f245
commit 4144e9ffd0
416 changed files with 32220 additions and 16750 deletions

View File

@@ -10,10 +10,24 @@ defmodule Mobilizon.Federation.ActivityPub do
import Mobilizon.Federation.ActivityPub.Utils
alias Mobilizon.{Actors, Config, Events, Reports, Share, Users}
alias Mobilizon.Actors.{Actor, Follower}
alias Mobilizon.Events.{Comment, Event, Participant}
alias Mobilizon.{
Actors,
Config,
Conversations,
Events,
Reports,
Resources,
Share,
Todos,
Users
}
alias Mobilizon.Actors.{Actor, Follower, Member}
alias Mobilizon.Conversations.Comment
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Reports.Report
alias Mobilizon.Resources.Resource
alias Mobilizon.Todos.{Todo, TodoList}
alias Mobilizon.Tombstone
alias Mobilizon.Federation.ActivityPub.{
@@ -31,6 +45,8 @@ defmodule Mobilizon.Federation.ActivityPub do
alias Mobilizon.Federation.WebFinger
alias Mobilizon.GraphQL.API.Utils, as: APIUtils
alias Mobilizon.Service.Notifications.Scheduler
alias Mobilizon.Service.RichMedia.Parser
alias Mobilizon.Web.Endpoint
alias Mobilizon.Web.Email.{Admin, Mailer}
@@ -40,7 +56,7 @@ defmodule Mobilizon.Federation.ActivityPub do
@doc """
Wraps an object into an activity
"""
@spec create_activity(map, boolean) :: {:ok, Activity.t()}
@spec create_activity(map(), boolean()) :: {:ok, Activity.t()}
def create_activity(map, local \\ true) when is_map(map) do
with map <- lazy_put_activity_defaults(map) do
{:ok,
@@ -61,27 +77,25 @@ defmodule Mobilizon.Federation.ActivityPub do
def fetch_object_from_url(url) do
Logger.info("Fetching object from url #{url}")
date = Signature.generate_date_header()
headers =
[{:Accept, "application/activity+json"}]
|> maybe_date_fetch(date)
|> sign_fetch(url, date)
Logger.debug("Fetch headers: #{inspect(headers)}")
with {:not_http, true} <- {:not_http, String.starts_with?(url, "http")},
{:existing_event, nil} <- {:existing_event, Events.get_event_by_url(url)},
{:existing_comment, nil} <- {:existing_comment, Events.get_comment_from_url(url)},
{:existing_actor, {:error, _err}} <-
{:existing_actor, get_or_fetch_actor_by_url(url)},
{:ok, %{body: body, status_code: code}} when code in 200..299 <-
{:existing_comment, nil} <- {:existing_comment, Conversations.get_comment_from_url(url)},
{:existing_resource, nil} <- {:existing_resource, Resources.get_resource_by_url(url)},
{:existing_actor, {:error, :actor_not_found}} <-
{:existing_actor, Actors.get_actor_by_url(url)},
date <- Signature.generate_date_header(),
headers <-
[{:Accept, "application/activity+json"}]
|> maybe_date_fetch(date)
|> sign_fetch_relay(url, date),
{:ok, %HTTPoison.Response{body: body, status_code: code}} when code in 200..299 <-
HTTPoison.get(
url,
headers,
follow_redirect: true,
timeout: 10_000,
recv_timeout: 20_000
recv_timeout: 20_000,
ssl: [{:versions, [:"tlsv1.2"]}]
),
{:ok, data} <- Jason.decode(body),
{:origin_check, true} <- {:origin_check, origin_check?(url, data)},
@@ -98,7 +112,13 @@ defmodule Mobilizon.Federation.ActivityPub do
{:ok, Events.get_public_event_by_url_with_preload!(object_url)}
"Note" ->
{:ok, Events.get_comment_from_url_with_preload!(object_url)}
{:ok, Conversations.get_comment_from_url_with_preload!(object_url)}
"Document" ->
{:ok, Resources.get_resource_by_url_with_preloads(object_url)}
"ResourceCollection" ->
{:ok, Resources.get_resource_by_url_with_preloads(object_url)}
"Actor" ->
{:ok, Actors.get_actor_by_url!(object_url, true)}
@@ -111,7 +131,10 @@ defmodule Mobilizon.Federation.ActivityPub do
{:ok, Events.get_public_event_by_url_with_preload!(event_url)}
{:existing_comment, %Comment{url: comment_url}} ->
{:ok, Events.get_comment_from_url_with_preload!(comment_url)}
{:ok, Conversations.get_comment_from_url_with_preload!(comment_url)}
{:existing_resource, %Resource{url: resource_url}} ->
{:ok, Resources.get_resource_by_url_with_preloads(resource_url)}
{:existing_actor, {:ok, %Actor{url: actor_url}}} ->
{:ok, Actors.get_actor_by_url!(actor_url, true)}
@@ -121,6 +144,7 @@ defmodule Mobilizon.Federation.ActivityPub do
{:error, "Object origin check failed"}
e ->
Logger.warn("Something failed while fetching url #{inspect(e)}")
{:error, e}
end
end
@@ -131,6 +155,8 @@ defmodule Mobilizon.Federation.ActivityPub do
@spec get_or_fetch_actor_by_url(String.t(), boolean) :: {:ok, Actor.t()} | {:error, String.t()}
def get_or_fetch_actor_by_url(url, preload \\ false)
def get_or_fetch_actor_by_url(nil, _preload), do: {:error, "Can't fetch a nil url"}
def get_or_fetch_actor_by_url("https://www.w3.org/ns/activitystreams#Public", _preload) do
with %Actor{url: url} <- Relay.get_actor() do
get_or_fetch_actor_by_url(url)
@@ -176,6 +202,9 @@ defmodule Mobilizon.Federation.ActivityPub do
:event -> create_event(args, additional)
:comment -> create_comment(args, additional)
:group -> create_group(args, additional)
:todo_list -> create_todo_list(args, additional)
:todo -> create_todo(args, additional)
:resource -> create_resource(args, additional)
end),
{:ok, activity} <- create_activity(create_data, local),
:ok <- maybe_federate(activity) do
@@ -205,7 +234,10 @@ defmodule Mobilizon.Federation.ActivityPub do
with {:ok, entity, update_data} <-
(case type do
:event -> update_event(old_entity, args, additional)
:comment -> update_comment(old_entity, args, additional)
:actor -> update_actor(old_entity, args, additional)
:todo -> update_todo(old_entity, args, additional)
:resource -> update_resource(old_entity, args, additional)
end),
{:ok, activity} <- create_activity(update_data, local),
:ok <- maybe_federate(activity) do
@@ -225,6 +257,7 @@ defmodule Mobilizon.Federation.ActivityPub do
case type do
:join -> accept_join(entity, additional)
:follow -> accept_follow(entity, additional)
:invite -> accept_invite(entity, additional)
end
with {:ok, activity} <- create_activity(update_data, local),
@@ -263,8 +296,7 @@ defmodule Mobilizon.Federation.ActivityPub do
local \\ true,
public \\ true
) do
with true <- Visibility.is_public?(object),
{:ok, %Actor{id: object_owner_actor_id}} <- Actors.get_actor_by_url(object["actor"]),
with {:ok, %Actor{id: object_owner_actor_id}} <- Actors.get_actor_by_url(object["actor"]),
{:ok, %Share{} = _share} <- Share.create(object["id"], actor.id, object_owner_actor_id),
announce_data <- make_announce_data(actor, object, activity_id, public),
{:ok, activity} <- create_activity(announce_data, local),
@@ -371,7 +403,7 @@ defmodule Mobilizon.Federation.ActivityPub do
with audience <-
Audience.calculate_to_and_cc_from_mentions(comment),
{:ok, %Comment{} = comment} <- Events.delete_comment(comment),
{:ok, %Comment{} = comment} <- Conversations.delete_comment(comment),
{:ok, true} <- Cachex.del(:activity_pub, "comment_#{comment.uuid}"),
{:ok, %Tombstone{} = _tombstone} <-
Tombstone.create_tombstone(%{uri: comment.url, actor_id: actor.id}),
@@ -399,6 +431,30 @@ defmodule Mobilizon.Federation.ActivityPub do
end
end
def delete(
%Resource{url: url, actor: %Actor{url: actor_url}} = resource,
local
) do
Logger.debug("Building Delete Resource activity")
data = %{
"actor" => actor_url,
"type" => "Delete",
"object" => url,
"id" => url <> "/delete",
"to" => [actor_url]
}
Logger.debug(inspect(data))
with {:ok, _resource} <- Resources.delete_resource(resource),
{:ok, true} <- Cachex.del(:activity_pub, "resource_#{resource.id}"),
{:ok, activity} <- create_activity(data, local),
:ok <- maybe_federate(activity) do
{:ok, activity, resource}
end
end
def flag(args, local \\ false, _additional \\ %{}) do
with {:build_args, args} <- {:build_args, prepare_args_for_report(args)},
{:create_report, {:ok, %Report{} = report}} <-
@@ -511,6 +567,79 @@ defmodule Mobilizon.Federation.ActivityPub do
end
end
@spec invite(Actor.t(), Actor.t(), Actor.t(), boolean, map()) ::
{:ok, map(), Member.t()} | {:error, :member_not_found}
def invite(
%Actor{url: group_url, id: group_id} = group,
%Actor{url: actor_url, id: actor_id} = actor,
%Actor{url: target_actor_url, id: target_actor_id} = _target_actor,
local \\ true,
additional \\ %{}
) do
Logger.debug("Handling #{actor_url} invite to #{group_url} sent to #{target_actor_url}")
with {:is_able_to_invite, true} <- {:is_able_to_invite, is_able_to_invite(actor, group)},
{:ok, %Member{url: member_url} = member} <-
Actors.create_member(%{
parent_id: group_id,
actor_id: target_actor_id,
role: :invited,
invited_by_id: actor_id,
url: Map.get(additional, :url)
}),
invite_data <- %{
"type" => "Invite",
"actor" => actor_url,
"object" => group_url,
"target" => target_actor_url,
"id" => member_url
},
{:ok, activity} <-
create_activity(
invite_data
|> Map.merge(%{"to" => [target_actor_url], "cc" => [group_url]})
|> Map.merge(additional),
local
),
:ok <- maybe_federate(activity) do
{:ok, activity, member}
end
end
defp is_able_to_invite(%Actor{domain: actor_domain, id: actor_id}, %Actor{
domain: group_domain,
id: group_id
}) do
# If the actor comes from the same domain we trust it
if actor_domain == group_domain do
true
else
# If local group, we'll send the invite
with {:ok, %Member{} = admin_member} <- Actors.get_member(actor_id, group_id) do
Member.is_administrator(admin_member)
end
end
end
def move(type, old_entity, args, local \\ false, additional \\ %{}) do
Logger.debug("We're moving something")
Logger.debug(inspect(args))
with {:ok, entity, update_data} <-
(case type do
:resource -> move_resource(old_entity, args, additional)
end),
{:ok, activity} <- create_activity(update_data, local),
:ok <- maybe_federate(activity) do
{:ok, activity, entity}
else
err ->
Logger.error("Something went wrong while creating a Move activity")
Logger.debug(inspect(err))
err
end
end
@doc """
Create an actor locally by its URL (AP ID)
"""
@@ -569,9 +698,14 @@ defmodule Mobilizon.Federation.ActivityPub do
defp is_create_activity?(%Activity{data: %{"type" => "Create"}}), do: true
defp is_create_activity?(_), do: false
@spec is_announce_activity?(Activity.t()) :: boolean
defp is_announce_activity?(%Activity{data: %{"type" => "Announce"}}), do: true
defp is_announce_activity?(_), do: false
@doc """
Publish an activity to all appropriated audiences inboxes
"""
# credo:disable-for-lines:47
@spec publish(Actor.t(), Activity.t()) :: :ok
def publish(actor, activity) do
Logger.debug("Publishing an activity")
@@ -593,9 +727,18 @@ defmodule Mobilizon.Federation.ActivityPub do
[]
end
# If we want to send to all members of the group, because this server is the one the group is on
members =
if is_announce_activity?(activity) and actor.type == :Group and
actor.members_url in activity.recipients and is_nil(actor.domain) do
Actors.list_external_members_for_group(actor)
else
[]
end
remote_inboxes =
(remote_actors(activity) ++ followers)
|> Enum.map(fn follower -> follower.shared_inbox_url end)
(remote_actors(activity) ++ followers ++ members)
|> Enum.map(fn follower -> follower.shared_inbox_url || follower.inbox_url end)
|> Enum.uniq()
{:ok, data} = Transmogrifier.prepare_outgoing(activity.data)
@@ -654,11 +797,14 @@ defmodule Mobilizon.Federation.ActivityPub do
res =
with %HTTPoison.Response{status_code: 200, body: body} <-
HTTPoison.get!(url, [Accept: "application/activity+json"], follow_redirect: true),
HTTPoison.get!(url, [Accept: "application/activity+json"],
follow_redirect: true,
ssl: [{:versions, [:"tlsv1.2"]}]
),
:ok <- Logger.debug("response okay, now decoding json"),
{:ok, data} <- Jason.decode(body) do
Logger.debug("Got activity+json response at actor's endpoint, now converting data")
Converter.Actor.as_to_model_data(data)
{:ok, Converter.Actor.as_to_model_data(data)}
else
# Actor is gone, probably deleted
{:ok, %HTTPoison.Response{status_code: 410}} ->
@@ -679,7 +825,9 @@ defmodule Mobilizon.Federation.ActivityPub do
@spec fetch_public_activities_for_actor(Actor.t(), integer(), integer()) :: map()
def fetch_public_activities_for_actor(%Actor{} = actor, page \\ 1, limit \\ 10) do
{:ok, events, total_events} = Events.list_public_events_for_actor(actor, page, limit)
{:ok, comments, total_comments} = Events.list_public_comments_for_actor(actor, page, limit)
{:ok, comments, total_comments} =
Conversations.list_public_comments_for_actor(actor, page, limit)
event_activities = Enum.map(events, &event_to_activity/1)
comment_activities = Enum.map(comments, &comment_to_activity/1)
@@ -732,7 +880,7 @@ defmodule Mobilizon.Federation.ActivityPub do
@spec create_comment(map(), map()) :: {:ok, map()}
defp create_comment(args, additional) do
with args <- prepare_args_for_comment(args),
{:ok, %Comment{} = comment} <- Events.create_comment(args),
{:ok, %Comment{} = comment} <- Conversations.create_comment(args),
comment_as_data <- Convertible.model_to_as(comment),
audience <-
Audience.calculate_to_and_cc_from_mentions(comment),
@@ -754,6 +902,83 @@ defmodule Mobilizon.Federation.ActivityPub do
end
end
@spec create_todo_list(map(), map()) :: {:ok, map()}
defp create_todo_list(args, additional) do
with {:ok, %TodoList{actor_id: group_id} = todo_list} <- Todos.create_todo_list(args),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
todo_list_as_data <- Convertible.model_to_as(%{todo_list | actor: group}),
audience <- %{"to" => [group.url], "cc" => []},
create_data <-
make_create_data(todo_list_as_data, Map.merge(audience, additional)) do
{:ok, todo_list, create_data}
end
end
@spec create_todo(map(), map()) :: {:ok, map()}
defp create_todo(args, additional) do
with {:ok, %Todo{todo_list_id: todo_list_id, creator_id: creator_id} = todo} <-
Todos.create_todo(args),
%TodoList{actor_id: group_id} = todo_list <- Todos.get_todo_list(todo_list_id),
%Actor{} = creator <- Actors.get_actor(creator_id),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
todo <- %{todo | todo_list: %{todo_list | actor: group}, creator: creator},
todo_as_data <-
Convertible.model_to_as(todo),
audience <- %{"to" => [group.url], "cc" => []},
create_data <-
make_create_data(todo_as_data, Map.merge(audience, additional)) do
{:ok, todo, create_data}
end
end
defp create_resource(%{type: type} = args, additional) do
args =
case type do
:folder ->
args
_ ->
case Parser.parse(Map.get(args, :resource_url)) do
{:ok, metadata} ->
Map.put(args, :metadata, metadata)
_ ->
args
end
end
with {:ok,
%Resource{actor_id: group_id, creator_id: creator_id, parent_id: parent_id} = resource} <-
Resources.create_resource(args),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
%Actor{url: creator_url} = creator <- Actors.get_actor(creator_id),
resource_as_data <-
Convertible.model_to_as(%{resource | actor: group, creator: creator}),
audience <- %{
"to" => [group.url],
"cc" => [],
"actor" => creator_url,
"attributedTo" => [creator_url]
} do
create_data =
case parent_id do
nil ->
make_create_data(resource_as_data, Map.merge(audience, additional))
parent_id ->
# In case the resource has a parent we don't `Create` the resource but `Add` it to an existing resource
parent = Resources.get_resource(parent_id)
make_add_data(resource_as_data, parent, Map.merge(audience, additional))
end
{:ok, resource, create_data}
else
err ->
Logger.error(inspect(err))
err
end
end
@spec check_for_tombstones(map()) :: Tombstone.t() | nil
defp check_for_tombstones(%{url: url}), do: Tombstone.find_tombstone(url)
defp check_for_tombstones(_), do: nil
@@ -776,6 +1001,24 @@ defmodule Mobilizon.Federation.ActivityPub do
end
end
@spec update_comment(Comment.t(), map(), map()) :: {:ok, Comment.t(), Activity.t()} | any()
defp update_comment(%Comment{} = old_comment, args, additional) do
with args <- prepare_args_for_comment(args),
{:ok, %Comment{} = new_comment} <- Conversations.update_comment(old_comment, args),
{:ok, true} <- Cachex.del(:activity_pub, "comment_#{new_comment.uuid}"),
comment_as_data <- Convertible.model_to_as(new_comment),
audience <-
Audience.calculate_to_and_cc_from_mentions(new_comment),
update_data <- make_update_data(comment_as_data, Map.merge(audience, additional)) do
{:ok, new_comment, update_data}
else
err ->
Logger.error("Something went wrong while creating an update activity")
Logger.debug(inspect(err))
err
end
end
@spec update_actor(Actor.t(), map, map) :: {:ok, Actor.t(), Activity.t()} | any
defp update_actor(%Actor{} = old_actor, args, additional) do
with {:ok, %Actor{} = new_actor} <- Actors.update_actor(old_actor, args),
@@ -789,6 +1032,84 @@ defmodule Mobilizon.Federation.ActivityPub do
end
end
@spec update_actor(Todo.t(), map, map) :: {:ok, Todo.t(), Activity.t()} | any
defp update_todo(%Todo{} = old_todo, args, additional) do
with {:ok, %Todo{todo_list_id: todo_list_id} = todo} <- Todos.update_todo(old_todo, args),
%TodoList{actor_id: group_id} = todo_list <- Todos.get_todo_list(todo_list_id),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
todo_as_data <-
Convertible.model_to_as(%{todo | todo_list: %{todo_list | actor: group}}),
audience <- %{"to" => [group.url], "cc" => []},
update_data <-
make_update_data(todo_as_data, Map.merge(audience, additional)) do
{:ok, todo, update_data}
end
end
defp update_resource(%Resource{} = old_resource, %{parent_id: _parent_id} = args, additional) do
move_resource(old_resource, args, additional)
end
# Simple rename
defp update_resource(%Resource{} = old_resource, %{title: title} = _args, additional) do
with {:ok, %Resource{actor_id: group_id, creator_id: creator_id} = resource} <-
Resources.update_resource(old_resource, %{title: title}),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
%Actor{url: creator_url} <- Actors.get_actor(creator_id),
resource_as_data <-
Convertible.model_to_as(%{resource | actor: group}),
audience <- %{
"to" => [group.url],
"cc" => [],
"actor" => creator_url,
"attributedTo" => [creator_url]
},
update_data <-
make_update_data(resource_as_data, Map.merge(audience, additional)) do
{:ok, resource, update_data}
else
err ->
Logger.error(inspect(err))
err
end
end
defp move_resource(
%Resource{parent_id: old_parent_id} = old_resource,
%{parent_id: _new_parent_id} = args,
additional
) do
with {:ok,
%Resource{actor_id: group_id, creator_id: creator_id, parent_id: new_parent_id} =
resource} <-
Resources.update_resource(old_resource, args),
old_parent <- Resources.get_resource(old_parent_id),
new_parent <- Resources.get_resource(new_parent_id),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
%Actor{url: creator_url} <- Actors.get_actor(creator_id),
resource_as_data <-
Convertible.model_to_as(%{resource | actor: group}),
audience <- %{
"to" => [group.url],
"cc" => [],
"actor" => creator_url,
"attributedTo" => [creator_url]
},
move_data <-
make_move_data(
resource_as_data,
old_parent,
new_parent,
Map.merge(audience, additional)
) do
{:ok, resource, move_data}
else
err ->
Logger.error(inspect(err))
err
end
end
@spec accept_follow(Follower.t(), map) :: {:ok, Follower.t(), Activity.t()} | any
defp accept_follow(%Follower{} = follower, additional) do
with {:ok, %Follower{} = follower} <- Actors.update_follower(follower, %{approved: true}),
@@ -819,6 +1140,8 @@ defmodule Mobilizon.Federation.ActivityPub do
Absinthe.Subscription.publish(Endpoint, participant.actor,
event_person_participation_changed: participant.actor.id
),
{:ok, _} <-
Scheduler.before_event_notification(participant),
participant_as_data <- Convertible.model_to_as(participant),
audience <-
Audience.calculate_to_and_cc_from_mentions(participant),
@@ -838,6 +1161,27 @@ defmodule Mobilizon.Federation.ActivityPub do
end
end
@spec accept_invite(Member.t(), map()) :: {:ok, Member.t(), Activity.t()} | any
defp accept_invite(
%Member{invited_by_id: invited_by_id, actor_id: actor_id} = member,
_additional
) do
with %Actor{} = inviter <- Actors.get_actor(invited_by_id),
%Actor{url: actor_url} <- Actors.get_actor(actor_id),
{:ok, %Member{url: member_url, id: member_id} = member} <-
Actors.update_member(member, %{role: :member}),
accept_data <- %{
"type" => "Accept",
"actor" => actor_url,
"to" => [inviter.url],
"cc" => [member.parent.url],
"object" => member_url,
"id" => "#{Endpoint.url()}/accept/invite/member/#{member_id}"
} do
{:ok, member, accept_data}
end
end
@spec reject_join(Participant.t(), map()) :: {:ok, Participant.t(), Activity.t()} | any()
defp reject_join(%Participant{} = participant, additional) do
with {:ok, %Participant{} = participant} <-
@@ -944,7 +1288,7 @@ defmodule Mobilizon.Federation.ActivityPub do
# Prepare and sanitize arguments for comments
defp prepare_args_for_comment(args) do
with in_reply_to_comment <-
args |> Map.get(:in_reply_to_comment_id) |> Events.get_comment_with_preload(),
args |> Map.get(:in_reply_to_comment_id) |> Conversations.get_comment_with_preload(),
event <- args |> Map.get(:event_id) |> handle_event_for_comment(),
args <- Map.update(args, :visibility, :public, & &1),
{text, mentions, tags} <-
@@ -1002,10 +1346,10 @@ defmodule Mobilizon.Federation.ActivityPub do
{:reported, %Actor{} = reported_actor} <-
{:reported, Actors.get_actor!(args.reported_id)},
content <- HtmlSanitizeEx.strip_tags(args.content),
event <- Events.get_comment(Map.get(args, :event_id)),
event <- Conversations.get_comment(Map.get(args, :event_id)),
{:get_report_comments, comments} <-
{:get_report_comments,
Events.list_comments_by_actor_and_ids(
Conversations.list_comments_by_actor_and_ids(
reported_actor.id,
Map.get(args, :comments_ids, [])
)} do

View File

@@ -5,7 +5,8 @@ defmodule Mobilizon.Federation.ActivityPub.Audience do
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.{Comment, Event, Participant}
alias Mobilizon.Conversations.Comment
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Share
alias Mobilizon.Storage.Repo

View File

@@ -0,0 +1,109 @@
defmodule Mobilizon.Federation.ActivityPub.Refresher do
@moduledoc """
Module that provides functions to explore and fetch collections on a group
"""
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityStream.Converter.Member, as: MemberConverter
alias Mobilizon.Federation.ActivityStream.Converter.Resource, as: ResourceConverter
alias Mobilizon.Federation.HTTPSignatures.Signature
alias Mobilizon.Resources
require Logger
import Mobilizon.Federation.ActivityPub.Utils,
only: [maybe_date_fetch: 2, sign_fetch: 4]
@spec fetch_group(String.t(), Actor.t()) :: :ok
def fetch_group(group_url, %Actor{} = on_behalf_of) do
with {:ok, %Actor{resources_url: resources_url, members_url: members_url}} <-
ActivityPub.get_or_fetch_actor_by_url(group_url) do
fetch_collection(members_url, on_behalf_of)
fetch_collection(resources_url, on_behalf_of)
end
end
def fetch_collection(nil, _on_behalf_of), do: :error
def fetch_collection(collection_url, on_behalf_of) do
Logger.debug("Fetching and preparing collection from url")
Logger.debug(inspect(collection_url))
with {:ok, data} <- fetch(collection_url, on_behalf_of) do
Logger.debug("Fetch ok, passing to process_collection")
process_collection(data, on_behalf_of)
end
end
defp process_collection(%{"type" => type, "orderedItems" => items}, _on_behalf_of)
when type in ["OrderedCollection", "OrderedCollectionPage"] do
Logger.debug(
"Processing an OrderedCollection / OrderedCollectionPage with has direct orderedItems"
)
Logger.debug(inspect(items))
Enum.each(items, &handling_element/1)
end
defp process_collection(%{"type" => "OrderedCollection", "first" => first}, on_behalf_of)
when is_map(first),
do: process_collection(first, on_behalf_of)
defp process_collection(%{"type" => "OrderedCollection", "first" => first}, on_behalf_of)
when is_bitstring(first) do
Logger.debug("OrderedCollection has a first property pointing to an URI")
with {:ok, data} <- fetch(first, on_behalf_of) do
Logger.debug("Fetched the collection for first property")
process_collection(data, on_behalf_of)
end
end
defp handling_element(%{"type" => "Member"} = data) do
Logger.debug("Handling Member element")
data
|> MemberConverter.as_to_model_data()
|> Actors.create_member()
end
defp handling_element(%{"type" => type} = data)
when type in ["Document", "ResourceCollection"] do
Logger.debug("Handling Resource element")
data
|> ResourceConverter.as_to_model_data()
|> Resources.create_resource()
end
defp fetch(url, %Actor{} = on_behalf_of) do
with date <- Signature.generate_date_header(),
headers <-
[{:Accept, "application/activity+json"}]
|> maybe_date_fetch(date)
|> sign_fetch(on_behalf_of, url, date),
%HTTPoison.Response{status_code: 200, body: body} <-
HTTPoison.get!(url, headers,
follow_redirect: true,
ssl: [{:versions, [:"tlsv1.2"]}]
),
{:ok, data} <-
Jason.decode(body) do
{:ok, data}
else
# Actor is gone, probably deleted
{:ok, %HTTPoison.Response{status_code: 410}} ->
Logger.info("Response HTTP 410")
{:error, :actor_deleted}
{:origin_check, false} ->
{:error, "Origin check failed"}
e ->
Logger.warn("Could not decode actor at fetch #{url}, #{inspect(e)}")
{:error, e}
end
end
end

View File

@@ -8,16 +8,18 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
A module to handle coding from internal to wire ActivityPub and back.
"""
alias Mobilizon.Actors
alias Mobilizon.Actors.{Actor, Follower}
alias Mobilizon.Events
alias Mobilizon.Events.{Comment, Event, Participant}
alias Mobilizon.{Actors, Conversations, Events, Resources, Todos}
alias Mobilizon.Actors.{Actor, Follower, Member}
alias Mobilizon.Conversations.Comment
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Resources.Resource
alias Mobilizon.Todos.{Todo, TodoList}
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.{Activity, Utils}
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Web.Email.Participation
alias Mobilizon.Web.Email.{Group, Participation}
require Logger
@@ -57,10 +59,10 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
def handle_incoming(%{"type" => "Create", "object" => %{"type" => "Note"} = object}) do
Logger.info("Handle incoming to create notes")
with {:ok, object_data} <-
with object_data <-
object |> Converter.Comment.as_to_model_data(),
{:existing_comment, {:error, :comment_not_found}} <-
{:existing_comment, Events.get_comment_from_url_with_preload(object_data.url)},
{:existing_comment, Conversations.get_comment_from_url_with_preload(object_data.url)},
{:ok, %Activity{} = activity, %Comment{} = comment} <-
ActivityPub.create(:comment, object_data, false) do
{:ok, activity, comment}
@@ -85,7 +87,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
def handle_incoming(%{"type" => "Create", "object" => %{"type" => "Event"} = object}) do
Logger.info("Handle incoming to create event")
with {:ok, object_data} <-
with object_data <-
object |> Converter.Event.as_to_model_data(),
{:existing_event, nil} <- {:existing_event, Events.get_event_by_url(object_data.url)},
{:ok, %Activity{} = activity, %Event{} = event} <-
@@ -110,6 +112,86 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
end
end
def handle_incoming(%{
"type" => "Create",
"object" => %{"type" => "TodoList", "id" => object_url} = object,
"actor" => actor_url
}) do
Logger.info("Handle incoming to create a todo list")
with {:existing_todo_list, nil} <-
{:existing_todo_list, Todos.get_todo_list_by_url(object_url)},
{:ok, %Actor{url: actor_url}} <- ActivityPub.get_or_fetch_actor_by_url(actor_url),
object_data when is_map(object_data) <-
object |> Converter.TodoList.as_to_model_data(),
{:ok, %Activity{} = activity, %TodoList{} = todo_list} <-
ActivityPub.create(:todo_list, object_data, false, %{"actor" => actor_url}) do
{:ok, activity, todo_list}
else
{:error, :group_not_found} -> :error
{:existing_todo_list, %TodoList{} = todo_list} -> {:ok, nil, todo_list}
end
end
def handle_incoming(%{
"type" => "Create",
"object" => %{"type" => "Todo", "id" => object_url} = object
}) do
Logger.info("Handle incoming to create a todo")
with {:existing_todo, nil} <-
{:existing_todo, Todos.get_todo_by_url(object_url)},
object_data <-
object |> Converter.Todo.as_to_model_data(),
{:ok, %Activity{} = activity, %Todo{} = todo} <-
ActivityPub.create(:todo, object_data, false) do
{:ok, activity, todo}
else
{:existing_todo, %Todo{} = todo} -> {:ok, nil, todo}
end
end
def handle_incoming(
%{
"type" => activity_type,
"object" => %{"type" => object_type, "id" => object_url} = object,
"to" => to
} = data
)
when activity_type in ["Create", "Add"] and
object_type in ["Document", "ResourceCollection"] do
Logger.info("Handle incoming to create a resource")
Logger.debug(inspect(data))
group_url = hd(to)
with {:existing_resource, nil} <-
{:existing_resource, Resources.get_resource_by_url(object_url)},
object_data when is_map(object_data) <-
object |> Converter.Resource.as_to_model_data(),
{:member, true} <-
{:member, Actors.is_member?(object_data.creator_id, object_data.actor_id)},
{:ok, %Activity{} = activity, %Resource{} = resource} <-
ActivityPub.create(:resource, object_data, false),
{:ok, %Actor{type: :Group, id: group_id} = group} <- Actors.get_actor_by_url(group_url),
announce_id <- "#{object_url}/announces/#{group_id}",
{:ok, _activity, _resource} <- ActivityPub.announce(group, object, announce_id) do
{:ok, activity, resource}
else
{:existing_resource, %Resource{} = resource} ->
{:ok, nil, resource}
{:member, false} ->
# At some point this should refresh the list of group members
# if the group is not local before simply returning an error
:error
{:error, e} ->
Logger.error(inspect(e))
:error
end
end
def handle_incoming(
%{
"type" => "Accept",
@@ -205,7 +287,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
})
when object_type in ["Person", "Group", "Application", "Service", "Organization"] do
with {:ok, %Actor{} = old_actor} <- Actors.get_actor_by_url(object["id"]),
{:ok, object_data} <-
object_data <-
object |> Converter.Actor.as_to_model_data(),
{:ok, %Activity{} = activity, %Actor{} = new_actor} <-
ActivityPub.update(:actor, old_actor, object_data, false) do
@@ -225,7 +307,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
{:ok, %Actor{url: actor_url}} <- Actors.get_actor_by_url(actor),
{:ok, %Event{} = old_event} <-
object |> Utils.get_url() |> ActivityPub.fetch_object_from_url(),
{:ok, object_data} <- Converter.Event.as_to_model_data(object),
object_data <- Converter.Event.as_to_model_data(object),
{:origin_check, true} <- {:origin_check, Utils.origin_check?(actor_url, update_data)},
{:ok, %Activity{} = activity, %Event{} = new_event} <-
ActivityPub.update(:event, old_event, object_data, false) do
@@ -350,6 +432,26 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
end
end
def handle_incoming(
%{
"type" => "Invite",
"object" => object,
"actor" => _actor,
"id" => id,
"target" => target
} = data
) do
with {:ok, %Actor{} = actor} <- data |> Utils.get_actor() |> Actors.get_actor_by_url(),
{:ok, object} <- object |> Utils.get_url() |> ActivityPub.fetch_object_from_url(),
{:ok, %Actor{} = target} <-
target |> Utils.get_url() |> ActivityPub.get_or_fetch_actor_by_url(),
{:ok, activity, %Member{} = member} <-
ActivityPub.invite(object, actor, target, false, %{url: id}),
:ok <- Group.send_invite_to_user(member) do
{:ok, activity, member}
end
end
#
# # TODO
# # Accept
@@ -373,8 +475,9 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
# end
# end
def handle_incoming(_) do
def handle_incoming(object) do
Logger.info("Handing something not supported")
Logger.debug(inspect(object))
{:error, :not_supported}
end
@@ -436,12 +539,37 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
# Handle incoming `Accept` activities wrapping a `Join` activity on an event
defp do_handle_incoming_accept_join(join_object, %Actor{} = actor_accepting) do
with {:join_event, {:ok, %Participant{role: role, event: event} = participant}}
when role in [:not_approved, :rejected] <-
{:join_event, get_participant(join_object)},
# TODO: The actor that accepts the Join activity may another one that the event organizer ?
# Or maybe for groups it's the group that sends the Accept activity
{:same_actor, true} <- {:same_actor, actor_accepting.id == event.organizer_actor_id},
case get_participant(join_object) do
{:ok, participant} ->
do_handle_incoming_accept_join_event(participant, actor_accepting)
{:error, _err} ->
case get_member(join_object) do
{:ok, member} ->
do_handle_incoming_accept_join_group(member, actor_accepting)
{:error, _err} ->
nil
end
end
end
defp do_handle_incoming_accept_join_event(%Participant{role: :participant}, _actor) do
Logger.debug(
"Tried to handle an Accept activity on a Join activity with a event object but the participant is already validated"
)
nil
end
defp do_handle_incoming_accept_join_event(
%Participant{role: role, event: event} = participant,
%Actor{} = actor_accepting
)
when role in [:not_approved, :rejected] do
# TODO: The actor that accepts the Join activity may another one that the event organizer ?
# Or maybe for groups it's the group that sends the Accept activity
with {:same_actor, true} <- {:same_actor, actor_accepting.id == event.organizer_actor_id},
{:ok, %Activity{} = activity, %Participant{role: :participant} = participant} <-
ActivityPub.accept(
:join,
@@ -452,20 +580,6 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
Participation.send_emails_to_local_user(participant) do
{:ok, activity, participant}
else
{:join_event, {:ok, %Participant{role: :participant}}} ->
Logger.debug(
"Tried to handle an Accept activity on a Join activity with a event object but the participant is already validated"
)
nil
{:join_event, _err} ->
Logger.debug(
"Tried to handle an Accept activity but it's not containing a Join activity on a event"
)
nil
{:same_actor} ->
{:error, "Actor who accepted the join wasn't the event organizer. Quite odd."}
@@ -474,6 +588,31 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
end
end
defp do_handle_incoming_accept_join_group(%Member{role: :member}, _actor) do
Logger.debug(
"Tried to handle an Accept activity on a Join activity with a group object but the member is already validated"
)
nil
end
defp do_handle_incoming_accept_join_group(
%Member{role: role, parent: _group} = member,
%Actor{} = _actor_accepting
)
when role in [:not_approved, :rejected, :invited] do
# TODO: The actor that accepts the Join activity may another one that the event organizer ?
# Or maybe for groups it's the group that sends the Accept activity
with {:ok, %Activity{} = activity, %Member{role: :member} = member} <-
ActivityPub.accept(
:invite,
member,
false
) do
{:ok, activity, member}
end
end
# Handle incoming `Reject` activities wrapping a `Join` activity on an event
defp do_handle_incoming_reject_join(join_object, %Actor{} = actor_accepting) do
with {:join_event, {:ok, %Participant{event: event, role: role} = participant}}
@@ -509,8 +648,6 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
end
end
# TODO: Add do_handle_incoming_accept_join/1 on Groups
defp get_follow(follow_object) do
with follow_object_id when not is_nil(follow_object_id) <- Utils.get_url(follow_object),
{:not_found, %Follower{} = follow} <-
@@ -539,6 +676,21 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
end
end
@spec get_member(String.t() | map()) :: {:ok, Member.t()} | {:error, String.t()}
defp get_member(member_object) do
with member_object_id when not is_nil(member_object_id) <- Utils.get_url(member_object),
%Member{} = member <-
Actors.get_member_by_url(member_object_id) do
{:ok, member}
else
{:error, :member_not_found} ->
{:error, "Member URL not found"}
_ ->
{:error, "ActivityPub ID not found in Accept Join object"}
end
end
def prepare_outgoing(%{"type" => _type} = data) do
data =
data

View File

@@ -24,6 +24,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
# so figure out what the actor's URI is based on what we have.
def get_url(%{"id" => id}), do: id
def get_url(id) when is_bitstring(id), do: id
def get_url(ids) when is_list(ids), do: get_url(hd(ids))
def get_url(_), do: nil
def make_json_ld_header do
@@ -176,6 +177,11 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
@doc """
Checks that an incoming AP object's actor matches the domain it came from.
"""
def origin_check?(id, %{"attributedTo" => actor} = params) do
params = params |> Map.put("actor", actor) |> Map.delete("attributedTo")
origin_check?(id, params)
end
def origin_check?(id, %{"actor" => actor} = params) when not is_nil(actor) do
id_uri = URI.parse(id)
actor_uri = URI.parse(get_actor(params))
@@ -185,9 +191,6 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
def origin_check?(_id, %{"actor" => nil}), do: false
def origin_check?(id, %{"attributedTo" => actor} = params),
do: origin_check?(id, Map.put(params, "actor", actor))
def origin_check?(_id, _data), do: false
defp compare_uris?(%URI{} = id_uri, %URI{} = other_uri), do: id_uri.host == other_uri.host
@@ -257,25 +260,24 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
def make_announce_data(actor, object, activity_id, public \\ true)
def make_announce_data(
%Actor{url: actor_url, followers_url: actor_followers_url} = _actor,
%Actor{} = actor,
%{"id" => url, "type" => type} = _object,
activity_id,
public
)
when type in @actor_types do
do_make_announce_data(actor_url, actor_followers_url, url, url, activity_id, public)
do_make_announce_data(actor, url, url, activity_id, public)
end
def make_announce_data(
%Actor{url: actor_url, followers_url: actor_followers_url} = _actor,
%Actor{} = actor,
%{"id" => url, "type" => type, "actor" => object_actor_url} = _object,
activity_id,
public
)
when type in ["Note", "Event"] do
when type in ["Note", "Event", "ResourceCollection", "Document"] do
do_make_announce_data(
actor_url,
actor_followers_url,
actor,
object_actor_url,
url,
activity_id,
@@ -284,8 +286,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
end
defp do_make_announce_data(
actor_url,
actor_followers_url,
%Actor{type: actor_type} = actor,
object_actor_url,
object_url,
activity_id,
@@ -293,15 +294,19 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
) do
{to, cc} =
if public do
{[actor_followers_url, object_actor_url],
{[actor.followers_url, object_actor_url],
["https://www.w3.org/ns/activitystreams#Public"]}
else
{[actor_followers_url], []}
if actor_type == :Group do
{[actor.members_url], []}
else
{[actor.followers_url], []}
end
end
data = %{
"type" => "Announce",
"actor" => actor_url,
"actor" => actor.url,
"object" => object_url,
"to" => to,
"cc" => cc
@@ -406,6 +411,51 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
|> Map.merge(additional)
end
@doc """
Make add activity data
"""
@spec make_add_data(map(), map()) :: map()
def make_add_data(object, target, additional \\ %{}) do
Logger.debug("Making add data")
Logger.debug(inspect(object))
Logger.debug(inspect(additional))
%{
"type" => "Add",
"to" => object["to"],
"cc" => object["cc"],
"actor" => object["actor"],
"object" => object,
"target" => Map.get(target, :url, target),
"id" => object["id"] <> "/add"
}
|> Map.merge(additional)
end
@doc """
Make move activity data
"""
@spec make_add_data(map(), map()) :: map()
def make_move_data(object, origin, target, additional \\ %{}) do
Logger.debug("Making move data")
Logger.debug(inspect(object))
Logger.debug(inspect(origin))
Logger.debug(inspect(target))
Logger.debug(inspect(additional))
%{
"type" => "Move",
"to" => object["to"],
"cc" => object["cc"],
"actor" => object["actor"],
"object" => object,
"origin" => if(is_nil(origin), do: origin, else: Map.get(origin, :url, origin)),
"target" => if(is_nil(target), do: target, else: Map.get(target, :url, target)),
"id" => object["id"] <> "/move"
}
|> Map.merge(additional)
end
@doc """
Converts PEM encoded keys to a public key representation
"""
@@ -428,11 +478,11 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
:public_key.pem_encode([public_key])
end
defp make_signature(id, date) do
def make_signature(actor, id, date) do
uri = URI.parse(id)
signature =
Relay.get_actor()
actor
|> HTTPSignatures.Signature.sign(%{
"(request-target)": "get #{uri.path}",
host: uri.host,
@@ -442,14 +492,32 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
[{:Signature, signature}]
end
def sign_fetch(headers, id, date) do
@doc """
Sign a request with the instance Relay actor.
"""
@spec sign_fetch_relay(List.t(), String.t(), String.t()) :: List.t()
def sign_fetch_relay(headers, id, date) do
with %Actor{} = actor <- Relay.get_actor() do
sign_fetch(headers, actor, id, date)
end
end
@doc """
Sign a request with an actor.
"""
@spec sign_fetch(List.t(), Actor.t(), String.t(), String.t()) :: List.t()
def sign_fetch(headers, actor, id, date) do
if Mobilizon.Config.get([:activitypub, :sign_object_fetches]) do
headers ++ make_signature(id, date)
headers ++ make_signature(actor, id, date)
else
headers
end
end
@doc """
Add the Date header to the request if we sign object fetches
"""
@spec maybe_date_fetch(List.t(), String.t()) :: List.t()
def maybe_date_fetch(headers, date) do
if Mobilizon.Config.get([:activitypub, :sign_object_fetches]) do
headers ++ [{:Date, date}]

View File

@@ -8,7 +8,7 @@ defmodule Mobilizon.Federation.ActivityPub.Visibility do
Utility functions related to content visibility
"""
alias Mobilizon.Events.Comment
alias Mobilizon.Conversations.Comment
alias Mobilizon.Federation.ActivityPub.Activity

View File

@@ -25,7 +25,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Actor do
Converts an AP object data to our internal data structure.
"""
@impl Converter
@spec as_to_model_data(map) :: map
@spec as_to_model_data(map()) :: {:ok, map()}
def as_to_model_data(data) do
avatar =
data["icon"]["url"] &&
@@ -41,7 +41,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Actor do
"url" => MediaProxy.url(data["image"]["url"])
}
actor_data = %{
%{
url: data["id"],
avatar: avatar,
banner: banner,
@@ -53,20 +53,20 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Actor do
outbox_url: data["outbox"],
following_url: data["following"],
followers_url: data["followers"],
members_url: data["members"],
resources_url: data["resources"],
shared_inbox_url: data["endpoints"]["sharedInbox"],
domain: URI.parse(data["id"]).host,
manually_approves_followers: data["manuallyApprovesFollowers"],
type: data["type"]
}
{:ok, actor_data}
end
@doc """
Convert an actor struct to an ActivityStream representation.
"""
@impl Converter
@spec model_to_as(ActorModel.t()) :: map
@spec model_to_as(ActorModel.t()) :: map()
def model_to_as(%ActorModel{} = actor) do
actor_data = %{
"id" => actor.url,
@@ -76,6 +76,9 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Actor do
"summary" => actor.summary,
"following" => actor.following_url,
"followers" => actor.followers_url,
"members" => actor.members_url,
"resources" => actor.resources_url,
"todos" => actor.todos_url,
"inbox" => actor.inbox_url,
"outbox" => actor.outbox_url,
"url" => actor.url,
@@ -94,6 +97,13 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Actor do
}
}
actor_data =
if actor.type == :Group do
Map.put(actor_data, "members", actor.members_url)
else
actor_data
end
actor_data =
if is_nil(actor.avatar) do
actor_data

View File

@@ -7,7 +7,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.Comment, as: CommentModel
alias Mobilizon.Conversations.Comment, as: CommentModel
alias Mobilizon.Events.Event
alias Mobilizon.Tombstone, as: TombstoneModel
@@ -60,42 +60,36 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
# We fetch the parent object
Logger.debug("We're fetching the parent object")
data =
if Map.has_key?(object, "inReplyTo") && object["inReplyTo"] != nil &&
object["inReplyTo"] != "" do
Logger.debug(fn -> "Object has inReplyTo #{object["inReplyTo"]}" end)
if Map.has_key?(object, "inReplyTo") && object["inReplyTo"] != nil &&
object["inReplyTo"] != "" do
Logger.debug(fn -> "Object has inReplyTo #{object["inReplyTo"]}" end)
case ActivityPub.fetch_object_from_url(object["inReplyTo"]) do
# Reply to an event (Event)
{:ok, %Event{id: id}} ->
Logger.debug("Parent object is an event")
data |> Map.put(:event_id, id)
case ActivityPub.fetch_object_from_url(object["inReplyTo"]) do
# Reply to an event (Event)
{:ok, %Event{id: id}} ->
Logger.debug("Parent object is an event")
data |> Map.put(:event_id, id)
# Reply to a comment (Comment)
{:ok, %CommentModel{id: id} = comment} ->
Logger.debug("Parent object is another comment")
# Reply to a comment (Comment)
{:ok, %CommentModel{id: id} = comment} ->
Logger.debug("Parent object is another comment")
data
|> Map.put(:in_reply_to_comment_id, id)
|> Map.put(:origin_comment_id, comment |> CommentModel.get_thread_id())
|> Map.put(:event_id, comment.event_id)
data
|> Map.put(:in_reply_to_comment_id, id)
|> Map.put(:origin_comment_id, comment |> CommentModel.get_thread_id())
|> Map.put(:event_id, comment.event_id)
# Anything else is kind of a MP
{:error, parent} ->
Logger.warn("Parent object is something we don't handle")
Logger.debug(inspect(parent))
data
end
else
Logger.debug("No parent object for this comment")
data
# Anything else is kind of a MP
{:error, parent} ->
Logger.warn("Parent object is something we don't handle")
Logger.debug(inspect(parent))
data
end
else
Logger.debug("No parent object for this comment")
{:ok, data}
else
err ->
{:error, err}
data
end
end
end

View File

@@ -63,7 +63,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
nil
end
entity = %{
%{
title: object["name"],
description: object["content"],
organizer_actor_id: actor_id,
@@ -87,11 +87,6 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
updated_at: object["updated"],
publish_at: object["published"]
}
{:ok, entity}
else
error ->
{:error, error}
end
end

View File

@@ -10,6 +10,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Flag do
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations
alias Mobilizon.Events
alias Mobilizon.Events.Event
alias Mobilizon.Reports.Report
@@ -91,7 +92,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Flag do
Enum.filter(objects, fn url ->
!(url == reported.url || (!is_nil(event) && event.url == url))
end),
comments <- Enum.map(comments, &Events.get_comment_from_url/1) do
comments <- Enum.map(comments, &Conversations.get_comment_from_url/1) do
%{
"reporter" => reporter,
"uri" => object["id"],

View File

@@ -0,0 +1,57 @@
defmodule Mobilizon.Federation.ActivityStream.Converter.Member do
@moduledoc """
Member converter.
This module allows to convert members from ActivityStream format to our own
internal one, and back.
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Actors.Member, as: MemberModel
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.Utils
alias Mobilizon.Federation.ActivityStream.Convertible
defimpl Convertible, for: MemberModel do
alias Mobilizon.Federation.ActivityStream.Converter.Member, as: MemberConverter
defdelegate model_to_as(member), to: MemberConverter
end
@doc """
Convert an event struct to an ActivityStream representation.
"""
@spec model_to_as(MemberModel.t()) :: map
def model_to_as(%MemberModel{} = member) do
%{
"type" => "Member",
"id" => member.url,
"actor" => member.actor.url,
"object" => member.parent.url,
"role" => member.role
}
end
def as_to_model_data(%{
"type" => "Member",
"actor" => actor,
"object" => group,
"role" => role,
"id" => url
}) do
with {:ok, %Actor{id: group_id}} <- get_actor(group),
{:ok, %Actor{id: actor_id}} <- get_actor(actor) do
%{
url: url,
actor_id: actor_id,
parent_id: group_id,
role: role
}
end
end
@spec get_actor(String.t() | map() | nil) :: {:ok, Actor.t()} | {:error, String.t()}
defp get_actor(nil), do: {:error, "nil property found for actor data"}
defp get_actor(actor), do: actor |> Utils.get_url() |> ActivityPub.get_or_fetch_actor_by_url()
end

View File

@@ -11,6 +11,10 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Picture do
alias Mobilizon.Web.Upload
@http_options [
ssl: [{:versions, [:"tlsv1.2"]}]
]
@doc """
Convert a picture struct to an ActivityStream representation.
"""
@@ -35,7 +39,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Picture do
actor_id
)
when is_bitstring(picture_url) do
with {:ok, %HTTPoison.Response{body: body}} <- HTTPoison.get(picture_url),
with {:ok, %HTTPoison.Response{body: body}} <- HTTPoison.get(picture_url, [], @http_options),
{:ok, %{name: name, url: url, content_type: content_type, size: size}} <-
Upload.store(%{body: body, name: name}),
{:picture_exists, nil} <- {:picture_exists, Media.get_picture_by_url(url)} do

View File

@@ -0,0 +1,129 @@
defmodule Mobilizon.Federation.ActivityStream.Converter.Resource do
@moduledoc """
Resource converter.
This module allows to convert resources from ActivityStream format to our own
internal one, and back.
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.Utils
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Resources
alias Mobilizon.Resources.Resource
require Logger
@behaviour Converter
defimpl Convertible, for: Resource do
alias Mobilizon.Federation.ActivityStream.Converter.Resource, as: ResourceConverter
defdelegate model_to_as(resource), to: ResourceConverter
end
@doc """
Convert an resource struct to an ActivityStream representation
"""
@impl Converter
@spec model_to_as(Resource.t()) :: map
def model_to_as(
%Resource{actor: %Actor{url: actor_url}, creator: %Actor{url: creator_url}, type: type} =
resource
) do
res = %{
"actor" => creator_url,
"id" => resource.url,
"name" => resource.title,
"summary" => resource.summary,
"context" => get_context(resource),
"attributedTo" => actor_url
}
case type do
:folder ->
Map.put(res, "type", "ResourceCollection")
_ ->
res
|> Map.put("type", "Document")
|> Map.put("url", resource.resource_url)
end
end
@doc """
Converts an AP object data to our internal data structure.
"""
@impl Converter
@spec as_to_model_data(map) :: {:ok, map} | {:error, any()}
def as_to_model_data(%{"type" => type, "actor" => creator, "attributedTo" => group} = object) do
with {:ok, %Actor{id: actor_id, resources_url: resources_url}} <- get_actor(group),
{:ok, %Actor{id: creator_id}} <- get_actor(creator),
parent_id <- get_parent_id(object["context"], resources_url) do
data = %{
title: object["name"],
summary: object["summary"],
url: object["id"],
actor_id: actor_id,
creator_id: creator_id,
parent_id: parent_id
}
case type do
"Document" ->
data
|> Map.put(:type, :link)
|> Map.put(:resource_url, object["url"])
"ResourceCollection" ->
data
|> Map.put(:type, :folder)
end
else
{:error, err} -> {:error, err}
err -> {:error, err}
end
end
@spec get_actor(String.t() | map() | nil) :: {:ok, Actor.t()} | {:error, String.t()}
defp get_actor(nil), do: {:error, "nil property found for actor data"}
defp get_actor(actor), do: actor |> Utils.get_url() |> ActivityPub.get_or_fetch_actor_by_url()
defp get_context(%Resource{parent_id: nil, actor: %Actor{resources_url: resources_url}}),
do: resources_url
defp get_context(%Resource{parent: %Resource{url: url}}), do: url
defp get_context(%Resource{parent_id: parent_id}),
do: parent_id |> Resources.get_resource() |> Map.get(:url)
@spec get_parent_id(String.t(), String.t()) :: Resource.t() | map()
defp get_parent_id(context, resources_url) do
Logger.debug(
"Getting parentID for context #{inspect(context)} and with resources_url #{
inspect(resources_url)
}"
)
case Utils.get_url(context) do
nil -> nil
^resources_url -> nil
context_url -> fetch_resource(context_url)
end
end
defp fetch_resource(context_url) do
case Resources.get_resource_by_url(context_url) do
%Resource{id: resource_id} = _resource ->
resource_id
nil ->
case ActivityPub.fetch_object_from_url(context_url) do
{:ok, %Resource{id: resource_id} = _resource} ->
resource_id
_ ->
nil
end
end
end
end

View File

@@ -0,0 +1,70 @@
defmodule Mobilizon.Federation.ActivityStream.Converter.Todo do
@moduledoc """
TodoList converter.
This module allows to convert todo lists from ActivityStream format to our own
internal one, and back.
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Todos
alias Mobilizon.Todos.{Todo, TodoList}
@behaviour Converter
defimpl Convertible, for: Todo do
alias Mobilizon.Federation.ActivityStream.Converter.Todo, as: TodoConverter
defdelegate model_to_as(todo), to: TodoConverter
end
@doc """
Convert an todo list struct to an ActivityStream representation
"""
@impl Converter
@spec model_to_as(Todo.t()) :: map
def model_to_as(
%Todo{
todo_list: %TodoList{actor: %Actor{url: group_url} = _group, url: todo_list_url},
creator: %Actor{url: creator_url}
} = todo
) do
%{
"type" => "Todo",
"actor" => creator_url,
"attributedTo" => group_url,
"id" => todo.url,
"name" => todo.title,
"status" => todo.status,
"todoList" => todo_list_url
}
end
@doc """
Converts an AP object data to our internal data structure.
"""
@impl Converter
@spec as_to_model_data(map) :: {:ok, map} | {:error, any()}
def as_to_model_data(
%{"type" => "Todo", "actor" => actor_url, "todoList" => todo_list_url} = object
) do
with {:ok, %Actor{id: creator_id} = _creator} <-
ActivityPub.get_or_fetch_actor_by_url(actor_url),
{:todo_list, %TodoList{id: todo_list_id}} <-
{:todo_list, Todos.get_todo_list_by_url(todo_list_url)} do
%{
title: object["name"],
status: object["status"],
url: object["id"],
todo_list_id: todo_list_id,
creator_id: creator_id
}
else
{:todo_list, nil} ->
with {:ok, %TodoList{}} <- ActivityPub.fetch_object_from_url(todo_list_url) do
as_to_model_data(object)
end
end
end
end

View File

@@ -0,0 +1,53 @@
defmodule Mobilizon.Federation.ActivityStream.Converter.TodoList do
@moduledoc """
TodoList converter.
This module allows to convert todo lists from ActivityStream format to our own
internal one, and back.
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Todos.TodoList
@behaviour Converter
defimpl Convertible, for: TodoList do
alias Mobilizon.Federation.ActivityStream.Converter.TodoList, as: TodoListConverter
defdelegate model_to_as(report), to: TodoListConverter
end
@doc """
Convert an todo list struct to an ActivityStream representation
"""
@impl Converter
@spec model_to_as(TodoList.t()) :: map
def model_to_as(%TodoList{actor: %Actor{url: group_url} = _group} = todo_list) do
%{
"type" => "TodoList",
"actor" => group_url,
"id" => todo_list.url,
"title" => todo_list.title
}
end
@doc """
Converts an AP object data to our internal data structure.
"""
@impl Converter
@spec as_to_model_data(map) :: {:ok, map} | {:error, any()}
def as_to_model_data(%{"type" => "TodoList", "actor" => actor_url} = object) do
case ActivityPub.get_or_fetch_actor_by_url(actor_url) do
{:ok, %Actor{type: :Group, id: group_id} = _group} ->
%{
title: object["name"],
url: object["id"],
actor_id: group_id
}
_ ->
{:error, :group_not_found}
end
end
end

View File

@@ -16,6 +16,11 @@ defmodule Mobilizon.Federation.WebFinger do
require Jason
require Logger
@http_options [
follow_redirect: true,
ssl: [{:versions, [:"tlsv1.2"]}]
]
def host_meta do
base_url = Endpoint.url()
@@ -116,7 +121,7 @@ defmodule Mobilizon.Federation.WebFinger do
HTTPoison.get(
address,
[Accept: "application/json, application/activity+json, application/jrd+json"],
follow_redirect: true
@http_options
),
%{status_code: status_code, body: body} when status_code in 200..299 <- response,
{:ok, doc} <- Jason.decode(body) do

View File

@@ -3,7 +3,7 @@ defmodule Mobilizon.GraphQL.API.Comments do
API for Comments.
"""
alias Mobilizon.Events.Comment
alias Mobilizon.Conversations.Comment
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.Activity
@@ -18,6 +18,10 @@ defmodule Mobilizon.GraphQL.API.Comments do
ActivityPub.create(:comment, args, true)
end
def update_comment(%Comment{} = comment, args) do
ActivityPub.update(:comment, comment, args, true)
end
@doc """
Deletes a comment

View File

@@ -9,8 +9,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
alias Mobilizon.Actors.Actor
alias Mobilizon.Admin.{ActionLog, Setting}
alias Mobilizon.Config
alias Mobilizon.Events.{Comment, Event}
alias Mobilizon.Events.{Comment, Event}
alias Mobilizon.Conversations.Comment
alias Mobilizon.Events.Event
alias Mobilizon.Federation.ActivityPub.Relay
alias Mobilizon.Reports.{Note, Report}
alias Mobilizon.Service.Statistics

View File

@@ -3,10 +3,10 @@ defmodule Mobilizon.GraphQL.Resolvers.Comment do
Handles the comment-related GraphQL calls.
"""
alias Mobilizon.{Actors, Admin, Events}
alias Mobilizon.{Actors, Admin, Conversations}
alias Mobilizon.Actors.Actor
alias Mobilizon.Events
alias Mobilizon.Events.Comment, as: CommentModel
alias Mobilizon.Conversations.Comment, as: CommentModel
alias Mobilizon.Users
alias Mobilizon.Users.User
alias Mobilizon.GraphQL.API.Comments
@@ -14,13 +14,17 @@ defmodule Mobilizon.GraphQL.Resolvers.Comment do
require Logger
def get_thread(_parent, %{id: thread_id}, _context) do
{:ok, Events.get_thread_replies(thread_id)}
{:ok, Conversations.get_thread_replies(thread_id)}
end
def create_comment(
_parent,
%{actor_id: actor_id} = args,
%{context: %{current_user: %User{} = user}}
%{
context: %{
current_user: %User{} = user
}
}
) do
with {:is_owned, %Actor{} = _organizer_actor} <- User.owns_actor(user, actor_id),
{:ok, _, %CommentModel{} = comment} <-
@@ -36,14 +40,40 @@ defmodule Mobilizon.GraphQL.Resolvers.Comment do
{:error, "You are not allowed to create a comment if not connected"}
end
def update_comment(
_parent,
%{text: text, comment_id: comment_id},
%{
context: %{
current_user: %User{} = user
}
}
) do
with {:actor, %Actor{id: actor_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
%CommentModel{actor_id: comment_actor_id} = comment <-
Mobilizon.Conversations.get_comment(comment_id),
true <- actor_id === comment_actor_id,
{:ok, _, %CommentModel{} = comment} <- Comments.update_comment(comment, %{text: text}) do
{:ok, comment}
end
end
def edit_comment(_parent, _args, _context) do
{:error, "You are not allowed to update a comment if not connected"}
end
def delete_comment(
_parent,
%{actor_id: actor_id, comment_id: comment_id},
%{context: %{current_user: %User{role: role} = user}}
%{
context: %{
current_user: %User{role: role} = user
}
}
) do
with {actor_id, ""} <- Integer.parse(actor_id),
{:is_owned, %Actor{} = _organizer_actor} <- User.owns_actor(user, actor_id),
%CommentModel{} = comment <- Events.get_comment_with_preload(comment_id) do
%CommentModel{} = comment <- Conversations.get_comment_with_preload(comment_id) do
cond do
{:comment_can_be_managed, true} == CommentModel.can_be_managed_by(comment, actor_id) ->
do_delete_comment(comment)

View File

@@ -100,7 +100,9 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do
endpoint: Config.instance_maps_tiles_endpoint(),
attribution: Config.instance_maps_tiles_attribution()
}
}
},
resource_providers: Config.instance_resource_providers(),
timezones: Tzdata.zone_list()
}
end
end

View File

@@ -0,0 +1,110 @@
defmodule Mobilizon.GraphQL.Resolvers.Conversation do
@moduledoc """
Handles the group-related GraphQL calls.
"""
alias Mobilizon.{Actors, Conversations, Users}
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Conversation, as: ConversationModel
alias Mobilizon.Storage.Page
alias Mobilizon.Users.User
def find_conversations_for_actor(
%Actor{id: group_id},
_args,
%{
context: %{
current_user: %User{} = user
}
}
) do
with {:actor, %Actor{id: actor_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)} do
{:ok, Conversations.find_conversations_for_actor(group_id)}
else
{:member, false} ->
{:ok, %Page{total: 0, elements: []}}
end
end
def find_conversations_for_actor(%Actor{}, _args, _resolution) do
{:ok, %Page{total: 0, elements: []}}
end
def get_conversation(_parent, %{id: id}, _resolution) do
{:ok, Conversations.get_conversation(id)}
end
def get_comments_for_conversation(
%ConversationModel{id: conversation_id},
%{page: page, limit: limit},
_resolution
) do
{:ok, Conversations.get_comments_for_conversation(conversation_id, page, limit)}
end
def create_conversation(
_parent,
%{title: title, text: text, actor_id: actor_id, creator_id: creator_id},
_resolution
) do
with {:ok, %ConversationModel{} = conversation} <-
Conversations.create_conversation(%{
title: title,
text: text,
actor_id: actor_id,
creator_id: creator_id
}) do
{:ok, conversation}
end
end
def reply_to_conversation(
_parent,
%{text: text, conversation_id: conversation_id},
%{
context: %{
current_user: %User{} = user
}
}
) do
with {:actor, %Actor{id: actor_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
{:no_conversation, %ConversationModel{} = conversation} <-
{:no_conversation, Conversations.get_conversation(conversation_id)},
{:ok, %ConversationModel{} = conversation} <-
Conversations.reply_to_conversation(
conversation,
%{
text: text,
actor_id: actor_id
}
) do
{:ok, conversation}
end
end
@spec update_conversation(map(), map(), map()) :: {:ok, ConversationModel.t()}
def update_conversation(
_parent,
%{title: title, conversation_id: conversation_id},
%{
context: %{
current_user: %User{} = user
}
}
) do
with {:actor, %Actor{id: actor_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
{:no_conversation, %ConversationModel{creator_id: creator_id} = conversation} <-
{:no_conversation, Conversations.get_conversation(conversation_id)},
{:check_access, true} <- {:check_access, actor_id == creator_id},
{:ok, %ConversationModel{} = conversation} <-
Conversations.update_conversation(
conversation,
%{
title: title
}
) do
{:ok, conversation}
end
end
end

View File

@@ -3,23 +3,50 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
Handles the group-related GraphQL calls.
"""
alias Mobilizon.Actors
alias Mobilizon.{Actors, Events, Users}
alias Mobilizon.Actors.{Actor, Member}
alias Mobilizon.Users.User
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.GraphQL.API
alias Mobilizon.GraphQL.Resolvers.Person
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Storage.Page
alias Mobilizon.Users.User
require Logger
@doc """
Find a group
"""
def find_group(
parent,
%{preferred_username: name} = args,
%{
context: %{
current_user: %User{} = user
}
}
) do
with {:ok, %Actor{id: group_id} = group} <-
ActivityPub.find_or_make_group_from_nickname(name),
{:actor, %Actor{id: actor_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
group <- Person.proxify_pictures(group) do
{:ok, group}
else
{:member, false} ->
find_group(parent, args, nil)
_ ->
{:error, "Group with name #{name} not found"}
end
end
@doc """
Find a group
"""
def find_group(_parent, %{preferred_username: name}, _resolution) do
with {:ok, actor} <- ActivityPub.find_or_make_group_from_nickname(name),
actor <- Person.proxify_pictures(actor) do
%Actor{} = actor <- Person.proxify_pictures(actor),
%Actor{} = actor <- restrict_fields_for_non_member_request(actor) do
{:ok, actor}
else
_ ->
@@ -31,18 +58,21 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
Lists all groups
"""
def list_groups(_parent, %{page: page, limit: limit}, _resolution) do
{
:ok,
page
|> Actors.list_groups(limit)
|> Enum.map(fn actor -> Person.proxify_pictures(actor) end)
}
{:ok, Actors.list_groups(page, limit)}
end
@doc """
Create a new group. The creator is automatically added as admin
"""
def create_group(_parent, args, %{context: %{current_user: user}}) do
def create_group(
_parent,
args,
%{
context: %{
current_user: user
}
}
) do
with creator_actor_id <- Map.get(args, :creator_actor_id),
{:is_owned, %Actor{} = creator_actor} <- User.owns_actor(user, creator_actor_id),
args <- Map.put(args, :creator_actor, creator_actor),
@@ -68,14 +98,18 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
def delete_group(
_parent,
%{group_id: group_id, actor_id: actor_id},
%{context: %{current_user: user}}
%{
context: %{
current_user: user
}
}
) do
with {actor_id, ""} <- Integer.parse(actor_id),
{group_id, ""} <- Integer.parse(group_id),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
{:is_owned, %Actor{}} <- User.owns_actor(user, actor_id),
{:ok, %Member{} = member} <- Actors.get_member(actor_id, group.id),
{:is_admin, true} <- Member.is_administrator(member),
{:is_admin, true} <- {:is_admin, Member.is_administrator(member)},
group <- Actors.delete_group!(group) do
{:ok, %{id: group.id}}
else
@@ -103,7 +137,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
def join_group(
_parent,
%{group_id: group_id, actor_id: actor_id},
%{context: %{current_user: user}}
%{
context: %{
current_user: user
}
}
) do
with {actor_id, ""} <- Integer.parse(actor_id),
{group_id, ""} <- Integer.parse(group_id),
@@ -146,7 +184,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
def leave_group(
_parent,
%{group_id: group_id, actor_id: actor_id},
%{context: %{current_user: user}}
%{
context: %{
current_user: user
}
}
) do
with {actor_id, ""} <- Integer.parse(actor_id),
{group_id, ""} <- Integer.parse(group_id),
@@ -156,7 +198,17 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
{:only_administrator, check_that_member_is_not_last_administrator(group_id, actor_id)},
{:ok, _} <-
Mobilizon.Actors.delete_member(member) do
{:ok, %{parent: %{id: group_id}, actor: %{id: actor_id}}}
{
:ok,
%{
parent: %{
id: group_id
},
actor: %{
id: actor_id
}
}
}
else
{:is_owned, nil} ->
{:error, "Actor id is not owned by authenticated user"}
@@ -173,17 +225,65 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
{:error, "You need to be logged-in to leave a group"}
end
def find_events_for_group(
%Actor{id: group_id} = group,
_args,
%{
context: %{
current_user: %User{} = user
}
}
) do
with {:actor, %Actor{id: actor_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)} do
# TODO : Handle public / restricted to group members events
{:ok, Events.list_organized_events_for_group(group)}
else
{:member, false} ->
{:ok, %Page{total: 0, elements: []}}
end
end
def find_events_for_group(_parent, _args, _resolution) do
{:ok, %Page{total: 0, elements: []}}
end
# We check that the actor asking to leave the group is not it's only administrator
# We start by fetching the list of administrator or creators and if there's only one of them
# and that it's the actor requesting leaving the group we return true
@spec check_that_member_is_not_last_administrator(integer, integer) :: boolean
defp check_that_member_is_not_last_administrator(group_id, actor_id) do
case Actors.list_administrator_members_for_group(group_id) do
[%Member{actor: %Actor{id: member_actor_id}}] ->
%Page{total: total} when total > 1 ->
true
%Page{
total: 1,
elements: [
%Member{
actor: %Actor{
id: member_actor_id
}
}
]
} ->
actor_id == member_actor_id
_ ->
false
end
end
defp restrict_fields_for_non_member_request(%Actor{} = group) do
Map.merge(
group,
%{
followers: [],
followings: [],
organized_events: [],
comments: [],
feed_tokens: []
}
)
end
end

View File

@@ -3,14 +3,92 @@ defmodule Mobilizon.GraphQL.Resolvers.Member do
Handles the member-related GraphQL calls
"""
alias Mobilizon.Actors
alias Mobilizon.Actors.{Actor}
alias Mobilizon.{Actors, Users}
alias Mobilizon.Actors.{Actor, Member}
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Storage.Page
alias Mobilizon.Users.User
@doc """
Find members for group
Find members for group.
If actor requesting is not part of the group, we only return the number of members, not members
"""
def find_members_for_group(%Actor{} = actor, _args, _resolution) do
members = Actors.list_members_for_group(actor)
{:ok, members}
def find_members_for_group(
%Actor{id: group_id} = group,
_args,
%{
context: %{current_user: %User{} = user}
} = _resolution
) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
%Page{} = page <- Actors.list_members_for_group(group) do
{:ok, page}
else
{:member, false} ->
# Actor is not member of group, fallback to public
with %Page{} = page <- Actors.list_members_for_group(group) do
{:ok, %Page{page | elements: []}}
end
end
end
def find_members_for_group(%Actor{} = group, _args, _resolution) do
with %Page{} = page <- Actors.list_members_for_group(group) do
{:ok, %Page{page | elements: []}}
end
end
def invite_member(
_parent,
%{group_id: group_id, target_actor_username: target_actor_username},
%{context: %{current_user: %User{} = user}}
) do
with %Actor{id: actor_id} = actor <- Users.get_actor_for_user(user),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
{:has_rights_to_invite, {:ok, %Member{role: role}}}
when role in [:moderator, :administrator, :creator] <-
{:has_rights_to_invite, Actors.get_member(actor_id, group_id)},
{:target_actor_username, {:ok, %Actor{id: target_actor_id} = target_actor}} <-
{:target_actor_username,
ActivityPub.find_or_make_actor_from_nickname(target_actor_username)},
{:error, :member_not_found} <- Actors.get_member(target_actor_id, group.id),
{:ok, _activity, %Member{} = member} <- ActivityPub.invite(group, actor, target_actor) do
{:ok, member}
else
{:is_owned, nil} ->
{:error, "Actor id is not owned by authenticated user"}
{:error, :group_not_found} ->
{:error, "Group id not found"}
{:target_actor_username, _} ->
{:error, "Actor invited doesn't exist"}
{:has_rights_to_invite, {:error, :member_not_found}} ->
{:error, "You are not a member of this group"}
{:has_rights_to_invite, _} ->
{:error, "You cannot invite to this group"}
{:ok, %Member{}} ->
{:error, "Actor is already a member of this group"}
end
end
def accept_invitation(_parent, %{id: member_id}, %{context: %{current_user: %User{} = user}}) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
%Member{actor: %Actor{id: member_actor_id}} = member <- Actors.get_member(member_id),
{:is_same_actor, true} <- {:is_same_actor, member_actor_id === actor_id},
{:ok, _activity, %Member{} = member} <-
ActivityPub.accept(
:invite,
member,
true
) do
# Launch an async task to refresh the group profile, fetch resources, discussions, members
{:ok, member}
end
end
end

View File

@@ -105,7 +105,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do
@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
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),

View File

@@ -224,6 +224,19 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
end
end
@doc """
Returns the list of events this person is going to
"""
def person_memberships(%Actor{id: actor_id}, _args, %{context: %{current_user: user}}) do
with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
participations <- Actors.list_members_for_actor(actor) do
{:ok, participations}
else
{:is_owned, nil} ->
{:error, "Actor id is not owned by authenticated user"}
end
end
def proxify_pictures(%Actor{} = actor) do
actor
|> proxify_avatar

View File

@@ -0,0 +1,230 @@
defmodule Mobilizon.GraphQL.Resolvers.Resource do
@moduledoc """
Handles the resources-related GraphQL calls
"""
alias Mobilizon.{Actors, Resources, Users}
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Resources.Resource
alias Mobilizon.Resources.Resource.Metadata
alias Mobilizon.Service.RichMedia.Parser
alias Mobilizon.Storage.Page
alias Mobilizon.Users.User
require Logger
@doc """
Find resources for group.
Returns only if actor requesting is a member of the group
"""
def find_resources_for_group(
%Actor{id: group_id} = group,
%{page: page, limit: limit},
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
%Page{} = page <- Resources.get_resources_for_group(group, page, limit) do
{:ok, page}
else
{:member, _} ->
find_resources_for_group(nil, nil, nil)
end
end
def find_resources_for_group(
_group,
_args,
_resolution
) do
{:ok, %Page{total: 0, elements: []}}
end
def find_resources_for_parent(
%Resource{actor_id: group_id} = parent,
_args,
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
%Page{} = page <- Resources.get_resources_for_folder(parent) do
{:ok, page}
end
end
def find_resources_for_parent(_parent, _args, _resolution),
do: {:ok, %Page{total: 0, elements: []}}
def get_resource(
_parent,
%{path: path, username: username},
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with {:current_actor, %Actor{id: actor_id}} <-
{:current_actor, Users.get_actor_for_user(user)},
{:group, %Actor{id: group_id}} <- {:group, Actors.get_actor_by_name(username, :Group)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:resource, %Resource{} = resource} <-
{:resource, Resources.get_resource_by_group_and_path_with_preloads(group_id, path)} do
{:ok, resource}
else
{:member, false} -> {:error, "Actor is not member of group"}
{:resource, _} -> {:error, "No such resource"}
end
end
def get_resource(_parent, _args, _resolution) do
{:error, "You need to be logged-in to access resources"}
end
def create_resource(
_parent,
%{actor_id: group_id} = args,
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
parent <- get_eventual_parent(args),
{:own_check, true} <- {:own_check, check_resource_owned_by_group(parent, group_id)},
{:ok, _, %Resource{} = resource} <-
ActivityPub.create(
:resource,
args
|> Map.put(:actor_id, group_id)
|> Map.put(:creator_id, actor_id),
true,
%{}
) do
{:ok, resource}
else
{:own_check, _} ->
{:error, "Parent resource doesn't match this group"}
{:member, _} ->
{:error, "Actor id is not member of group"}
end
end
def create_resource(_parent, _args, _resolution) do
{:error, "You need to be logged-in to create resources"}
end
def update_resource(
_parent,
%{id: resource_id} = args,
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
{:resource, %Resource{actor_id: group_id} = resource} <-
{:resource, Resources.get_resource_with_preloads(resource_id)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:ok, _, %Resource{} = resource} <-
ActivityPub.update(:resource, resource, args, true, %{}) do
{:ok, resource}
else
{:resource, _} ->
{:error, "Resource doesn't exist"}
{:member, _} ->
{:error, "Actor id is not member of group"}
end
end
def update_resource(_parent, _args, _resolution) do
{:error, "You need to be logged-in to update resources"}
end
def delete_resource(
_parent,
%{id: resource_id},
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
{:resource, %Resource{parent_id: _parent_id, actor_id: group_id} = resource} <-
{:resource, Resources.get_resource_with_preloads(resource_id)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:ok, _, %Resource{} = resource} <-
ActivityPub.delete(resource) do
{:ok, resource}
else
{:resource, _} ->
{:error, "Resource doesn't exist"}
{:member, _} ->
{:error, "Actor id is not member of group"}
end
end
def delete_resource(_parent, _args, _resolution) do
{:error, "You need to be logged-in to delete resources"}
end
def preview_resource_link(
_parent,
%{resource_url: resource_url},
%{
context: %{
current_user: %User{} = _user
}
} = _resolution
) do
with {:ok, data} when is_map(data) <- Parser.parse(resource_url) do
{:ok, struct(Metadata, data)}
end
end
def preview_resource_link(_parent, _args, _resolution) do
{:error, "You need to be logged-in to view a resource preview"}
end
@spec get_eventual_parent(map()) :: Resource.t() | nil
defp get_eventual_parent(args) do
parent = args |> Map.get(:parent_id) |> get_parent_resource()
case parent do
%Resource{} -> parent
_ -> nil
end
end
@spec get_parent_resource(integer | nil) :: nil | Resource.t()
defp get_parent_resource(nil), do: nil
defp get_parent_resource(parent_id), do: Resources.get_resource(parent_id)
@spec check_resource_owned_by_group(Resource.t() | nil, integer) :: boolean
defp check_resource_owned_by_group(nil, _group_id), do: true
defp check_resource_owned_by_group(%Resource{actor_id: actor_id}, group_id)
when is_binary(group_id),
do: actor_id == String.to_integer(group_id)
defp check_resource_owned_by_group(%Resource{actor_id: actor_id}, group_id)
when is_number(group_id),
do: actor_id == group_id
end

View File

@@ -0,0 +1,255 @@
defmodule Mobilizon.GraphQL.Resolvers.Todos do
@moduledoc """
Handles the todos related GraphQL calls
"""
alias Mobilizon.{Actors, Todos, Users}
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Storage.Page
alias Mobilizon.Todos.{Todo, TodoList}
alias Mobilizon.Users.User
require Logger
@doc """
Find todo lists for group.
Returns only if actor requesting is a member of the group
"""
def find_todo_lists_for_group(
%Actor{id: group_id} = group,
_args,
%{
context: %{current_user: %User{} = user}
} = _resolution
) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
%Page{} = page <- Todos.get_todo_lists_for_group(group) do
{:ok, page}
else
{:member, _} ->
with %Page{} = page <- Todos.get_todo_lists_for_group(group) do
{:ok, %Page{page | elements: []}}
end
end
end
def find_todo_lists_for_group(_parent, _args, _resolution) do
{:ok, %Page{total: 0, elements: []}}
end
def find_todos_for_todo_list(
%TodoList{actor_id: group_id} = todo_list,
_args,
%{
context: %{current_user: %User{} = user}
} = _resolution
) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
%Page{} = page <- Todos.get_todos_for_todo_list(todo_list) do
{:ok, page}
else
{:is_owned, nil} ->
{:error, "Actor id is not owned by authenticated user"}
{:member, _} ->
{:error, "Actor id is not member of group"}
end
end
def get_todo_list(
_parent,
%{id: todo_list_id},
%{
context: %{current_user: %User{} = user}
} = _resolution
) do
with {:actor, %Actor{id: actor_id}} <- {:actor, Users.get_actor_for_user(user)},
{:todo, %TodoList{actor_id: group_id} = todo} <-
{:todo, Todos.get_todo_list(todo_list_id)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)} do
{:ok, todo}
else
{:todo, nil} ->
{:error, "Todo list doesn't exist"}
{:actor, nil} ->
{:error, "No actor found for user"}
{:member, _} ->
{:error, "Actor id is not member of group"}
end
end
def create_todo_list(
_parent,
%{group_id: group_id} = args,
%{
context: %{current_user: %User{} = user}
} = _resolution
) do
with {:actor, %Actor{id: actor_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:ok, _, %TodoList{} = todo_list} <-
ActivityPub.create(:todo_list, Map.put(args, :actor_id, group_id), true, %{}) do
{:ok, todo_list}
else
{:member, _} ->
{:error, "Actor id is not member of group"}
end
end
# def update_todo_list(
# _parent,
# %{id: todo_list_id, actor_id: actor_id},
# %{
# context: %{current_user: %User{} = user}
# } = _resolution
# ) do
# with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
# {:todo_list, %TodoList{actor_id: group_id} = todo_list} <-
# {:todo_list, Todos.get_todo_list(todo_list_id)},
# {:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
# {:ok, _, %TodoList{} = todo} <-
# ActivityPub.update_todo_list(todo_list, actor, true, %{}) do
# {:ok, todo}
# else
# {:todo_list, _} ->
# {:error, "TodoList doesn't exist"}
# {:member, _} ->
# {:error, "Actor id is not member of group"}
# end
# end
# def delete_todo_list(
# _parent,
# %{id: todo_list_id, actor_id: actor_id},
# %{
# context: %{current_user: %User{} = user}
# } = _resolution
# ) do
# with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
# {:todo_list, %TodoList{actor_id: group_id} = todo_list} <-
# {:todo_list, Todos.get_todo_list(todo_list_id)},
# {:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
# {:ok, _, %TodoList{} = todo} <-
# ActivityPub.delete_todo_list(todo_list, actor, true, %{}) do
# {:ok, todo}
# else
# {:todo_list, _} ->
# {:error, "TodoList doesn't exist"}
# {:member, _} ->
# {:error, "Actor id is not member of group"}
# end
# end
def get_todo(
_parent,
%{id: todo_id},
%{
context: %{current_user: %User{} = user}
} = _resolution
) do
with {:actor, %Actor{id: actor_id}} <- {:actor, Users.get_actor_for_user(user)},
{:todo, %Todo{todo_list_id: todo_list_id} = todo} <-
{:todo, Todos.get_todo(todo_id)},
{:todo_list, %TodoList{actor_id: group_id}} <-
{:todo_list, Todos.get_todo_list(todo_list_id)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)} do
{:ok, todo}
else
{:todo, nil} ->
{:error, "Todo doesn't exist"}
{:actor, nil} ->
{:error, "No actor found for user"}
{:member, _} ->
{:error, "Actor id is not member of group"}
end
end
def create_todo(
_parent,
%{todo_list_id: todo_list_id} = args,
%{
context: %{current_user: %User{} = user}
} = _resolution
) do
with {:actor, %Actor{id: actor_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
{:todo_list, %TodoList{actor_id: group_id} = _todo_list} <-
{:todo_list, Todos.get_todo_list(todo_list_id)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:ok, _, %Todo{} = todo} <-
ActivityPub.create(:todo, Map.put(args, :creator_id, actor_id), true, %{}) do
{:ok, todo}
else
{:todo_list, _} ->
{:error, "TodoList doesn't exist"}
{:member, _} ->
{:error, "Actor id is not member of group"}
end
end
def update_todo(
_parent,
%{id: todo_id} = args,
%{
context: %{current_user: %User{} = user}
} = _resolution
) do
with {:actor, %Actor{id: actor_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
{:todo, %Todo{todo_list_id: todo_list_id} = todo} <-
{:todo, Todos.get_todo(todo_id)},
{:todo_list, %TodoList{actor_id: group_id}} <-
{:todo_list, Todos.get_todo_list(todo_list_id)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:ok, _, %Todo{} = todo} <-
ActivityPub.update(:todo, todo, args, true, %{}) do
{:ok, todo}
else
{:todo_list, _} ->
{:error, "TodoList doesn't exist"}
{:todo, _} ->
{:error, "Todo doesn't exist"}
{:member, _} ->
{:error, "Actor id is not member of group"}
end
end
# def delete_todo(
# _parent,
# %{id: todo_id, actor_id: actor_id},
# %{
# context: %{current_user: %User{} = user}
# } = _resolution
# ) do
# with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
# {:todo, %Todo{todo_list_id: todo_list_id} = todo} <-
# {:todo, Todos.get_todo(todo_id)},
# {:todo_list, %TodoList{actor_id: group_id}} <-
# {:todo_list, Todos.get_todo_list(todo_list_id)},
# {:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
# {:ok, _, %Todo{} = todo} <-
# ActivityPub.delete_todo(todo, actor, true, %{}) do
# {:ok, todo}
# else
# {:todo_list, _} ->
# {:error, "TodoList doesn't exist"}
# {:todo, _} ->
# {:error, "Todo doesn't exist"}
# {:member, _} ->
# {:error, "Actor id is not member of group"}
# end
# end
end

View File

@@ -8,8 +8,8 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
alias Mobilizon.{Actors, Config, Events, Users}
alias Mobilizon.Actors.Actor
alias Mobilizon.Crypto
alias Mobilizon.Storage.Repo
alias Mobilizon.Users.User
alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Users.{Setting, User}
alias Mobilizon.Web.{Auth, Email}
@@ -245,7 +245,7 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
%{context: %{current_user: %User{id: logged_user_id}}}
) do
with true <- user_id == logged_user_id,
participations <-
%Page{} = page <-
Events.list_participations_for_user(
user_id,
Map.get(args, :after_datetime),
@@ -253,7 +253,26 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
Map.get(args, :page),
Map.get(args, :limit)
) do
{:ok, participations}
{:ok, page}
end
end
@doc """
Returns the list of groups this user is a member is a member of
"""
def user_memberships(
%User{id: user_id},
%{page: page, limit: limit} = _args,
%{context: %{current_user: %User{id: logged_user_id}}}
) do
with true <- user_id == logged_user_id,
memberships <-
Actors.list_memberships_for_user(
user_id,
page,
limit
) do
{:ok, memberships}
end
end
@@ -379,4 +398,42 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
def delete_account(_parent, _args, _resolution) do
{:error, "You need to be logged-in to delete your account"}
end
@spec user_settings(User.t(), map(), map()) :: {:ok, list(Setting.t())} | {:error, String.t()}
def user_settings(%User{id: user_id} = user, _args, %{
context: %{current_user: %User{id: logged_user_id}}
}) do
with {:same_user, true} <- {:same_user, user_id == logged_user_id},
{:setting, settings} <- {:setting, Users.get_setting(user)} do
{:ok, settings}
else
{:same_user, _} ->
{:error, "User requested is not logged-in"}
end
end
@spec set_user_setting(map(), map(), map()) :: {:ok, Setting.t()} | {:error, any()}
def set_user_setting(_parent, attrs, %{
context: %{current_user: %User{id: logged_user_id}}
}) do
attrs = Map.put(attrs, :user_id, logged_user_id)
res =
case Users.get_setting(logged_user_id) do
nil ->
Users.create_setting(attrs)
%Setting{} = setting ->
Users.update_setting(setting, attrs)
end
case res do
{:ok, %Setting{} = setting} ->
{:ok, setting}
{:error, changeset} ->
Logger.debug(inspect(changeset))
{:error, "Error while saving user setting"}
end
end
end

View File

@@ -5,9 +5,10 @@ defmodule Mobilizon.GraphQL.Schema do
use Absinthe.Schema
alias Mobilizon.{Actors, Addresses, Events, Media, Reports, Users}
alias Mobilizon.{Actors, Addresses, Conversations, Events, Media, Reports, Todos, Users}
alias Mobilizon.Actors.{Actor, Follower, Member}
alias Mobilizon.Events.{Comment, Event, Participant}
alias Mobilizon.Conversations.Comment
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.GraphQL.Schema
alias Mobilizon.Storage.Repo
@@ -22,8 +23,12 @@ defmodule Mobilizon.GraphQL.Schema do
import_types(Schema.Actors.PersonType)
import_types(Schema.Actors.GroupType)
import_types(Schema.Actors.ApplicationType)
import_types(Schema.CommentType)
import_types(Schema.Conversations.CommentType)
import_types(Schema.Conversations.ConversationType)
import_types(Schema.SearchType)
import_types(Schema.ResourceType)
import_types(Schema.Todos.TodoListType)
import_types(Schema.Todos.TodoType)
import_types(Schema.ConfigType)
import_types(Schema.ReportType)
import_types(Schema.AdminType)
@@ -98,10 +103,12 @@ defmodule Mobilizon.GraphQL.Schema do
Dataloader.new()
|> Dataloader.add_source(Actors, default_source)
|> Dataloader.add_source(Users, default_source)
|> Dataloader.add_source(Events, Events.data())
|> Dataloader.add_source(Events, default_source)
|> Dataloader.add_source(Conversations, Conversations.data())
|> Dataloader.add_source(Addresses, default_source)
|> Dataloader.add_source(Media, default_source)
|> Dataloader.add_source(Reports, default_source)
|> Dataloader.add_source(Todos, default_source)
Map.put(ctx, :loader, loader)
end
@@ -126,6 +133,10 @@ defmodule Mobilizon.GraphQL.Schema do
import_fields(:picture_queries)
import_fields(:report_queries)
import_fields(:admin_queries)
import_fields(:todo_list_queries)
import_fields(:todo_queries)
import_fields(:conversation_queries)
import_fields(:resource_queries)
end
@desc """
@@ -143,6 +154,10 @@ defmodule Mobilizon.GraphQL.Schema do
import_fields(:picture_mutations)
import_fields(:report_mutations)
import_fields(:admin_mutations)
import_fields(:todo_list_mutations)
import_fields(:todo_mutations)
import_fields(:conversation_mutations)
import_fields(:resource_mutations)
end
@desc """

View File

@@ -5,10 +5,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
use Absinthe.Schema.Notation
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.Events
alias Mobilizon.GraphQL.Resolvers.{Group, Member}
alias Mobilizon.GraphQL.Resolvers.{Conversation, Group, Member, Resource, Todos}
alias Mobilizon.GraphQL.Schema
import_types(Schema.Actors.MemberType)
@@ -44,10 +41,15 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
field(:followingCount, :integer, description: "Number of actors following this actor")
# This one should have a privacy setting
field(:organized_events, list_of(:event),
resolve: dataloader(Events),
description: "A list of the events this actor has organized"
)
field :organized_events, :paginated_event_list do
resolve(&Group.find_events_for_group/3)
description("A list of the events this actor has organized")
end
field :conversations, :paginated_conversation_list do
resolve(&Conversation.find_conversations_for_actor/3)
description("A list of the conversations for this group")
end
field(:types, :group_type, description: "The type of group : Group, Community,…")
@@ -55,10 +57,22 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
description: "Whether the group is opened to all or has restricted access"
)
field(:members, non_null(list_of(:member)),
resolve: &Member.find_members_for_group/3,
description: "List of group members"
)
field :members, :paginated_member_list do
resolve(&Member.find_members_for_group/3)
description("List of group members")
end
field :resources, :paginated_resource_list do
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
resolve(&Resource.find_resources_for_group/3)
description("A paginated list of the resources this group has")
end
field :todo_lists, :paginated_todo_list_list do
resolve(&Todos.find_todo_lists_for_group/3)
description("A paginated list of the todo lists this group has")
end
end
@desc """
@@ -80,9 +94,14 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
value(:open, description: "The actor is open to followings")
end
object :paginated_group_list do
field(:elements, list_of(:group), description: "A list of groups")
field(:total, :integer, description: "The total number of elements in the list")
end
object :group_queries do
@desc "Get all groups"
field :groups, list_of(:group) do
field :groups, :paginated_group_list do
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
resolve(&Group.list_groups/3)

View File

@@ -4,15 +4,27 @@ defmodule Mobilizon.GraphQL.Schema.Actors.MemberType do
"""
use Absinthe.Schema.Notation
alias Mobilizon.GraphQL.Resolvers.Group
alias Mobilizon.GraphQL.Resolvers.{Group, Member}
@desc """
Represents a member of a group
"""
object :member do
field(:id, :id, description: "The member's ID")
field(:parent, :group, description: "Of which the profile is member")
field(:actor, :person, description: "Which profile is member of")
field(:role, :integer, description: "The role of this membership")
field(:role, :member_role_enum, description: "The role of this membership")
field(:invited_by, :person, description: "Who invited this member")
end
enum :member_role_enum do
value(:not_approved)
value(:invited)
value(:member)
value(:moderator)
value(:administrator)
value(:creator)
value(:rejected)
end
@desc "Represents a deleted member"
@@ -21,6 +33,11 @@ defmodule Mobilizon.GraphQL.Schema.Actors.MemberType do
field(:actor, :deleted_object)
end
object :paginated_member_list do
field(:elements, list_of(:member), description: "A list of members")
field(:total, :integer, description: "The total number of elements in the list")
end
object :member_mutations do
@desc "Join a group"
field :join_group, :member do
@@ -30,12 +47,27 @@ defmodule Mobilizon.GraphQL.Schema.Actors.MemberType do
resolve(&Group.join_group/3)
end
@desc "Leave an event"
@desc "Leave a group"
field :leave_group, :deleted_member do
arg(:group_id, non_null(:id))
arg(:actor_id, non_null(:id))
resolve(&Group.leave_group/3)
end
@desc "Invite an actor to join the group"
field :invite_member, :member do
arg(:group_id, non_null(:id))
arg(:target_actor_username, non_null(:string))
resolve(&Member.invite_member/3)
end
@desc "Accept an invitation to a group"
field :accept_invitation, :member do
arg(:id, non_null(:id))
resolve(&Member.accept_invitation/3)
end
end
end

View File

@@ -64,6 +64,13 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
arg(:event_id, :id)
resolve(&Person.person_participations/3)
end
@desc "The list of group this person is member of"
field(:memberships, :paginated_member_list,
description: "The list of group this person is member of"
) do
resolve(&Person.person_memberships/3)
end
end
object :person_queries do

View File

@@ -5,7 +5,8 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
use Absinthe.Schema.Notation
alias Mobilizon.Events.{Comment, Event}
alias Mobilizon.Conversations.Comment
alias Mobilizon.Events.Event
alias Mobilizon.Reports.{Note, Report}
alias Mobilizon.GraphQL.Resolvers.Admin

View File

@@ -20,6 +20,8 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do
field(:geocoding, :geocoding)
field(:maps, :maps)
field(:anonymous, :anonymous)
field(:resource_providers, list_of(:resource_provider))
field(:timezones, list_of(:string))
field(:terms, :terms, description: "The instance's terms") do
arg(:locale, :string, default_value: "en")
@@ -97,6 +99,12 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do
field(:enabled, :boolean)
end
object :resource_provider do
field(:type, :string)
field(:endpoint, :string)
field(:software, :string)
end
object :config_queries do
@desc "Get the instance config"
field :config, :config do

View File

@@ -1,4 +1,4 @@
defmodule Mobilizon.GraphQL.Schema.CommentType do
defmodule Mobilizon.GraphQL.Schema.Conversations.CommentType do
@moduledoc """
Schema representation for Comment
"""
@@ -48,6 +48,11 @@ defmodule Mobilizon.GraphQL.Schema.CommentType do
value(:invite, description: "visible only to people invited")
end
object :paginated_comment_list do
field(:elements, list_of(:comment), description: "A list of comments")
field(:total, :integer, description: "The total number of comments in the list")
end
object :comment_queries do
@desc "Get replies for thread"
field :thread, type: list_of(:comment) do
@@ -67,6 +72,14 @@ defmodule Mobilizon.GraphQL.Schema.CommentType do
resolve(&Comment.create_comment/3)
end
@desc "Update a comment"
field :update_comment, type: :comment do
arg(:text, non_null(:string))
arg(:comment_id, non_null(:id))
resolve(&Comment.update_comment/3)
end
field :delete_comment, type: :comment do
arg(:comment_id, non_null(:id))
arg(:actor_id, non_null(:id))

View File

@@ -0,0 +1,74 @@
defmodule Mobilizon.GraphQL.Schema.Conversations.ConversationType do
@moduledoc """
Schema representation for Conversation
"""
use Absinthe.Schema.Notation
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.Actors
alias Mobilizon.GraphQL.Resolvers.Conversation
@desc "A conversation"
object :conversation do
field(:id, :id, description: "Internal ID for this conversation")
field(:title, :string)
field(:slug, :string)
field(:last_comment, :comment)
field :comments, :paginated_comment_list do
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
resolve(&Conversation.get_comments_for_conversation/3)
description("The comments for the conversation")
end
field(:creator, :person, resolve: dataloader(Actors))
field(:actor, :actor, resolve: dataloader(Actors))
field(:inserted_at, :datetime)
field(:updated_at, :datetime)
end
object :paginated_conversation_list do
field(:elements, list_of(:conversation), description: "A list of conversation")
field(:total, :integer, description: "The total number of comments in the list")
end
object :conversation_queries do
@desc "Get a conversation"
field :conversation, type: :conversation do
arg(:id, non_null(:id))
resolve(&Conversation.get_conversation/3)
end
end
object :conversation_mutations do
@desc "Create a conversation"
field :create_conversation, type: :conversation do
arg(:title, non_null(:string))
arg(:text, non_null(:string))
arg(:actor_id, non_null(:id))
arg(:creator_id, non_null(:id))
resolve(&Conversation.create_conversation/3)
end
field :reply_to_conversation, type: :conversation do
arg(:conversation_id, non_null(:id))
arg(:text, non_null(:string))
resolve(&Conversation.reply_to_conversation/3)
end
field :update_conversation, type: :conversation do
arg(:title, non_null(:string))
arg(:conversation_id, non_null(:id))
resolve(&Conversation.update_conversation/3)
end
field :delete_conversation, type: :conversation do
arg(:conversation_id, non_null(:id))
# resolve(&Conversation.delete_conversation/3)
end
end
end

View File

@@ -115,6 +115,11 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
value(:cancelled, description: "The event is cancelled")
end
object :paginated_event_list do
field(:elements, list_of(:event), description: "A list of events")
field(:total, :integer, description: "The total number of events in the list")
end
object :participant_stats do
field(:going, :integer,
description: "The number of approved participants",
@@ -201,6 +206,11 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
field(:show_start_time, :boolean, description: "Show event start time")
field(:show_end_time, :boolean, description: "Show event end time")
field(:hide_organizer_when_group_event, :boolean,
description:
"Whether to show or hide the person organizer when event is organized by a group"
)
end
input_object :event_options_input do
@@ -242,6 +252,11 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
field(:show_start_time, :boolean, description: "Show event start time")
field(:show_end_time, :boolean, description: "Show event end time")
field(:hide_organizer_when_group_event, :boolean,
description:
"Whether to show or hide the person organizer when event is organized by a group"
)
end
object :event_queries do
@@ -284,6 +299,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
arg(:online_address, :string)
arg(:phone_address, :string)
arg(:organizer_actor_id, non_null(:id))
arg(:attributed_to_id, :id)
arg(:category, :string, default_value: "meeting")
arg(:physical_address, :address_input)
arg(:options, :event_options_input)
@@ -314,6 +330,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
arg(:online_address, :string)
arg(:phone_address, :string)
arg(:organizer_actor_id, :id)
arg(:attributed_to_id, :id)
arg(:category, :string)
arg(:physical_address, :address_input)
arg(:options, :event_options_input)

View File

@@ -0,0 +1,95 @@
defmodule Mobilizon.GraphQL.Schema.ResourceType do
@moduledoc """
Schema representation for Resources
"""
use Absinthe.Schema.Notation
alias Mobilizon.GraphQL.Resolvers.Resource
@desc "A resource"
object :resource do
field(:id, :id, description: "The resource's ID")
field(:title, :string, description: "The resource's title")
field(:summary, :string, description: "The resource's summary")
field(:url, :string, description: "The resource's URL")
field(:resource_url, :string, description: "The resource's URL")
field(:metadata, :resource_metadata, description: "The resource's metadata")
field(:creator, :actor, description: "The resource's creator")
field(:actor, :actor, description: "The resource's owner")
field(:inserted_at, :naive_datetime, description: "The resource's creation date")
field(:updated_at, :naive_datetime, description: "The resource's last update date")
field(:type, :string, description: "The resource's type (if it's a folder)")
field(:path, :string, description: "The resource's path")
field(:parent, :resource, description: "The resource's parent")
field :children, :paginated_resource_list do
description("Children resources in folder")
resolve(&Resource.find_resources_for_parent/3)
end
end
object :paginated_resource_list do
field(:elements, list_of(:resource), description: "A list of resources")
field(:total, :integer, description: "The total number of resources in the list")
end
object :resource_metadata do
field(:type, :string, description: "The type of the resource")
field(:title, :string, description: "The resource's metadata title")
field(:description, :string, description: "The resource's metadata description")
field(:image_remote_url, :string, description: "The resource's metadata image")
field(:width, :integer)
field(:height, :integer)
field(:author_name, :string)
field(:author_url, :string)
field(:provider_name, :string)
field(:provider_url, :string)
field(:html, :string)
field(:favicon_url, :string)
end
object :resource_queries do
@desc "Get a resource"
field :resource, :resource do
arg(:path, non_null(:string))
arg(:username, non_null(:string))
resolve(&Resource.get_resource/3)
end
end
object :resource_mutations do
@desc "Create a resource"
field :create_resource, :resource do
arg(:parent_id, :id)
arg(:actor_id, non_null(:id))
arg(:title, non_null(:string))
arg(:summary, :string)
arg(:resource_url, :string)
arg(:type, :string, default_value: "link")
resolve(&Resource.create_resource/3)
end
@desc "Update a resource"
field :update_resource, :resource do
arg(:id, non_null(:id))
arg(:title, :string)
arg(:summary, :string)
arg(:parent_id, :id)
arg(:resource_url, :string)
resolve(&Resource.update_resource/3)
end
@desc "Delete a resource"
field :delete_resource, :deleted_object do
arg(:id, non_null(:id))
resolve(&Resource.delete_resource/3)
end
@desc "Get a preview for a resource link"
field :preview_resource_link, :resource_metadata do
arg(:resource_url, non_null(:string))
resolve(&Resource.preview_resource_link/3)
end
end
end

View File

@@ -0,0 +1,72 @@
defmodule Mobilizon.GraphQL.Schema.Todos.TodoType do
@moduledoc """
Schema representation for Todos
"""
use Absinthe.Schema.Notation
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.GraphQL.Resolvers.Todos, as: TodoResolver
alias Mobilizon.{Actors, Todos}
@desc "A todo"
object :todo do
field(:id, :id, description: "The todo's ID")
field(:title, :string, description: "The todo's title")
field(:status, :boolean, description: "The todo's status")
field(:due_date, :datetime, description: "The todo's due date")
field(:creator, :actor, resolve: dataloader(Actors), description: "The todo's creator")
field(:todo_list, :todo_list,
resolve: dataloader(Todos),
description: "The todo list this todo is attached to"
)
field(:assigned_to, :actor,
resolve: dataloader(Actors),
description: "The todos's assigned person"
)
end
object :paginated_todo_list do
field(:elements, list_of(:todo), description: "A list of todos")
field(:total, :integer, description: "The total number of todos in the list")
end
object :todo_queries do
@desc "Get a todo"
field :todo, :todo do
arg(:id, non_null(:id))
resolve(&TodoResolver.get_todo/3)
end
end
object :todo_mutations do
@desc "Create a todo"
field :create_todo, :todo do
arg(:todo_list_id, non_null(:id))
arg(:title, non_null(:string))
arg(:status, :boolean)
arg(:due_date, :datetime)
arg(:assigned_to_id, :id)
resolve(&TodoResolver.create_todo/3)
end
@desc "Update a todo"
field :update_todo, :todo do
arg(:id, non_null(:id))
arg(:todo_list_id, :id)
arg(:title, :string)
arg(:status, :boolean)
arg(:due_date, :datetime)
arg(:assigned_to_id, :id)
resolve(&TodoResolver.update_todo/3)
end
# @desc "Delete a todo"
# field :delete_todo, :deleted_object do
# arg(:id, non_null(:id))
# resolve(&TodoResolver.delete_todo/3)
# end
end
end

View File

@@ -0,0 +1,47 @@
defmodule Mobilizon.GraphQL.Schema.Todos.TodoListType do
@moduledoc """
Schema representation for Todo Lists
"""
use Absinthe.Schema.Notation
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.Actors
alias Mobilizon.GraphQL.Resolvers.Todos
@desc "A todo list"
object :todo_list do
field(:id, :id, description: "The todo list's ID")
field(:title, :string, description: "The todo list's title")
field(:actor, :actor,
resolve: dataloader(Actors),
description: "The actor that owns this todo list"
)
field(:todos, :paginated_todo_list,
resolve: &Todos.find_todos_for_todo_list/3,
description: "The todo-list's todos"
)
end
object :paginated_todo_list_list do
field(:elements, list_of(:todo_list), description: "A list of todo lists")
field(:total, :integer, description: "The total number of todo lists in the list")
end
object :todo_list_queries do
@desc "Get a todo list"
field :todo_list, :todo_list do
arg(:id, non_null(:id))
resolve(&Todos.get_todo_list/3)
end
end
object :todo_list_mutations do
@desc "Create a todo list"
field :create_todo_list, :todo_list do
arg(:title, non_null(:string))
arg(:group_id, non_null(:id))
resolve(&Todos.create_todo_list/3)
end
end
end

View File

@@ -51,7 +51,7 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
field(:locale, :string, description: "The user's locale")
field(:participations, list_of(:participant),
field(:participations, :paginated_participant_list,
description: "The list of participations this user has"
) do
arg(:after_datetime, :datetime)
@@ -61,11 +61,23 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
resolve(&User.user_participations/3)
end
field(:memberships, :paginated_member_list,
description: "The list of memberships for this user"
) do
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
resolve(&User.user_memberships/3)
end
field(:drafts, list_of(:event), description: "The list of draft events this user has created") do
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
resolve(&User.user_drafted_events/3)
end
field(:settings, :user_settings, description: "The list of settings for this user") do
resolve(&User.user_settings/3)
end
end
enum :user_role do
@@ -91,6 +103,22 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
value(:id)
end
object :user_settings do
field(:timezone, :string, description: "The timezone for this user")
field(:notification_on_day, :boolean,
description: "Whether this user will receive an email at the start of the day of an event."
)
field(:notification_each_week, :boolean,
description: "Whether this user will receive an weekly event recap"
)
field(:notification_before_event, :boolean,
description: "Whether this user will receive a notification right before event"
)
end
object :user_queries do
@desc "Get an user"
field :user, :user do
@@ -179,20 +207,31 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
resolve(&User.change_password/3)
end
@desc "Change an user email"
field :change_email, :user do
arg(:email, non_null(:string))
arg(:password, non_null(:string))
resolve(&User.change_email/3)
end
@desc "Validate an user email"
field :validate_email, :user do
arg(:token, non_null(:string))
resolve(&User.validate_email/3)
end
@desc "Delete an account"
field :delete_account, :deleted_object do
arg(:password, non_null(:string))
resolve(&User.delete_account/3)
end
field :set_user_settings, :user_settings do
arg(:timezone, :string)
arg(:notification_on_day, :boolean)
arg(:notification_each_week, :boolean)
arg(:notification_before_event, :boolean)
resolve(&User.set_user_setting/3)
end
end
end

View File

@@ -0,0 +1,17 @@
defmodule Mix.Tasks.Mobilizon.Groups do
@moduledoc """
Tasks to manage groups
"""
use Mix.Task
alias Mix.Tasks
@shortdoc "Manages Mobilizon groups"
@impl Mix.Task
def run(_) do
Mix.shell().info("\nAvailable tasks:")
Tasks.Help.run(["--search", "mobilizon.groups."])
end
end

View File

@@ -0,0 +1,27 @@
defmodule Mix.Tasks.Mobilizon.Groups.Refresh do
@moduledoc """
Task to refresh an actor details
"""
use Mix.Task
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub.Refresher
@shortdoc "Refresh a group private informations from an account member"
@impl Mix.Task
def run([group_url, on_behalf_of]) do
Mix.Task.run("app.start")
with %Actor{} = actor <- Actors.get_local_actor_by_name(on_behalf_of) do
res = Refresher.fetch_group(group_url, actor)
IO.puts(inspect(res))
end
end
def run(_) do
Mix.raise(
"mobilizon.groups.refresh requires a group URL and an actor username which is member of the group as arguments"
)
end
end

View File

@@ -39,8 +39,9 @@ defmodule Mobilizon do
[
# supervisors
Storage.Repo,
{Phoenix.PubSub, name: Mobilizon.PubSub},
Web.Endpoint,
{Absinthe.Subscription, [Web.Endpoint]},
{Absinthe.Subscription, Web.Endpoint},
{Oban, Application.get_env(:mobilizon, Oban)},
# workers
Guardian.DB.Token.SweeperServer,
@@ -55,6 +56,7 @@ defmodule Mobilizon do
),
cachex_spec(:statistics, 10, 60, 60),
cachex_spec(:config, 10, 60, 60),
cachex_spec(:rich_media_cache, 10, 60, 60),
cachex_spec(:activity_pub, 2500, 3, 15)
] ++
task_children(@env)

View File

@@ -9,7 +9,8 @@ defmodule Mobilizon.Actors.Actor do
alias Mobilizon.{Actors, Config, Crypto, Mention, Share}
alias Mobilizon.Actors.{ActorOpenness, ActorType, ActorVisibility, Follower, Member}
alias Mobilizon.Events.{Comment, Event, FeedToken}
alias Mobilizon.Conversations.Comment
alias Mobilizon.Events.{Event, FeedToken}
alias Mobilizon.Media.File
alias Mobilizon.Reports.{Note, Report}
alias Mobilizon.Users.User
@@ -67,7 +68,8 @@ defmodule Mobilizon.Actors.Actor do
:summary,
:manually_approves_followers,
:last_refreshed_at,
:user_id
:user_id,
:visibility
]
@attrs @required_attrs ++ @optional_attrs
@@ -92,15 +94,25 @@ defmodule Mobilizon.Actors.Actor do
:shared_inbox_url,
:following_url,
:followers_url,
:members_url,
:resources_url,
:name,
:summary,
:manually_approves_followers
:manually_approves_followers,
:visibility
]
@remote_actor_creation_attrs @remote_actor_creation_required_attrs ++
@remote_actor_creation_optional_attrs
@group_creation_required_attrs [:url, :outbox_url, :inbox_url, :type, :preferred_username]
@group_creation_optional_attrs [:shared_inbox_url, :name, :domain, :summary]
@group_creation_required_attrs [
:url,
:outbox_url,
:inbox_url,
:type,
:preferred_username,
:members_url
]
@group_creation_optional_attrs [:shared_inbox_url, :name, :domain, :summary, :visibility]
@group_creation_attrs @group_creation_required_attrs ++ @group_creation_optional_attrs
schema "actors" do
@@ -110,6 +122,9 @@ defmodule Mobilizon.Actors.Actor do
field(:following_url, :string)
field(:followers_url, :string)
field(:shared_inbox_url, :string)
field(:members_url, :string)
field(:resources_url, :string)
field(:todos_url, :string)
field(:type, ActorType, default: :Person)
field(:name, :string)
field(:domain, :string, default: nil)
@@ -274,18 +289,13 @@ defmodule Mobilizon.Actors.Actor do
def group_creation_changeset(%__MODULE__{} = actor, params) do
actor
|> cast(params, @group_creation_attrs)
|> cast_embed(:avatar)
|> cast_embed(:banner)
|> build_urls(:Group)
|> common_changeset()
|> put_change(:domain, nil)
|> put_change(:keys, Crypto.generate_rsa_2048_private_key())
|> put_change(:type, :Group)
|> unique_username_validator()
|> validate_required(@group_creation_required_attrs)
|> unique_constraint(:preferred_username,
name: :actors_preferred_username_domain_type_index
)
|> unique_constraint(:url, name: :actors_url_index)
|> validate_length(:summary, max: 5000)
|> validate_length(:preferred_username, max: 100)
end
@@ -309,13 +319,14 @@ defmodule Mobilizon.Actors.Actor do
@spec build_urls(Ecto.Changeset.t(), ActorType.t()) :: Ecto.Changeset.t()
defp build_urls(changeset, type \\ :Person)
defp build_urls(%Ecto.Changeset{changes: %{preferred_username: username}} = changeset, _type) do
defp build_urls(%Ecto.Changeset{changes: %{preferred_username: username}} = changeset, type) do
changeset
|> put_change(:outbox_url, build_url(username, :outbox))
|> put_change(:followers_url, build_url(username, :followers))
|> put_change(:following_url, build_url(username, :following))
|> put_change(:inbox_url, build_url(username, :inbox))
|> put_change(:shared_inbox_url, "#{Endpoint.url()}/inbox")
|> put_change(:members_url, if(type == :Group, do: build_url(username, :members), else: nil))
|> put_change(:url, build_url(username, :page))
end
@@ -333,14 +344,16 @@ defmodule Mobilizon.Actors.Actor do
def build_url("relay", :page, _args),
do: Endpoint |> Routes.activity_pub_url(:relay) |> URI.decode()
def build_url(preferred_username, :page, args) do
def build_url(preferred_username, endpoint, args) when endpoint in [:page, :resources] do
endpoint = if endpoint == :page, do: :actor, else: endpoint
Endpoint
|> Routes.page_url(:actor, preferred_username, args)
|> Routes.page_url(endpoint, preferred_username, args)
|> URI.decode()
end
def build_url(preferred_username, endpoint, args)
when endpoint in [:outbox, :following, :followers] do
when endpoint in [:outbox, :following, :followers, :members, :todos] do
Endpoint
|> Routes.activity_pub_url(endpoint, preferred_username, args)
|> URI.decode()

View File

@@ -43,11 +43,13 @@ defmodule Mobilizon.Actors do
])
defenum(MemberRole, :member_role, [
:invited,
:not_approved,
:member,
:moderator,
:administrator,
:creator
:creator,
:rejected
])
@public_visibility [:public, :unlisted]
@@ -341,9 +343,19 @@ defmodule Mobilizon.Actors do
"""
@spec create_group(map) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()}
def create_group(attrs \\ %{}) do
%Actor{}
|> Actor.group_creation_changeset(attrs)
|> Repo.insert()
with {:ok, %{insert_group: %Actor{} = group, add_admin_member: %Member{} = _admin_member}} <-
Multi.new()
|> Multi.insert(:insert_group, Actor.group_creation_changeset(%Actor{}, attrs))
|> Multi.insert(:add_admin_member, fn %{insert_group: group} ->
Member.changeset(%Member{}, %{
parent_id: group.id,
actor_id: attrs.creator_actor_id,
role: :administrator
})
end)
|> Repo.transaction() do
{:ok, group}
end
end
@doc """
@@ -354,11 +366,10 @@ defmodule Mobilizon.Actors do
@doc """
Lists the groups.
"""
@spec list_groups(integer | nil, integer | nil) :: [Actor.t()]
@spec list_groups(integer | nil, integer | nil) :: Page.t()
def list_groups(page \\ nil, limit \\ nil) do
groups_query()
|> Page.paginate(page, limit)
|> Repo.all()
|> Page.build_page(page, limit)
end
@doc """
@@ -371,6 +382,16 @@ defmodule Mobilizon.Actors do
|> Repo.all()
end
@doc """
Gets a single member.
"""
@spec get_member(integer | String.t()) :: Member.t() | nil
def get_member(id) do
Member
|> Repo.get(id)
|> Repo.preload([:actor, :parent, :invited_by])
end
@doc """
Gets a single member.
Raises `Ecto.NoResultsError` if the member does not exist.
@@ -393,6 +414,44 @@ defmodule Mobilizon.Actors do
end
end
@spec get_member(integer | String.t(), integer | String.t(), list()) ::
{:ok, Member.t()} | {:error, :member_not_found}
def get_member(actor_id, parent_id, roles) do
case Member
|> where([m], m.actor_id == ^actor_id and m.parent_id == ^parent_id and m.role in ^roles)
|> Repo.one() do
nil ->
{:error, :member_not_found}
member ->
{:ok, member}
end
end
@spec is_member?(integer | String.t(), integer | String.t()) :: boolean()
def is_member?(actor_id, parent_id) do
match?(
{:ok, %Member{}},
get_member(actor_id, parent_id, [
:member,
:moderator,
:administrator,
:creator
])
)
end
@doc """
Gets a single member of an actor (for example a group).
"""
@spec get_member_by_url(String.t()) :: Member.t() | nil
def get_member_by_url(url) do
Member
|> where(url: ^url)
|> preload([:actor, :parent, :invited_by])
|> Repo.one()
end
@doc """
Creates a member.
"""
@@ -402,7 +461,7 @@ defmodule Mobilizon.Actors do
%Member{}
|> Member.changeset(attrs)
|> Repo.insert() do
{:ok, Repo.preload(member, [:actor, :parent])}
{:ok, Repo.preload(member, [:actor, :parent, :invited_by])}
end
end
@@ -422,36 +481,69 @@ defmodule Mobilizon.Actors do
@spec delete_member(Member.t()) :: {:ok, Member.t()} | {:error, Ecto.Changeset.t()}
def delete_member(%Member{} = member), do: Repo.delete(member)
@doc """
Returns the list of memberships for an user.
Default behaviour is to not return :not_approved memberships
## Examples
iex> list_event_participations_for_user(5)
%Page{total: 3, elements: [%Participant{}, ...]}
"""
@spec list_memberships_for_user(
integer,
integer | nil,
integer | nil
) :: Page.t()
def list_memberships_for_user(user_id, page, limit) do
user_id
|> list_members_for_user_query()
|> Page.build_page(page, limit)
end
@doc """
Returns the list of members for an actor.
"""
@spec list_members_for_actor(Actor.t()) :: [Member.t()]
def list_members_for_actor(%Actor{id: actor_id}) do
@spec list_members_for_actor(Actor.t(), integer | nil, integer | nil) :: Page.t()
def list_members_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
actor_id
|> members_for_actor_query()
|> Repo.all()
|> Page.build_page(page, limit)
end
@doc """
Returns the list of members for a group.
"""
@spec list_members_for_group(Actor.t()) :: [Member.t()]
def list_members_for_group(%Actor{id: group_id, type: :Group}) do
@spec list_members_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
def list_members_for_group(%Actor{id: group_id, type: :Group}, page \\ nil, limit \\ nil) do
group_id
|> members_for_group_query()
|> Repo.all()
|> Page.build_page(page, limit)
end
@spec list_external_members_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
def list_external_members_for_group(
%Actor{id: group_id, type: :Group},
page \\ nil,
limit \\ nil
) do
group_id
|> members_for_group_query()
|> filter_external()
|> Page.build_page(page, limit)
end
@doc """
Returns the list of administrator members for a group.
"""
@spec list_administrator_members_for_group(integer | String.t(), integer | nil, integer | nil) ::
[Member.t()]
Page.t()
def list_administrator_members_for_group(id, page \\ nil, limit \\ nil) do
id
|> administrator_members_for_group_query()
|> Page.paginate(page, limit)
|> Repo.all()
|> Page.build_page(page, limit)
end
@doc """
@@ -909,6 +1001,17 @@ defmodule Mobilizon.Actors do
)
end
@spec list_members_for_user_query(integer()) :: Ecto.Query.t()
defp list_members_for_user_query(user_id) do
from(
m in Member,
join: a in Actor,
on: m.actor_id == a.id,
where: a.user_id == ^user_id and m.role != ^:not_approved,
preload: [:parent, :actor, :invited_by]
)
end
@spec members_for_actor_query(integer | String.t()) :: Ecto.Query.t()
defp members_for_actor_query(actor_id) do
from(

View File

@@ -8,6 +8,7 @@ defmodule Mobilizon.Actors.Member do
import Ecto.Changeset
alias Mobilizon.Actors.{Actor, MemberRole}
alias Mobilizon.Web.Endpoint
@type t :: %__MODULE__{
role: MemberRole.t(),
@@ -15,13 +16,21 @@ defmodule Mobilizon.Actors.Member do
actor: Actor.t()
}
@required_attrs [:parent_id, :actor_id]
@optional_attrs [:role]
@required_attrs [:parent_id, :actor_id, :url]
@optional_attrs [:role, :invited_by_id]
@attrs @required_attrs ++ @optional_attrs
@metadata_attrs []
@primary_key {:id, :binary_id, autogenerate: true}
schema "members" do
field(:role, MemberRole, default: :member)
field(:url, :string)
embeds_one :metadata, Metadata, on_replace: :delete do
# TODO : Use this space to put notes when someone is invited / requested to join
end
belongs_to(:invited_by, Actor)
belongs_to(:parent, Actor)
belongs_to(:actor, Actor)
@@ -44,16 +53,49 @@ defmodule Mobilizon.Actors.Member do
@doc """
Checks whether the member is an administrator (admin or creator) of the group.
"""
def is_administrator(%__MODULE__{role: :administrator}), do: {:is_admin, true}
def is_administrator(%__MODULE__{role: :creator}), do: {:is_admin, true}
def is_administrator(%__MODULE__{}), do: {:is_admin, false}
def is_administrator(%__MODULE__{role: :administrator}), do: true
def is_administrator(%__MODULE__{role: :creator}), do: true
def is_administrator(%__MODULE__{}), do: false
@doc false
@spec changeset(t, map) :: Ecto.Changeset.t()
def changeset(%__MODULE__{} = member, attrs) do
member
|> cast(attrs, @attrs)
|> cast_embed(:metadata, with: &metadata_changeset/2)
|> ensure_url()
|> validate_required(@required_attrs)
# On both parent_id and actor_id
|> unique_constraint(:parent_id, name: :members_actor_parent_unique_index)
|> unique_constraint(:url, name: :members_url_index)
end
defp metadata_changeset(schema, params) do
schema
|> cast(params, @metadata_attrs)
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
case fetch_change(changeset, :url) do
{:ok, _url} ->
changeset
:error ->
generate_url(changeset)
end
end
# Most time just go with the given URL
defp ensure_url(%Ecto.Changeset{} = changeset), do: changeset
@spec generate_url(Ecto.Changeset.t()) :: Ecto.Changeset.t()
defp generate_url(%Ecto.Changeset{} = changeset) do
uuid = Ecto.UUID.generate()
changeset
|> put_change(:id, uuid)
|> put_change(:url, "#{Endpoint.url()}/member/#{uuid}")
end
end

View File

@@ -139,6 +139,29 @@ defmodule Mobilizon.Config do
:enabled
]
def instance_resource_providers do
types = get_in(Application.get_env(:mobilizon, Mobilizon.Service.ResourceProviders), [:types])
providers =
get_in(Application.get_env(:mobilizon, Mobilizon.Service.ResourceProviders), [:providers])
providers_map = :maps.filter(fn key, _value -> key in Keyword.values(types) end, providers)
case Enum.count(providers_map) do
0 ->
[]
_ ->
Enum.map(providers_map, fn {key, value} ->
%{
type: key,
software: types |> Enum.find(fn {_key, val} -> val == key end) |> elem(0),
endpoint: value
}
end)
end
end
def anonymous_actor_id, do: get_cached_value(:anonymous_actor_id)
def relay_actor_id, do: get_cached_value(:relay_actor_id)

View File

@@ -1,4 +1,4 @@
defmodule Mobilizon.Events.Comment do
defmodule Mobilizon.Conversations.Comment do
@moduledoc """
Represents an actor comment (for instance on an event or on a group).
"""
@@ -8,7 +8,8 @@ defmodule Mobilizon.Events.Comment do
import Ecto.Changeset
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.{Comment, CommentVisibility, Event, Tag}
alias Mobilizon.Conversations.{Comment, CommentVisibility, Conversation}
alias Mobilizon.Events.{Event, Tag}
alias Mobilizon.Mention
alias Mobilizon.Web.Endpoint
@@ -40,7 +41,8 @@ defmodule Mobilizon.Events.Comment do
:origin_comment_id,
:attributed_to_id,
:deleted_at,
:local
:local,
:conversation_id
]
@attrs @required_attrs ++ @optional_attrs
@@ -58,6 +60,7 @@ defmodule Mobilizon.Events.Comment do
belongs_to(:event, Event, foreign_key: :event_id)
belongs_to(:in_reply_to_comment, Comment, foreign_key: :in_reply_to_comment_id)
belongs_to(:origin_comment, Comment, foreign_key: :origin_comment_id)
belongs_to(:conversation, Conversation)
has_many(:replies, Comment, foreign_key: :in_reply_to_comment_id)
many_to_many(:tags, Tag, join_through: "comments_tags", on_replace: :delete)
has_many(:mentions, Mention)
@@ -81,6 +84,14 @@ defmodule Mobilizon.Events.Comment do
|> validate_required(@creation_required_attrs)
end
def update_changeset(%__MODULE__{} = comment, attrs) do
comment
|> changeset(attrs)
# TODO handle comment edits
# |> put_change(:edits, comment.edits + 1)
end
@spec delete_changeset(t) :: Ecto.Changeset.t()
def delete_changeset(%__MODULE__{} = comment) do
comment

View File

@@ -0,0 +1,53 @@
defmodule Mobilizon.Conversations.Conversation.TitleSlug do
@moduledoc """
Module to generate the slug for conversations
"""
use EctoAutoslugField.Slug, from: :title, to: :slug
end
defmodule Mobilizon.Conversations.Conversation do
@moduledoc """
Represents a conversation
"""
use Ecto.Schema
import Ecto.Changeset
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Comment
alias Mobilizon.Conversations.Conversation.TitleSlug
@type t :: %__MODULE__{
creator: Actor.t(),
actor: Actor.t(),
title: String.t(),
slug: String.t(),
last_comment: Comment.t(),
comments: list(Comment.t())
}
@required_attrs [:actor_id, :creator_id, :title, :last_comment_id]
@optional_attrs []
@attrs @required_attrs ++ @optional_attrs
schema "conversations" do
field(:title, :string)
field(:slug, TitleSlug.Type)
belongs_to(:creator, Actor)
belongs_to(:actor, Actor)
belongs_to(:last_comment, Comment)
has_many(:comments, Comment, foreign_key: :conversation_id)
timestamps(type: :utc_datetime)
end
@doc false
@spec changeset(t, map) :: Ecto.Changeset.t()
def changeset(%__MODULE__{} = conversation, attrs) do
conversation
|> cast(attrs, @attrs)
|> validate_required(@required_attrs)
|> TitleSlug.maybe_generate_slug()
end
end

View File

@@ -0,0 +1,385 @@
defmodule Mobilizon.Conversations do
@moduledoc """
The conversations context
"""
import EctoEnum
import Ecto.Query
alias Ecto.Changeset
alias Ecto.Multi
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.{Comment, Conversation}
alias Mobilizon.Storage.{Page, Repo}
defenum(
CommentVisibility,
:comment_visibility,
[
:public,
:unlisted,
:private,
:moderated,
:invite
]
)
defenum(
CommentModeration,
:comment_moderation,
[
:allow_all,
:moderated,
:closed
]
)
@comment_preloads [
:actor,
:event,
:attributed_to,
:in_reply_to_comment,
:origin_comment,
:replies,
:tags,
:mentions
]
@conversation_preloads [
:last_comment,
:comments,
:creator,
:actor
]
@public_visibility [:public, :unlisted]
def data do
Dataloader.Ecto.new(Repo, query: &query/2)
end
@doc """
Query for comment dataloader
We only get first comment of thread, and count replies.
Read: https://hexdocs.pm/absinthe/ecto.html#dataloader
"""
def query(Comment, _params) do
Comment
|> join(:left, [c], r in Comment, on: r.origin_comment_id == c.id)
|> where([c, _], is_nil(c.in_reply_to_comment_id))
|> where([_, r], is_nil(r.deleted_at))
|> group_by([c], c.id)
|> select([c, r], %{c | total_replies: count(r.id)})
end
def query(queryable, _) do
queryable
end
@doc """
Gets a single comment.
"""
@spec get_comment(integer | String.t()) :: Comment.t() | nil
def get_comment(nil), do: nil
def get_comment(id), do: Repo.get(Comment, id)
@doc """
Gets a single comment.
Raises `Ecto.NoResultsError` if the comment does not exist.
"""
@spec get_comment!(integer | String.t()) :: Comment.t()
def get_comment!(id), do: Repo.get!(Comment, id)
def get_comment_with_preload(nil), do: nil
def get_comment_with_preload(id) do
Comment
|> where(id: ^id)
|> preload_for_comment()
|> Repo.one()
end
@doc """
Gets a comment by its URL.
"""
@spec get_comment_from_url(String.t()) :: Comment.t() | nil
def get_comment_from_url(url), do: Repo.get_by(Comment, url: url)
@doc """
Gets a comment by its URL.
Raises `Ecto.NoResultsError` if the comment does not exist.
"""
@spec get_comment_from_url!(String.t()) :: Comment.t()
def get_comment_from_url!(url), do: Repo.get_by!(Comment, url: url)
@doc """
Gets a comment by its URL, with all associations loaded.
"""
@spec get_comment_from_url_with_preload(String.t()) ::
{:ok, Comment.t()} | {:error, :comment_not_found}
def get_comment_from_url_with_preload(url) do
query = from(c in Comment, where: c.url == ^url)
comment =
query
|> preload_for_comment()
|> Repo.one()
case comment do
%Comment{} = comment ->
{:ok, comment}
nil ->
{:error, :comment_not_found}
end
end
@doc """
Gets a comment by its URL, with all associations loaded.
Raises `Ecto.NoResultsError` if the comment does not exist.
"""
@spec get_comment_from_url_with_preload(String.t()) :: Comment.t()
def get_comment_from_url_with_preload!(url) do
Comment
|> Repo.get_by!(url: url)
|> Repo.preload(@comment_preloads)
end
@doc """
Gets a comment by its UUID, with all associations loaded.
"""
@spec get_comment_from_uuid_with_preload(String.t()) :: Comment.t()
def get_comment_from_uuid_with_preload(uuid) do
Comment
|> Repo.get_by(uuid: uuid)
|> Repo.preload(@comment_preloads)
end
def get_threads(event_id) do
Comment
|> where([c, _], c.event_id == ^event_id and is_nil(c.origin_comment_id))
|> join(:left, [c], r in Comment, on: r.origin_comment_id == c.id)
|> group_by([c], c.id)
|> select([c, r], %{c | total_replies: count(r.id)})
|> Repo.all()
end
@doc """
Gets paginated replies for root comment
"""
@spec get_thread_replies(integer()) :: [Comment.t()]
def get_thread_replies(parent_id) do
parent_id
|> public_replies_for_thread_query()
|> Repo.all()
end
def get_or_create_comment(%{"url" => url} = attrs) do
case Repo.get_by(Comment, url: url) do
%Comment{} = comment -> {:ok, Repo.preload(comment, @comment_preloads)}
nil -> create_comment(attrs)
end
end
@doc """
Creates a comment.
"""
@spec create_comment(map) :: {:ok, Comment.t()} | {:error, Changeset.t()}
def create_comment(attrs \\ %{}) do
with {:ok, %Comment{} = comment} <-
%Comment{}
|> Comment.changeset(attrs)
|> Repo.insert(),
%Comment{} = comment <- Repo.preload(comment, @comment_preloads) do
{:ok, comment}
end
end
@doc """
Updates a comment.
"""
@spec update_comment(Comment.t(), map) :: {:ok, Comment.t()} | {:error, Changeset.t()}
def update_comment(%Comment{} = comment, attrs) do
comment
|> Comment.update_changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a comment
But actually just empty the fields so that threads are not broken.
"""
@spec delete_comment(Comment.t()) :: {:ok, Comment.t()} | {:error, Changeset.t()}
def delete_comment(%Comment{} = comment) do
comment
|> Comment.delete_changeset()
|> Repo.update()
end
@doc """
Returns the list of public comments.
"""
@spec list_comments :: [Comment.t()]
def list_comments do
Repo.all(from(c in Comment, where: c.visibility == ^:public))
end
@doc """
Returns the list of public comments for the actor.
"""
@spec list_public_comments_for_actor(Actor.t(), integer | nil, integer | nil) ::
{:ok, [Comment.t()], integer}
def list_public_comments_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
comments =
actor_id
|> public_comments_for_actor_query()
|> Page.paginate(page, limit)
|> Repo.all()
count_comments =
actor_id
|> count_comments_query()
|> Repo.one()
{:ok, comments, count_comments}
end
@doc """
Returns the list of comments by an actor and a list of ids.
"""
@spec list_comments_by_actor_and_ids(integer | String.t(), [integer | String.t()]) ::
[Comment.t()]
def list_comments_by_actor_and_ids(actor_id, comment_ids \\ [])
def list_comments_by_actor_and_ids(_actor_id, []), do: []
def list_comments_by_actor_and_ids(actor_id, comment_ids) do
Comment
|> where([c], c.id in ^comment_ids)
|> where([c], c.actor_id == ^actor_id)
|> Repo.all()
end
@spec get_comments_for_conversation(integer, integer | nil, integer | nil) :: Page.t()
def get_comments_for_conversation(conversation_id, page \\ nil, limit \\ nil) do
Comment
|> where([c], c.conversation_id == ^conversation_id)
|> order_by(asc: :inserted_at)
|> Page.build_page(page, limit)
end
@doc """
Counts local comments.
"""
@spec count_local_comments :: integer
def count_local_comments, do: Repo.one(count_local_comments_query())
def get_conversation(conversation_id) do
Conversation
|> Repo.get(conversation_id)
|> Repo.preload(@conversation_preloads)
end
@spec find_conversations_for_actor(integer, integer | nil, integer | nil) :: Page.t()
def find_conversations_for_actor(actor_id, page \\ nil, limit \\ nil) do
Conversation
|> where([c], c.actor_id == ^actor_id)
|> preload(^@conversation_preloads)
|> Page.build_page(page, limit)
end
@doc """
Creates a conversation.
"""
@spec create_conversation(map) :: {:ok, Comment.t()} | {:error, Changeset.t()}
def create_conversation(attrs \\ %{}) do
with {:ok, %{comment: %Comment{} = _comment, conversation: %Conversation{} = conversation}} <-
Multi.new()
|> Multi.insert(
:comment,
Comment.changeset(%Comment{}, Map.merge(attrs, %{actor_id: attrs.creator_id}))
)
|> Multi.insert(:conversation, fn %{comment: %Comment{id: comment_id}} ->
Conversation.changeset(
%Conversation{},
Map.merge(attrs, %{last_comment_id: comment_id})
)
end)
|> Multi.update(:comment_conversation, fn %{
comment: %Comment{} = comment,
conversation: %Conversation{
id: conversation_id
}
} ->
Changeset.change(comment, %{conversation_id: conversation_id})
end)
|> Repo.transaction() do
{:ok, conversation}
end
end
def reply_to_conversation(%Conversation{id: conversation_id} = conversation, attrs \\ %{}) do
with {:ok, %{comment: %Comment{} = comment, conversation: %Conversation{} = conversation}} <-
Multi.new()
|> Multi.insert(
:comment,
Comment.changeset(%Comment{}, Map.merge(attrs, %{conversation_id: conversation_id}))
)
|> Multi.update(:conversation, fn %{comment: %Comment{id: comment_id}} ->
Conversation.changeset(
conversation,
%{last_comment_id: comment_id}
)
end)
|> Repo.transaction() do
# For some reason conversation is not updated
{:ok, Map.put(conversation, :last_comment, comment)}
end
end
@doc """
Update a conversation. Only their title for now.
"""
@spec update_conversation(Conversation.t(), map()) ::
{:ok, Conversation.t()} | {:error, Changeset.t()}
def update_conversation(%Conversation{} = conversation, attrs \\ %{}) do
conversation
|> Conversation.changeset(attrs)
|> Repo.update()
end
defp public_comments_for_actor_query(actor_id) do
Comment
|> where([c], c.actor_id == ^actor_id and c.visibility in ^@public_visibility)
|> order_by([c], desc: :id)
|> preload_for_comment()
end
defp public_replies_for_thread_query(comment_id) do
Comment
|> where([c], c.origin_comment_id == ^comment_id and c.visibility in ^@public_visibility)
|> group_by([c], [c.in_reply_to_comment_id, c.id])
|> preload_for_comment()
end
@spec count_comments_query(integer) :: Ecto.Query.t()
defp count_comments_query(actor_id) do
from(c in Comment, select: count(c.id), where: c.actor_id == ^actor_id)
end
@spec count_local_comments_query :: Ecto.Query.t()
defp count_local_comments_query do
from(
c in Comment,
select: count(c.id),
where: c.local == ^true and c.visibility in ^@public_visibility
)
end
@spec preload_for_comment(Ecto.Query.t()) :: Ecto.Query.t()
defp preload_for_comment(query), do: preload(query, ^@comment_preloads)
# @spec preload_for_conversation(Ecto.Query.t()) :: Ecto.Query.t()
# defp preload_for_conversation(query), do: preload(query, ^@conversation_preloads)
end

View File

@@ -13,8 +13,9 @@ defmodule Mobilizon.Events.Event do
alias Mobilizon.{Addresses, Events, Media, Mention}
alias Mobilizon.Addresses.Address
alias Mobilizon.Conversations.Comment
alias Mobilizon.Events.{
Comment,
EventOptions,
EventParticipantStats,
EventStatus,
@@ -78,7 +79,8 @@ defmodule Mobilizon.Events.Event do
:online_address,
:phone_address,
:picture_id,
:physical_address_id
:physical_address_id,
:attributed_to_id
]
@attrs @required_attrs ++ @optional_attrs

View File

@@ -7,8 +7,9 @@ defmodule Mobilizon.Events.EventOptions do
import Ecto.Changeset
alias Mobilizon.Conversations.CommentModeration
alias Mobilizon.Events.{
CommentModeration,
EventOffer,
EventParticipationCondition
}
@@ -25,7 +26,8 @@ defmodule Mobilizon.Events.EventOptions do
offers: [EventOffer.t()],
participation_condition: [EventParticipationCondition.t()],
show_start_time: boolean,
show_end_time: boolean
show_end_time: boolean,
hide_organizer_when_group_event: boolean
}
@attrs [
@@ -38,7 +40,8 @@ defmodule Mobilizon.Events.EventOptions do
:comment_moderation,
:show_participation_price,
:show_start_time,
:show_end_time
:show_end_time,
:hide_organizer_when_group_event
]
@primary_key false
@@ -54,6 +57,7 @@ defmodule Mobilizon.Events.EventOptions do
field(:show_participation_price, :boolean)
field(:show_start_time, :boolean, default: true)
field(:show_end_time, :boolean, default: true)
field(:hide_organizer_when_group_event, :boolean, default: false)
embeds_many(:offers, EventOffer)
embeds_many(:participation_condition, EventParticipationCondition)

View File

@@ -16,7 +16,6 @@ defmodule Mobilizon.Events do
alias Mobilizon.Addresses.Address
alias Mobilizon.Events.{
Comment,
Event,
EventParticipantStats,
FeedToken,
@@ -61,20 +60,6 @@ defmodule Mobilizon.Events do
:meeting
])
defenum(CommentVisibility, :comment_visibility, [
:public,
:unlisted,
:private,
:moderated,
:invite
])
defenum(CommentModeration, :comment_moderation, [
:allow_all,
:moderated,
:closed
])
defenum(ParticipantRole, :participant_role, [
:not_approved,
:not_confirmed,
@@ -100,17 +85,6 @@ defmodule Mobilizon.Events do
:picture
]
@comment_preloads [
:actor,
:event,
:attributed_to,
:in_reply_to_comment,
:origin_comment,
:replies,
:tags,
:mentions
]
@doc """
Gets a single event.
"""
@@ -427,6 +401,14 @@ defmodule Mobilizon.Events do
{:ok, events, events_count}
end
@spec list_organized_events_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
def list_organized_events_for_group(%Actor{id: group_id}, page \\ nil, limit \\ nil) do
group_id
|> event_for_group_query()
|> preload_for_event()
|> Page.build_page(page, limit)
end
@spec list_drafts_for_user(integer, integer | nil, integer | nil) :: [Event.t()]
def list_drafts_for_user(user_id, page \\ nil, limit \\ nil) do
Event
@@ -796,13 +778,12 @@ defmodule Mobilizon.Events do
DateTime.t() | nil,
integer | nil,
integer | nil
) :: list(Participant.t())
) :: Page.t()
def list_participations_for_user(user_id, after_datetime, before_datetime, page, limit) do
user_id
|> list_participations_for_user_query()
|> participation_filter_begins_on(after_datetime, before_datetime)
|> Page.paginate(page, limit)
|> Repo.all()
|> Page.build_page(page, limit)
end
@doc """
@@ -1127,219 +1108,6 @@ defmodule Mobilizon.Events do
|> Repo.all()
end
def data do
Dataloader.Ecto.new(Repo, query: &query/2)
end
@doc """
Query for comment dataloader
We only get first comment of thread, and count replies.
Read: https://hexdocs.pm/absinthe/ecto.html#dataloader
"""
def query(Comment, _params) do
Comment
|> join(:left, [c], r in Comment, on: r.origin_comment_id == c.id)
|> where([c, _], is_nil(c.in_reply_to_comment_id))
|> where([_, r], is_nil(r.deleted_at))
|> group_by([c], c.id)
|> select([c, r], %{c | total_replies: count(r.id)})
end
def query(queryable, _) do
queryable
end
@doc """
Gets a single comment.
"""
@spec get_comment(integer | String.t()) :: Comment.t()
def get_comment(nil), do: nil
def get_comment(id), do: Repo.get(Comment, id)
@doc """
Gets a single comment.
Raises `Ecto.NoResultsError` if the comment does not exist.
"""
@spec get_comment!(integer | String.t()) :: Comment.t()
def get_comment!(id), do: Repo.get!(Comment, id)
def get_comment_with_preload(nil), do: nil
def get_comment_with_preload(id) do
Comment
|> where(id: ^id)
|> preload_for_comment()
|> Repo.one()
end
@doc """
Gets a comment by its URL.
"""
@spec get_comment_from_url(String.t()) :: Comment.t() | nil
def get_comment_from_url(url), do: Repo.get_by(Comment, url: url)
@doc """
Gets a comment by its URL.
Raises `Ecto.NoResultsError` if the comment does not exist.
"""
@spec get_comment_from_url!(String.t()) :: Comment.t()
def get_comment_from_url!(url), do: Repo.get_by!(Comment, url: url)
@doc """
Gets a comment by its URL, with all associations loaded.
"""
@spec get_comment_from_url_with_preload(String.t()) ::
{:ok, Comment.t()} | {:error, :comment_not_found}
def get_comment_from_url_with_preload(url) do
query = from(c in Comment, where: c.url == ^url)
comment =
query
|> preload_for_comment()
|> Repo.one()
case comment do
%Comment{} = comment ->
{:ok, comment}
nil ->
{:error, :comment_not_found}
end
end
@doc """
Gets a comment by its URL, with all associations loaded.
Raises `Ecto.NoResultsError` if the comment does not exist.
"""
@spec get_comment_from_url_with_preload(String.t()) :: Comment.t()
def get_comment_from_url_with_preload!(url) do
Comment
|> Repo.get_by!(url: url)
|> Repo.preload(@comment_preloads)
end
@doc """
Gets a comment by its UUID, with all associations loaded.
"""
@spec get_comment_from_uuid_with_preload(String.t()) :: Comment.t()
def get_comment_from_uuid_with_preload(uuid) do
Comment
|> Repo.get_by(uuid: uuid)
|> Repo.preload(@comment_preloads)
end
def get_threads(event_id) do
Comment
|> where([c, _], c.event_id == ^event_id and is_nil(c.origin_comment_id))
|> join(:left, [c], r in Comment, on: r.origin_comment_id == c.id)
|> group_by([c], c.id)
|> select([c, r], %{c | total_replies: count(r.id)})
|> Repo.all()
end
@doc """
Gets paginated replies for root comment
"""
@spec get_thread_replies(integer()) :: [Comment.t()]
def get_thread_replies(parent_id) do
parent_id
|> public_replies_for_thread_query()
|> Repo.all()
end
def get_or_create_comment(%{"url" => url} = attrs) do
case Repo.get_by(Comment, url: url) do
%Comment{} = comment -> {:ok, Repo.preload(comment, @comment_preloads)}
nil -> create_comment(attrs)
end
end
@doc """
Creates a comment.
"""
@spec create_comment(map) :: {:ok, Comment.t()} | {:error, Changeset.t()}
def create_comment(attrs \\ %{}) do
with {:ok, %Comment{} = comment} <-
%Comment{}
|> Comment.changeset(attrs)
|> Repo.insert(),
%Comment{} = comment <- Repo.preload(comment, @comment_preloads) do
{:ok, comment}
end
end
@doc """
Updates a comment.
"""
@spec update_comment(Comment.t(), map) :: {:ok, Comment.t()} | {:error, Changeset.t()}
def update_comment(%Comment{} = comment, attrs) do
comment
|> Comment.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a comment
But actually just empty the fields so that threads are not broken.
"""
@spec delete_comment(Comment.t()) :: {:ok, Comment.t()} | {:error, Changeset.t()}
def delete_comment(%Comment{} = comment) do
comment
|> Comment.delete_changeset()
|> Repo.update()
end
@doc """
Returns the list of public comments.
"""
@spec list_comments :: [Comment.t()]
def list_comments do
Repo.all(from(c in Comment, where: c.visibility == ^:public))
end
@doc """
Returns the list of public comments for the actor.
"""
@spec list_public_comments_for_actor(Actor.t(), integer | nil, integer | nil) ::
{:ok, [Comment.t()], integer}
def list_public_comments_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
comments =
actor_id
|> public_comments_for_actor_query()
|> Page.paginate(page, limit)
|> Repo.all()
count_comments =
actor_id
|> count_comments_query()
|> Repo.one()
{:ok, comments, count_comments}
end
@doc """
Returns the list of comments by an actor and a list of ids.
"""
@spec list_comments_by_actor_and_ids(integer | String.t(), [integer | String.t()]) ::
[Comment.t()]
def list_comments_by_actor_and_ids(actor_id, comment_ids \\ [])
def list_comments_by_actor_and_ids(_actor_id, []), do: []
def list_comments_by_actor_and_ids(actor_id, comment_ids) do
Comment
|> where([c], c.id in ^comment_ids)
|> where([c], c.actor_id == ^actor_id)
|> Repo.all()
end
@doc """
Counts local comments.
"""
@spec count_local_comments :: integer
def count_local_comments, do: Repo.one(count_local_comments_query())
@doc """
Gets a single feed token.
"""
@@ -1429,6 +1197,15 @@ defmodule Mobilizon.Events do
)
end
@spec event_for_group_query(integer | String.t()) :: Ecto.Query.t()
defp event_for_group_query(group_id) do
from(
e in Event,
where: e.attributed_to_id == ^group_id,
order_by: [desc: :id]
)
end
@spec upcoming_public_event_for_actor_query(integer | String.t()) :: Ecto.Query.t()
defp upcoming_public_event_for_actor_query(actor_id) do
from(
@@ -1656,20 +1433,6 @@ defmodule Mobilizon.Events do
from(s in Session, where: s.track_id == ^track_id)
end
defp public_comments_for_actor_query(actor_id) do
Comment
|> where([c], c.actor_id == ^actor_id and c.visibility in ^@public_visibility)
|> order_by([c], desc: :id)
|> preload_for_comment()
end
defp public_replies_for_thread_query(comment_id) do
Comment
|> where([c], c.origin_comment_id == ^comment_id and c.visibility in ^@public_visibility)
|> group_by([c], [c.in_reply_to_comment_id, c.id])
|> preload_for_comment()
end
@spec list_participants_for_event_query(String.t()) :: Ecto.Query.t()
defp list_participants_for_event_query(event_id) do
from(
@@ -1711,20 +1474,6 @@ defmodule Mobilizon.Events do
)
end
@spec count_comments_query(integer) :: Ecto.Query.t()
defp count_comments_query(actor_id) do
from(c in Comment, select: count(c.id), where: c.actor_id == ^actor_id)
end
@spec count_local_comments_query :: Ecto.Query.t()
defp count_local_comments_query do
from(
c in Comment,
select: count(c.id),
where: c.local == ^true and c.visibility in ^@public_visibility
)
end
@spec feed_token_query(String.t()) :: Ecto.Query.t()
defp feed_token_query(token) do
from(ftk in FeedToken, where: ftk.token == ^token, preload: [:actor, :user])
@@ -1825,6 +1574,17 @@ defmodule Mobilizon.Events do
|> participation_order_begins_on_desc()
end
defp participation_filter_begins_on(
query,
%DateTime{} = after_datetime,
%DateTime{} = before_datetime
) do
query
|> where([_p, e, _a], e.begins_on < ^before_datetime)
|> where([_p, e, _a], e.begins_on > ^after_datetime)
|> participation_order_begins_on_asc()
end
defp participation_order_begins_on_asc(query),
do: order_by(query, [_p, e, _a], asc: e.begins_on)
@@ -1833,7 +1593,4 @@ defmodule Mobilizon.Events do
@spec preload_for_event(Ecto.Query.t()) :: Ecto.Query.t()
defp preload_for_event(query), do: preload(query, ^@event_preloads)
@spec preload_for_comment(Ecto.Query.t()) :: Ecto.Query.t()
defp preload_for_comment(query), do: preload(query, ^@comment_preloads)
end

View File

@@ -6,7 +6,8 @@ defmodule Mobilizon.Mention do
use Ecto.Schema
import Ecto.Changeset
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.{Comment, Event}
alias Mobilizon.Conversations.Comment
alias Mobilizon.Events.Event
alias Mobilizon.Storage.Repo
@type t :: %__MODULE__{

View File

@@ -8,7 +8,8 @@ defmodule Mobilizon.Reports.Report do
import Ecto.Changeset
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.{Comment, Event}
alias Mobilizon.Conversations.Comment
alias Mobilizon.Events.Event
alias Mobilizon.Reports.{Note, ReportStatus}
alias Mobilizon.Web.Endpoint

View File

@@ -0,0 +1,104 @@
defmodule Mobilizon.Resources.Resource do
@moduledoc """
Represents a web resource
"""
use Ecto.Schema
import Ecto.Changeset
alias Ecto.Changeset
import Mobilizon.Storage.Ecto, only: [ensure_url: 2]
import EctoEnum
defenum(TypeEnum, folder: 0, link: 1, picture: 20, pad: 30, calc: 40, visio: 50)
alias Mobilizon.Actors.Actor
@type t :: %__MODULE__{
title: String.t(),
summary: String.t(),
url: String.t(),
resource_url: String.t(),
type: atom(),
metadata: Mobilizon.Resources.Resource.Metadata.t(),
children: list(__MODULE__),
parent: __MODULE__,
actor: Actor.t(),
creator: Actor.t(),
local: boolean
}
@primary_key {:id, :binary_id, autogenerate: true}
schema "resource" do
field(:summary, :string)
field(:title, :string)
field(:url, :string)
field(:resource_url, :string)
field(:type, TypeEnum)
field(:path, :string)
field(:local, :boolean, default: true)
embeds_one :metadata, Metadata, on_replace: :delete do
field(:type, :string)
field(:title, :string)
field(:description, :string)
field(:image_remote_url, :string)
field(:width, :integer)
field(:height, :integer)
field(:author_name, :string)
field(:author_url, :string)
field(:provider_name, :string)
field(:provider_url, :string)
field(:html, :string)
field(:favicon_url, :string)
end
has_many(:children, __MODULE__, foreign_key: :parent_id)
belongs_to(:parent, __MODULE__, type: :binary_id)
belongs_to(:actor, Actor)
belongs_to(:creator, Actor)
timestamps()
end
@required_attrs [:title, :url, :actor_id, :creator_id, :type, :path]
@optional_attrs [:summary, :parent_id, :resource_url, :local]
@attrs @required_attrs ++ @optional_attrs
@metadata_attrs [
:type,
:title,
:description,
:image_remote_url,
:width,
:height,
:author_name,
:author_url,
:provider_name,
:provider_url,
:html,
:favicon_url
]
@doc false
def changeset(resource, attrs) do
resource
|> cast(attrs, @attrs)
|> cast_embed(:metadata, with: &metadata_changeset/2)
|> ensure_url(:resource)
|> validate_resource_or_folder()
|> validate_required(@required_attrs)
|> unique_constraint(:url, name: :resource_url_index)
end
defp metadata_changeset(schema, params) do
schema
|> cast(params, @metadata_attrs)
end
@spec validate_resource_or_folder(Changeset.t()) :: Changeset.t()
defp validate_resource_or_folder(%Changeset{} = changeset) do
with {status, type} when status in [:changes, :data] <- fetch_field(changeset, :type),
true <- type != :folder do
validate_required(changeset, [:resource_url])
else
_ -> changeset
end
end
end

View File

@@ -0,0 +1,227 @@
defmodule Mobilizon.Resources do
@moduledoc """
The Resources context.
"""
alias Ecto.Adapters.SQL
alias Ecto.Multi
alias Ecto.UUID
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Resources.Resource
alias Mobilizon.Storage.{Page, Repo}
import Ecto.Query
require Logger
@resource_preloads [:actor, :creator, :children, :parent]
@doc """
Returns the list of recent resources for a group
"""
@spec get_resources_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
def get_resources_for_group(%Actor{id: group_id}, page \\ nil, limit \\ nil) do
Resource
|> where(actor_id: ^group_id)
|> order_by(desc: :updated_at)
|> Page.build_page(page, limit)
end
@doc """
Returns the list of top-level resources for a group
"""
def get_top_level_resources_for_group(%Actor{id: group_id}, page \\ nil, limit \\ nil) do
get_resources_for_folder(%Resource{id: "root_something", actor_id: group_id}, page, limit)
end
@doc """
Returns the list of resources for a resource folder.
"""
@spec get_resources_for_folder(Resource.t(), integer | nil, integer | nil) :: Page.t()
def get_resources_for_folder(resource, page \\ nil, limit \\ nil)
def get_resources_for_folder(
%Resource{id: "root_" <> _group_id, actor_id: group_id},
page,
limit
) do
Resource
|> where([r], r.actor_id == ^group_id and is_nil(r.parent_id))
|> order_by(asc: :type)
|> preload([r], [:actor, :creator])
|> Page.build_page(page, limit)
end
def get_resources_for_folder(%Resource{id: resource_id}, page, limit) do
Resource
|> where([r], r.parent_id == ^resource_id)
|> order_by(asc: :type)
|> Page.build_page(page, limit)
end
@doc """
Get a resource by it's ID
"""
@spec get_resource(integer | String.t()) :: Resource.t() | nil
def get_resource(nil), do: nil
def get_resource(id), do: Repo.get(Resource, id)
@spec get_resource_with_preloads(integer | String.t()) :: Resource.t() | nil
def get_resource_with_preloads(id) do
Resource
|> Repo.get(id)
|> Repo.preload(@resource_preloads)
end
@spec get_resource_by_group_and_path_with_preloads(String.t() | integer, String.t()) ::
Resource.t() | nil
def get_resource_by_group_and_path_with_preloads(group_id, "/") do
with {:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id) do
%Resource{
actor_id: group_id,
id: "root_#{group_id}",
actor: group,
path: "/",
title: "Root"
}
end
end
def get_resource_by_group_and_path_with_preloads(group_id, path) do
Resource
|> Repo.get_by(actor_id: group_id, path: path)
|> Repo.preload(@resource_preloads)
end
@doc """
Get a resource by it's URL
"""
@spec get_resource_by_url(String.t()) :: Resource.t() | nil
def get_resource_by_url(url), do: Repo.get_by(Resource, url: url)
@spec get_resource_by_url_with_preloads(String.t()) :: Resource.t() | nil
def get_resource_by_url_with_preloads(url) do
Resource
|> Repo.get_by(url: url)
|> Repo.preload(@resource_preloads)
end
@doc """
Creates a resource.
"""
@spec create_resource(map) :: {:ok, Resource.t()} | {:error, Ecto.Changeset.t()}
def create_resource(attrs \\ %{}) do
Multi.new()
|> do_find_parent_path(Map.get(attrs, :parent_id))
|> Multi.insert(:insert, fn %{find_parent_path: path} ->
Resource.changeset(%Resource{}, Map.put(attrs, :path, "#{path}/#{attrs.title}"))
end)
|> Repo.transaction()
|> case do
{:ok, %{insert: %Resource{} = resource}} ->
{:ok, resource}
{:error, operation, reason, _changes} ->
{:error, "Error while inserting resource when #{operation} because of #{inspect(reason)}"}
end
end
@doc """
Updates a resource.
Since a resource can be a folder and hold children, we do the following in a transaction:
* Get the parent path so that we can reconstruct the path for current resource (if moved or simply renamed)
* Update all children with the new parent path
* Update the resource path itself
"""
@spec update_resource(Resource.t(), map) :: {:ok, Resource.t()} | {:error, Ecto.Changeset.t()}
def update_resource(%Resource{title: old_title} = resource, attrs) do
Multi.new()
|> find_parent_path(resource, attrs)
|> update_children(resource, attrs)
|> Multi.update(:update, fn %{find_parent_path: path} ->
title = Map.get(attrs, :title, old_title)
Resource.changeset(resource, Map.put(attrs, :path, "#{path}/#{title}"))
end)
|> Repo.transaction()
|> case do
{:ok,
%{
find_parent_path: _parent_path,
update: %Resource{} = resource,
update_children: children
}} ->
resource = Map.put(resource, :children, children)
{:ok, resource}
# collect errors into record changesets
{:error, operation, reason, _changes} ->
{:error, "Error while updating resource when #{operation} because of #{inspect(reason)}"}
end
end
@spec find_parent_path(Multi.t(), Resource.t(), map()) :: Multi.t()
defp find_parent_path(
%Multi{} = multi,
%Resource{parent_id: old_parent_id} = _resource,
attrs
) do
updated_parent_id = Map.get(attrs, :parent_id, old_parent_id)
Logger.debug("Finding parent path for updated_parent_id #{inspect(updated_parent_id)}")
do_find_parent_path(multi, updated_parent_id)
end
@spec do_find_parent_path(Multi.t(), String.t() | nil) :: Multi.t()
defp do_find_parent_path(%Multi{} = multi, nil),
do: Multi.run(multi, :find_parent_path, fn _, _ -> {:ok, ""} end)
defp do_find_parent_path(%Multi{} = multi, parent_id) do
Multi.run(multi, :find_parent_path, fn _repo, _changes ->
case get_resource(parent_id) do
%Resource{path: path} = _resource -> {:ok, path}
_ -> {:error, :not_found}
end
end)
end
@spec update_children(Multi.t(), Resource.t(), map()) :: Multi.t()
defp update_children(
%Multi{} = multi,
%Resource{
id: id,
type: :folder,
title: old_title,
actor_id: actor_id
},
attrs
) do
title = Map.get(attrs, :title, old_title)
Multi.run(multi, :update_children, fn repo, %{find_parent_path: path} ->
{:ok, uuid} = UUID.dump(id)
{query, params} =
{"UPDATE resource SET path = CONCAT($1::text, title) WHERE actor_id = $2 AND parent_id = $3::uuid",
["#{path}/#{title}/", actor_id, uuid]}
{:ok, _} =
SQL.query(
repo,
query,
params
)
children = repo.all(from(r in Resource, where: r.parent_id == ^id))
{:ok, children}
end)
end
defp update_children(multi, _, _),
do: Multi.run(multi, :update_children, fn _, _ -> {:ok, ""} end)
@doc """
Deletes a resource
"""
@spec delete_resource(Resource.t()) :: {:ok, Resource.t()} | {:error, Ecto.Changeset.t()}
def delete_resource(%Resource{} = resource), do: Repo.delete(resource)
end

View File

@@ -4,6 +4,9 @@ defmodule Mobilizon.Storage.Ecto do
"""
import Ecto.Query, warn: false
import Ecto.Changeset, only: [fetch_change: 2, put_change: 3]
alias Mobilizon.Web.Endpoint
alias Mobilizon.Web.Router.Helpers, as: Routes
@doc """
Adds sort to the query.
@@ -12,4 +15,35 @@ defmodule Mobilizon.Storage.Ecto do
def sort(query, sort, direction) do
from(query, order_by: [{^direction, ^sort}])
end
@doc """
Ensure changeset contains an URL
If there's a blank URL that's because we're doing the first insert.
Most of the time just go with the given URL.
"""
@spec ensure_url(Ecto.Changeset.t(), atom()) :: Ecto.Changeset.t()
def ensure_url(%Ecto.Changeset{data: %{url: nil}} = changeset, route) do
case fetch_change(changeset, :url) do
{:ok, _url} ->
changeset
:error ->
generate_url(changeset, route)
end
end
def ensure_url(%Ecto.Changeset{} = changeset, _route), do: changeset
@spec generate_url(Ecto.Changeset.t(), atom()) :: Ecto.Changeset.t()
defp generate_url(%Ecto.Changeset{} = changeset, route) do
uuid = Ecto.UUID.generate()
changeset
|> put_change(:id, uuid)
|> put_change(
:url,
apply(Routes, String.to_existing_atom("#{to_string(route)}_url"), [Endpoint, route, uuid])
)
end
end

View File

@@ -0,0 +1,47 @@
defmodule Mobilizon.Todos.Todo do
@moduledoc """
Represents a todo, or task
"""
use Ecto.Schema
import Ecto.Changeset
import Mobilizon.Storage.Ecto, only: [ensure_url: 2]
alias Mobilizon.Actors.Actor
alias Mobilizon.Todos.TodoList
@type t :: %__MODULE__{
status: boolean(),
title: String.t(),
due_date: DateTime.t(),
todo_list: TodoList.t(),
creator: Actor.t(),
assigned_to: Actor.t(),
local: boolean
}
@primary_key {:id, :binary_id, autogenerate: true}
schema "todos" do
field(:status, :boolean, default: false)
field(:title, :string)
field(:url, :string)
field(:due_date, :utc_datetime)
field(:local, :boolean, default: true)
belongs_to(:todo_list, TodoList, type: :binary_id)
belongs_to(:creator, Actor)
belongs_to(:assigned_to, Actor)
timestamps()
end
@required_attrs [:title, :creator_id, :url, :todo_list_id]
@optional_attrs [:status, :due_date, :assigned_to_id, :local]
@attrs @required_attrs ++ @optional_attrs
@doc false
def changeset(todo, attrs) do
todo
|> cast(attrs, @attrs)
|> ensure_url(:todo)
|> validate_required(@required_attrs)
end
end

View File

@@ -0,0 +1,42 @@
defmodule Mobilizon.Todos.TodoList do
@moduledoc """
Represents a todo list, or task list
"""
use Ecto.Schema
import Ecto.Changeset
import Mobilizon.Storage.Ecto, only: [ensure_url: 2]
alias Mobilizon.Actors.Actor
alias Mobilizon.Todos.Todo
@type t :: %__MODULE__{
title: String.t(),
todos: [Todo.t()],
actor: Actor.t(),
local: boolean
}
@primary_key {:id, :binary_id, autogenerate: true}
schema "todo_lists" do
field(:title, :string)
field(:url, :string)
field(:local, :boolean, default: true)
belongs_to(:actor, Actor)
has_many(:todos, Todo)
timestamps()
end
@required_attrs [:title, :url, :actor_id]
@optional_attrs [:local]
@attrs @required_attrs ++ @optional_attrs
@doc false
def changeset(todo_list, attrs) do
todo_list
|> cast(attrs, @attrs)
|> ensure_url(:todo_list)
|> validate_required(@required_attrs)
end
end

View File

@@ -0,0 +1,109 @@
defmodule Mobilizon.Todos do
@moduledoc """
The Todos context.
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Todos.{Todo, TodoList}
import Ecto.Query
@doc """
Get a todo list by it's ID
"""
@spec get_todo_list(integer | String.t()) :: TodoList.t() | nil
def get_todo_list(id), do: Repo.get(TodoList, id)
@doc """
Get a todo list by it's URL
"""
@spec get_todo_list_by_url(String.t()) :: TodoList.t() | nil
def get_todo_list_by_url(url), do: Repo.get_by(TodoList, url: url)
@doc """
Returns the list of todo lists for a group.
"""
@spec get_todo_lists_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
def get_todo_lists_for_group(%Actor{id: group_id, type: :Group}, page \\ nil, limit \\ nil) do
TodoList
|> where(actor_id: ^group_id)
|> order_by(desc: :updated_at)
|> Page.build_page(page, limit)
end
@doc """
Returns the list of todos for a group.
"""
@spec get_todos_for_todo_list(TodoList.t(), integer | nil, integer | nil) :: Page.t()
def get_todos_for_todo_list(%TodoList{id: todo_list_id}, page \\ nil, limit \\ nil) do
Todo
|> where(todo_list_id: ^todo_list_id)
|> order_by(asc: :status)
# |> order_by(desc: :updated_at)
|> Page.build_page(page, limit)
end
@doc """
Creates a todo list.
"""
@spec create_todo_list(map) :: {:ok, TodoList.t()} | {:error, Ecto.Changeset.t()}
def create_todo_list(attrs \\ %{}) do
%TodoList{}
|> TodoList.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a todo list.
"""
@spec update_todo_list(TodoList.t(), map) ::
{:ok, TodoList.t()} | {:error, Ecto.Changeset.t()}
def update_todo_list(%TodoList{} = todo_list, attrs) do
todo_list
|> TodoList.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a todo list
"""
@spec delete_todo_list(TodoList.t()) :: {:ok, TodoList.t()} | {:error, Ecto.Changeset.t()}
def delete_todo_list(%TodoList{} = todo_list), do: Repo.delete(todo_list)
@doc """
Get a todo by it's ID
"""
@spec get_todo(integer | String.t()) :: Todo.t() | nil
def get_todo(id), do: Repo.get(Todo, id)
@doc """
Get a todo by it's URL
"""
@spec get_todo_by_url(String.t()) :: Todo.t() | nil
def get_todo_by_url(url), do: Repo.get_by(Todo, url: url)
@doc """
Creates a todo.
"""
@spec create_todo(map) :: {:ok, Todo.t()} | {:error, Ecto.Changeset.t()}
def create_todo(attrs \\ %{}) do
%Todo{}
|> Todo.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a todo.
"""
@spec update_todo(Todo.t(), map) :: {:ok, Todo.t()} | {:error, Ecto.Changeset.t()}
def update_todo(%Todo{} = todo, attrs) do
todo
|> Todo.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a todo
"""
@spec delete_todo(Todo.t()) :: {:ok, Todo.t()} | {:error, Ecto.Changeset.t()}
def delete_todo(%Todo{} = todo), do: Repo.delete(todo)
end

View File

@@ -0,0 +1,38 @@
defmodule Mobilizon.Users.Setting do
@moduledoc """
Module to manage users settings
"""
use Ecto.Schema
import Ecto.Changeset
alias Mobilizon.Users.User
@required_attrs [:user_id]
@optional_attrs [
:timezone,
:notification_on_day,
:notification_each_week,
:notification_before_event
]
@attrs @required_attrs ++ @optional_attrs
@primary_key {:user_id, :id, autogenerate: false}
schema "user_settings" do
field(:timezone, :string)
field(:notification_on_day, :boolean)
field(:notification_each_week, :boolean)
field(:notification_before_event, :boolean)
belongs_to(:user, User, primary_key: true, type: :id, foreign_key: :id, define_field: false)
timestamps()
end
@doc false
def changeset(setting, attrs) do
setting
|> cast(attrs, @attrs)
|> validate_required(@required_attrs)
end
end

View File

@@ -10,7 +10,7 @@ defmodule Mobilizon.Users.User do
alias Mobilizon.Actors.Actor
alias Mobilizon.Crypto
alias Mobilizon.Events.FeedToken
alias Mobilizon.Users.UserRole
alias Mobilizon.Users.{Setting, UserRole}
alias Mobilizon.Web.Email.Checker
@type t :: %__MODULE__{
@@ -68,6 +68,7 @@ defmodule Mobilizon.Users.User do
belongs_to(:default_actor, Actor)
has_many(:actors, Actor)
has_many(:feed_tokens, FeedToken, foreign_key: :user_id)
has_one(:settings, Setting)
timestamps()
end

View File

@@ -11,7 +11,7 @@ defmodule Mobilizon.Users do
alias Mobilizon.Actors.Actor
alias Mobilizon.Events
alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Users.User
alias Mobilizon.Users.{Setting, User}
alias Mobilizon.Web.Auth
@@ -44,6 +44,15 @@ defmodule Mobilizon.Users do
@spec get_user!(integer | String.t()) :: User.t()
def get_user!(id), do: Repo.get!(User, id)
@spec get_user(integer | String.t()) :: User.t() | nil
def get_user(id), do: Repo.get(User, id)
def get_user_with_settings!(id) do
User
|> Repo.get(id)
|> Repo.preload([:settings])
end
@doc """
Gets an user by its email.
"""
@@ -265,6 +274,96 @@ defmodule Mobilizon.Users do
end
end
@doc """
Gets a settings for an user.
Raises `Ecto.NoResultsError` if the Setting does not exist.
## Examples
iex> get_setting!(123)
%Setting{}
iex> get_setting!(456)
** (Ecto.NoResultsError)
"""
def get_setting!(user_id), do: Repo.get!(Setting, user_id)
@spec get_setting(User.t()) :: Setting.t()
def get_setting(%User{id: user_id}), do: get_setting(user_id)
@spec get_setting(String.t() | integer()) :: Setting.t()
def get_setting(user_id), do: Repo.get(Setting, user_id)
@doc """
Creates a setting.
## Examples
iex> create_setting(%{field: value})
{:ok, %Setting{}}
iex> create_setting(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_setting(attrs \\ %{}) do
%Setting{}
|> Setting.changeset(attrs)
|> Repo.insert(
on_conflict: {:replace_all_except, [:user_id, :inserted_at]},
conflict_target: :user_id
)
end
@doc """
Updates a setting.
## Examples
iex> update_setting(setting, %{field: new_value})
{:ok, %Setting{}}
iex> update_setting(setting, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_setting(%Setting{} = setting, attrs) do
setting
|> Setting.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a setting.
## Examples
iex> delete_setting(setting)
{:ok, %Setting{}}
iex> delete_setting(setting)
{:error, %Ecto.Changeset{}}
"""
def delete_setting(%Setting{} = setting) do
Repo.delete(setting)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking setting changes.
## Examples
iex> change_setting(setting)
%Ecto.Changeset{source: %Setting{}}
"""
def change_setting(%Setting{} = setting) do
Setting.changeset(setting, %{})
end
@spec user_by_email_query(String.t(), boolean | nil) :: Ecto.Query.t()
defp user_by_email_query(email, nil) do
from(u in User,

View File

@@ -8,19 +8,19 @@ defmodule Mobilizon.Service.Formatter.DefaultScrubbler do
Custom strategy to filter HTML content.
"""
alias HtmlSanitizeEx.Scrubber.Meta
require HtmlSanitizeEx.Scrubber.Meta
require FastSanitize.Sanitizer.Meta
alias FastSanitize.Sanitizer.Meta
# credo:disable-for-previous-line
# No idea how to fix this one…
Meta.remove_cdata_sections_before_scrub()
@valid_schemes ~w(https http)
Meta.strip_comments()
Meta.allow_tag_with_uri_attributes("a", ["href", "data-user", "data-tag"], ["https", "http"])
Meta.allow_tag_with_uri_attributes(:a, ["href", "data-user", "data-tag"], @valid_schemes)
Meta.allow_tag_with_this_attribute_values("a", "class", [
Meta.allow_tag_with_this_attribute_values(:a, "class", [
"hashtag",
"u-url",
"mention",
@@ -28,7 +28,7 @@ defmodule Mobilizon.Service.Formatter.DefaultScrubbler do
"mention u-url"
])
Meta.allow_tag_with_this_attribute_values("a", "rel", [
Meta.allow_tag_with_this_attribute_values(:a, "rel", [
"tag",
"nofollow",
"noopener",
@@ -36,34 +36,42 @@ defmodule Mobilizon.Service.Formatter.DefaultScrubbler do
"ugc"
])
Meta.allow_tag_with_these_attributes("a", ["name", "title"])
Meta.allow_tag_with_these_attributes(:a, ["name", "title"])
Meta.allow_tag_with_these_attributes("abbr", ["title"])
Meta.allow_tag_with_these_attributes(:abbr, ["title"])
Meta.allow_tag_with_these_attributes("b", [])
Meta.allow_tag_with_these_attributes("blockquote", [])
Meta.allow_tag_with_these_attributes("br", [])
Meta.allow_tag_with_these_attributes("code", [])
Meta.allow_tag_with_these_attributes("del", [])
Meta.allow_tag_with_these_attributes("em", [])
Meta.allow_tag_with_these_attributes("i", [])
Meta.allow_tag_with_these_attributes("li", [])
Meta.allow_tag_with_these_attributes("ol", [])
Meta.allow_tag_with_these_attributes("p", [])
Meta.allow_tag_with_these_attributes("pre", [])
Meta.allow_tag_with_these_attributes("strong", [])
Meta.allow_tag_with_these_attributes("u", [])
Meta.allow_tag_with_these_attributes("ul", [])
Meta.allow_tag_with_these_attributes("img", ["src", "alt"])
Meta.allow_tag_with_these_attributes(:b, [])
Meta.allow_tag_with_these_attributes(:blockquote, [])
Meta.allow_tag_with_these_attributes(:br, [])
Meta.allow_tag_with_these_attributes(:code, [])
Meta.allow_tag_with_these_attributes(:del, [])
Meta.allow_tag_with_these_attributes(:em, [])
Meta.allow_tag_with_these_attributes(:i, [])
Meta.allow_tag_with_these_attributes(:li, [])
Meta.allow_tag_with_these_attributes(:ol, [])
Meta.allow_tag_with_these_attributes(:p, [])
Meta.allow_tag_with_these_attributes(:pre, [])
Meta.allow_tag_with_these_attributes(:strong, [])
Meta.allow_tag_with_these_attributes(:u, [])
Meta.allow_tag_with_these_attributes(:ul, [])
Meta.allow_tag_with_uri_attributes(:img, ["src"], @valid_schemes)
Meta.allow_tag_with_this_attribute_values("span", "class", ["h-card", "mention"])
Meta.allow_tag_with_these_attributes("span", ["data-user"])
Meta.allow_tag_with_these_attributes(:img, [
"width",
"height",
"class",
"title",
"alt"
])
Meta.allow_tag_with_these_attributes("h1", [])
Meta.allow_tag_with_these_attributes("h2", [])
Meta.allow_tag_with_these_attributes("h3", [])
Meta.allow_tag_with_these_attributes("h4", [])
Meta.allow_tag_with_these_attributes("h5", [])
Meta.allow_tag_with_this_attribute_values(:span, "class", ["h-card", "mention"])
Meta.allow_tag_with_these_attributes(:span, ["data-user"])
Meta.allow_tag_with_these_attributes(:h1, [])
Meta.allow_tag_with_these_attributes(:h2, [])
Meta.allow_tag_with_these_attributes(:h3, [])
Meta.allow_tag_with_these_attributes(:h4, [])
Meta.allow_tag_with_these_attributes(:h5, [])
Meta.strip_everything_not_covered()
end

View File

@@ -95,7 +95,9 @@ defmodule Mobilizon.Service.Formatter do
end
def html_escape(text, "text/html") do
HTML.filter_tags(text)
with {:ok, content} <- HTML.filter_tags(text) do
content
end
end
def html_escape(text, "text/plain") do

View File

@@ -8,9 +8,11 @@ defmodule Mobilizon.Service.Formatter.HTML do
Service to filter tags out of HTML content.
"""
alias HtmlSanitizeEx.Scrubber
alias FastSanitize.Sanitizer
alias Mobilizon.Service.Formatter.DefaultScrubbler
alias Mobilizon.Service.Formatter.{DefaultScrubbler, OEmbed}
def filter_tags(html), do: Scrubber.scrub(html, DefaultScrubbler)
def filter_tags(html), do: Sanitizer.scrub(html, DefaultScrubbler)
def filter_tags_for_oembed(html), do: Sanitizer.scrub(html, OEmbed)
end

View File

@@ -0,0 +1,34 @@
defmodule Mobilizon.Service.Formatter.OEmbed do
@moduledoc """
Custom strategy to filter HTML content in OEmbed html
"""
require FastSanitize.Sanitizer.Meta
alias FastSanitize.Sanitizer.Meta
@valid_schemes ~w(https http)
Meta.strip_comments()
Meta.allow_tag_with_uri_attributes(:a, ["href"], @valid_schemes)
Meta.allow_tag_with_uri_attributes(:img, ["src"], @valid_schemes)
Meta.allow_tag_with_these_attributes(:audio, ["controls"])
Meta.allow_tag_with_uri_attributes(:embed, ["src"], @valid_schemes)
Meta.allow_tag_with_these_attributes(:embed, ["height type width"])
Meta.allow_tag_with_uri_attributes(:iframe, ["src"], @valid_schemes)
Meta.allow_tag_with_these_attributes(
:iframe,
["allowfullscreen frameborder allow height scrolling width"]
)
Meta.allow_tag_with_uri_attributes(:source, ["src"], @valid_schemes)
Meta.allow_tag_with_these_attributes(:source, ["type"])
Meta.allow_tag_with_these_attributes(:video, ["controls height loop width"])
Meta.strip_everything_not_covered()
end

View File

@@ -13,6 +13,11 @@ defmodule Mobilizon.Service.Geospatial.Addok do
@endpoint Application.get_env(:mobilizon, __MODULE__) |> get_in([:endpoint])
@http_options [
follow_redirect: true,
ssl: [{:versions, [:"tlsv1.2"]}]
]
@impl Provider
@doc """
Addok implementation for `c:Mobilizon.Service.Geospatial.Provider.geocode/3`.
@@ -26,7 +31,7 @@ defmodule Mobilizon.Service.Geospatial.Addok do
Logger.debug("Asking addok for addresses with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, headers),
HTTPoison.get(url, headers, @http_options),
{:ok, %{"features" => features}} <- Poison.decode(body) do
process_data(features)
else
@@ -46,7 +51,7 @@ defmodule Mobilizon.Service.Geospatial.Addok do
Logger.debug("Asking addok for addresses with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, headers),
HTTPoison.get(url, headers, @http_options),
{:ok, %{"features" => features}} <- Poison.decode(body) do
process_data(features)
else

View File

@@ -28,6 +28,11 @@ defmodule Mobilizon.Service.Geospatial.GoogleMaps do
@api_key_missing_message "API Key required to use Google Maps"
@http_options [
follow_redirect: true,
ssl: [{:versions, [:"tlsv1.2"]}]
]
@impl Provider
@doc """
Google Maps implementation for `c:Mobilizon.Service.Geospatial.Provider.geocode/3`.
@@ -39,7 +44,7 @@ defmodule Mobilizon.Service.Geospatial.GoogleMaps do
Logger.debug("Asking Google Maps for reverse geocode with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url),
HTTPoison.get(url, [], @http_options),
{:ok, %{"results" => results, "status" => "OK"}} <- Poison.decode(body) do
Enum.map(results, fn entry -> process_data(entry, options) end)
else
@@ -59,7 +64,7 @@ defmodule Mobilizon.Service.Geospatial.GoogleMaps do
Logger.debug("Asking Google Maps for addresses with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url),
HTTPoison.get(url, [], @http_options),
{:ok, %{"results" => results, "status" => "OK"}} <- Poison.decode(body) do
results |> Enum.map(fn entry -> process_data(entry, options) end)
else
@@ -161,7 +166,7 @@ defmodule Mobilizon.Service.Geospatial.GoogleMaps do
Logger.debug("Asking Google Maps for details with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url),
HTTPoison.get(url, [], @http_options),
{:ok, %{"result" => %{"name" => name}, "status" => "OK"}} <- Poison.decode(body) do
name
else

View File

@@ -21,6 +21,11 @@ defmodule Mobilizon.Service.Geospatial.MapQuest do
@api_key_missing_message "API Key required to use MapQuest"
@http_options [
follow_redirect: true,
ssl: [{:versions, [:"tlsv1.2"]}]
]
@impl Provider
@doc """
MapQuest implementation for `c:Mobilizon.Service.Geospatial.Provider.geocode/3`.
@@ -42,7 +47,8 @@ defmodule Mobilizon.Service.Geospatial.MapQuest do
"https://#{prefix}.mapquestapi.com/geocoding/v1/reverse?key=#{api_key}&location=#{
lat
},#{lon}&maxResults=#{limit}",
headers
headers,
@http_options
),
{:ok, %{"results" => results, "info" => %{"statuscode" => 0}}} <- Poison.decode(body) do
results |> Enum.map(&process_data/1)
@@ -77,7 +83,7 @@ defmodule Mobilizon.Service.Geospatial.MapQuest do
Logger.debug("Asking MapQuest for addresses with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, headers),
HTTPoison.get(url, headers, @http_options),
{:ok, %{"results" => results, "info" => %{"statuscode" => 0}}} <- Poison.decode(body) do
results |> Enum.map(&process_data/1)
else

View File

@@ -17,6 +17,11 @@ defmodule Mobilizon.Service.Geospatial.Mimirsbrunn do
@endpoint Application.get_env(:mobilizon, __MODULE__) |> get_in([:endpoint])
@http_options [
follow_redirect: true,
ssl: [{:versions, [:"tlsv1.2"]}]
]
@impl Provider
@doc """
Mimirsbrunn implementation for `c:Mobilizon.Service.Geospatial.Provider.geocode/3`.
@@ -29,7 +34,7 @@ defmodule Mobilizon.Service.Geospatial.Mimirsbrunn do
Logger.debug("Asking Mimirsbrunn for reverse geocoding with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, headers),
HTTPoison.get(url, headers, @http_options),
{:ok, %{"features" => features}} <- Poison.decode(body) do
process_data(features)
else
@@ -49,7 +54,7 @@ defmodule Mobilizon.Service.Geospatial.Mimirsbrunn do
Logger.debug("Asking Mimirsbrunn for addresses with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, headers),
HTTPoison.get(url, headers, @http_options),
{:ok, %{"features" => features}} <- Poison.decode(body) do
process_data(features)
else

View File

@@ -14,6 +14,11 @@ defmodule Mobilizon.Service.Geospatial.Nominatim do
@endpoint Application.get_env(:mobilizon, __MODULE__) |> get_in([:endpoint])
@api_key Application.get_env(:mobilizon, __MODULE__) |> get_in([:api_key])
@http_options [
follow_redirect: true,
ssl: [{:versions, [:"tlsv1.2"]}]
]
@impl Provider
@doc """
Nominatim implementation for `c:Mobilizon.Service.Geospatial.Provider.geocode/3`.
@@ -26,7 +31,7 @@ defmodule Mobilizon.Service.Geospatial.Nominatim do
Logger.debug("Asking Nominatim for geocode with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, headers),
HTTPoison.get(url, headers, @http_options),
{:ok, %{"features" => features}} <- Poison.decode(body) do
features |> process_data() |> Enum.filter(& &1)
else
@@ -46,7 +51,7 @@ defmodule Mobilizon.Service.Geospatial.Nominatim do
Logger.debug("Asking Nominatim for addresses with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, headers),
HTTPoison.get(url, headers, @http_options),
{:ok, %{"features" => features}} <- Poison.decode(body) do
features |> process_data() |> Enum.filter(& &1)
else

View File

@@ -15,6 +15,11 @@ defmodule Mobilizon.Service.Geospatial.Pelias do
@endpoint Application.get_env(:mobilizon, __MODULE__) |> get_in([:endpoint])
@http_options [
follow_redirect: true,
ssl: [{:versions, [:"tlsv1.2"]}]
]
@impl Provider
@doc """
Pelias implementation for `c:Mobilizon.Service.Geospatial.Provider.geocode/3`.
@@ -27,7 +32,7 @@ defmodule Mobilizon.Service.Geospatial.Pelias do
Logger.debug("Asking Pelias for reverse geocoding with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, headers),
HTTPoison.get(url, headers, @http_options),
{:ok, %{"features" => features}} <- Poison.decode(body) do
process_data(features)
else
@@ -47,7 +52,7 @@ defmodule Mobilizon.Service.Geospatial.Pelias do
Logger.debug("Asking Pelias for addresses with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, headers),
HTTPoison.get(url, headers, @http_options),
{:ok, %{"features" => features}} <- Poison.decode(body) do
process_data(features)
else

View File

@@ -13,6 +13,11 @@ defmodule Mobilizon.Service.Geospatial.Photon do
@endpoint Application.get_env(:mobilizon, __MODULE__) |> get_in([:endpoint])
@http_options [
follow_redirect: true,
ssl: [{:versions, [:"tlsv1.2"]}]
]
@impl Provider
@doc """
Photon implementation for `c:Mobilizon.Service.Geospatial.Provider.geocode/3`.
@@ -27,7 +32,7 @@ defmodule Mobilizon.Service.Geospatial.Photon do
Logger.debug("Asking photon for reverse geocoding with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, headers),
HTTPoison.get(url, headers, @http_options),
{:ok, %{"features" => features}} <- Poison.decode(body) do
process_data(features)
else
@@ -47,7 +52,7 @@ defmodule Mobilizon.Service.Geospatial.Photon do
Logger.debug("Asking photon for addresses with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, headers),
HTTPoison.get(url, headers, @http_options),
{:ok, %{"features" => features}} <- Poison.decode(body) do
process_data(features)
else

View File

@@ -1,6 +1,6 @@
defimpl Mobilizon.Service.Metadata, for: Mobilizon.Events.Comment do
defimpl Mobilizon.Service.Metadata, for: Mobilizon.Conversations.Comment do
alias Phoenix.HTML.Tag
alias Mobilizon.Events.Comment
alias Mobilizon.Conversations.Comment
def build_tags(%Comment{} = comment) do
[

View File

@@ -0,0 +1,82 @@
defmodule Mobilizon.Service.Notifications.Scheduler do
@moduledoc """
Allows to insert jobs
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Service.Workers.Notification
alias Mobilizon.Users
alias Mobilizon.Users.Setting
require Logger
def before_event_notification(%Participant{
id: participant_id,
event: %Event{begins_on: begins_on},
actor: %Actor{user_id: user_id}
})
when not is_nil(user_id) do
case Users.get_setting(user_id) do
%Setting{notification_before_event: true} ->
Notification.enqueue(:before_event_notification, %{participant_id: participant_id},
scheduled_at: DateTime.add(begins_on, -3600, :second)
)
_ ->
{:ok, nil}
end
end
def before_event_notification(_), do: {:ok, nil}
def on_day_notification(%Participant{
event: %Event{begins_on: begins_on},
actor: %Actor{user_id: user_id}
})
when not is_nil(user_id) do
case Users.get_setting(user_id) do
%Setting{notification_on_day: true, timezone: timezone} ->
%DateTime{hour: hour} = begins_on_shifted = shift_zone(begins_on, timezone)
Logger.debug("Participation event start at #{inspect(begins_on_shifted)} (user timezone)")
send_date =
cond do
begins_on < DateTime.utc_now() ->
nil
hour > 8 ->
# If the event is after 8 o'clock
%{begins_on_shifted | hour: 8, minute: 0, second: 0, microsecond: {0, 0}}
true ->
# If the event is before 8 o'clock, we send the notification the day before,
# unless this is already passed
begins_on_shifted
|> DateTime.add(-24 * 3_600)
|> (&%{&1 | hour: 8, minute: 0, second: 0, microsecond: {0, 0}}).()
end
Logger.debug(
"Participation notification should be sent at #{inspect(send_date)} (user timezone)"
)
if DateTime.utc_now() > send_date do
{:ok, "Too late to send same day notifications"}
else
Notification.enqueue(:on_day_notification, %{user_id: user_id}, scheduled_at: send_date)
end
_ ->
{:ok, "User has disable on day notifications"}
end
end
def on_day_notification(_), do: {:ok, nil}
defp shift_zone(datetime, timezone) do
case DateTime.shift_zone(datetime, timezone) do
{:ok, shift_datetime} -> shift_datetime
{:error, _} -> datetime
end
end
end

View File

@@ -0,0 +1,104 @@
defmodule Mobilizon.Service.RichMedia.Favicon do
@moduledoc """
Module to fetch favicon information from a website
Taken and adapted from https://github.com/ricn/favicon
"""
require Logger
alias Mobilizon.Config
@options [
max_body: 2_000_000,
timeout: 10_000,
recv_timeout: 20_000,
follow_redirect: true,
ssl: [{:versions, [:"tlsv1.2"]}]
]
@spec fetch(String.t(), List.t()) :: {:ok, String.t()} | {:error, any()}
def fetch(url, options \\ []) do
user_agent = Keyword.get(options, :user_agent, Config.instance_user_agent())
headers = [{"User-Agent", user_agent}]
case HTTPoison.get(url, headers, @options) do
{:ok, %HTTPoison.Response{status_code: code, body: body}} when code in 200..299 ->
find_favicon_url(url, body, headers)
{:ok, %HTTPoison.Response{}} ->
{:error, "Error while fetching the page"}
{:error, %HTTPoison.Error{reason: reason}} ->
{:error, reason}
end
end
@spec find_favicon_url(String.t(), String.t(), List.t()) :: {:ok, String.t()} | {:error, any()}
defp find_favicon_url(url, body, headers) do
Logger.debug("finding favicon URL for #{url}")
case find_favicon_link_tag(body) do
{:ok, tag} ->
Logger.debug("Found link #{inspect(tag)}")
{"link", attrs, _} = tag
{"href", path} =
Enum.find(attrs, fn {name, _} ->
name == "href"
end)
{:ok, format_url(url, path)}
_ ->
find_favicon_in_root(url, headers)
end
end
@spec format_url(String.t(), String.t()) :: String.t()
defp format_url(url, path) do
image_uri = URI.parse(path)
uri = URI.parse(url)
cond do
is_nil(image_uri.host) -> "#{uri.scheme}://#{uri.host}#{path}"
is_nil(image_uri.scheme) -> "#{uri.scheme}:#{path}"
true -> path
end
end
@spec find_favicon_link_tag(String.t()) :: {:ok, tuple()} | {:error, any()}
defp find_favicon_link_tag(html) do
with {:ok, html} <- Floki.parse_document(html),
links <- Floki.find(html, "link"),
{:link, link} when not is_nil(link) <-
{:link,
Enum.find(links, fn {"link", attrs, _} ->
Enum.any?(attrs, fn {name, value} ->
name == "rel" && String.contains?(value, "icon") &&
!String.contains?(value, "-icon-")
end)
end)} do
{:ok, link}
else
{:link, nil} -> {:error, "No link found"}
err -> err
end
end
@spec find_favicon_in_root(String.t(), List.t()) :: {:ok, String.t()} | {:error, any()}
defp find_favicon_in_root(url, headers) do
uri = URI.parse(url)
favicon_url = "#{uri.scheme}://#{uri.host}/favicon.ico"
case HTTPoison.head(favicon_url, headers, @options) do
{:ok, %HTTPoison.Response{status_code: code}} when code in 200..299 ->
{:ok, favicon_url}
{:ok, %HTTPoison.Response{}} ->
{:error, "Error while doing a HEAD request on the favicon"}
{:error, %HTTPoison.Error{reason: reason}} ->
{:error, reason}
end
end
end

View File

@@ -0,0 +1,278 @@
# Portions of this file are derived from Pleroma:
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mobilizon.Service.RichMedia.Parser do
@moduledoc """
Module to parse data in HTML pages
"""
@options [
max_body: 2_000_000,
timeout: 10_000,
recv_timeout: 20_000,
follow_redirect: true,
# TODO: Remove me once Hackney/HTTPoison fixes their shit with TLS1.3 and OTP 23
ssl: [{:versions, [:"tlsv1.2"]}]
]
alias Mobilizon.Config
alias Mobilizon.Service.RichMedia.Favicon
alias Plug.Conn.Utils
require Logger
defp parsers do
Mobilizon.Config.get([:rich_media, :parsers])
end
def parse(nil), do: {:error, "No URL provided"}
@spec parse(String.t()) :: {:ok, map()} | {:error, any()}
def parse(url) do
case Cachex.fetch(:rich_media_cache, url, fn _ ->
case parse_url(url) do
{:ok, data} -> {:commit, data}
{:error, err} -> {:ignore, err}
end
end) do
{status, value} when status in [:ok, :commit] ->
{:ok, value}
{_, err} ->
{:error, err}
end
rescue
e ->
{:error, "Cachex error: #{inspect(e)}"}
end
@spec parse_url(String.t(), List.t()) :: {:ok, map()} | {:error, any()}
defp parse_url(url, options \\ []) do
user_agent = Keyword.get(options, :user_agent, Config.instance_user_agent())
headers = [{"User-Agent", user_agent}]
Logger.debug("Fetching content at address #{inspect(url)}")
try do
with {:ok, _} <- prevent_local_address(url),
{:ok, %HTTPoison.Response{body: body, status_code: code, headers: response_headers}}
when code in 200..299 <-
HTTPoison.get(
url,
headers,
@options
),
{:is_html, _response_headers, true} <-
{:is_html, response_headers, is_html(response_headers)} do
body
|> parse_html()
|> maybe_parse()
|> Map.put(:url, url)
|> maybe_add_favicon()
|> clean_parsed_data()
|> check_parsed_data()
|> check_remote_picture_path()
else
{:is_html, response_headers, false} ->
data = get_data_for_media(response_headers, url)
{:ok, data}
{:error, err} ->
Logger.debug("HTTP error: #{inspect(err)}")
{:error, "HTTP error: #{inspect(err)}"}
end
rescue
e ->
{:error, "Parsing error: #{inspect(e)} #{inspect(__STACKTRACE__)}"}
end
end
@spec get_data_for_media(List.t(), String.t()) :: map()
defp get_data_for_media(response_headers, url) do
data = %{title: get_filename_from_headers(response_headers) || get_filename_from_url(url)}
if is_image(response_headers) do
Map.put(data, :image_remote_url, url)
else
data
end
end
@spec is_html(List.t()) :: boolean
defp is_html(headers) do
headers
|> get_header("Content-Type")
|> content_type_header_matches(["text/html", "application/xhtml"])
end
@spec is_image(List.t()) :: boolean
defp is_image(headers) do
headers
|> get_header("Content-Type")
|> content_type_header_matches(["image/"])
end
@spec content_type_header_matches(String.t() | nil, List.t()) :: boolean
defp content_type_header_matches(header, content_types)
defp content_type_header_matches(nil, _content_types), do: false
defp content_type_header_matches(header, content_types) when is_binary(header) do
Enum.any?(content_types, fn content_type -> String.starts_with?(header, content_type) end)
end
@spec get_header(List.t(), String.t()) :: String.t() | nil
defp get_header(headers, key) do
case List.keyfind(headers, key, 0) do
{^key, value} -> String.downcase(value)
nil -> nil
end
end
@spec get_filename_from_headers(List.t()) :: String.t() | nil
defp get_filename_from_headers(headers) do
case get_header(headers, "Content-Disposition") do
nil -> nil
content_disposition -> parse_content_disposition(content_disposition)
end
end
@spec get_filename_from_url(String.t()) :: String.t()
defp get_filename_from_url(url) do
%URI{path: path} = URI.parse(url)
path
|> String.split("/", trim: true)
|> Enum.at(-1)
|> URI.decode()
end
# The following is taken from https://github.com/elixir-plug/plug/blob/65986ad32f9aaae3be50dc80cbdd19b326578da7/lib/plug/parsers/multipart.ex#L207
@spec parse_content_disposition(String.t()) :: String.t() | nil
defp parse_content_disposition(disposition) do
with [_, params] <- :binary.split(disposition, ";"),
%{"name" => _name} = params <- Utils.params(params) do
handle_disposition(params)
else
_ -> nil
end
end
@spec handle_disposition(map()) :: String.t() | nil
defp handle_disposition(params) do
case params do
%{"filename" => ""} ->
nil
%{"filename" => filename} ->
filename
%{"filename*" => ""} ->
nil
%{"filename*" => "utf-8''" <> filename} ->
URI.decode(filename)
_ ->
nil
end
end
defp parse_html(html), do: Floki.parse_document!(html)
defp maybe_parse(html) do
Enum.reduce_while(parsers(), %{}, fn parser, acc ->
case parser.parse(html, acc) do
{:ok, data} -> {:halt, data}
{:error, _msg} -> {:cont, acc}
end
end)
end
defp check_parsed_data(%{title: title} = data)
when is_binary(title) and byte_size(title) > 0 do
{:ok, data}
end
defp check_parsed_data(data) do
{:error, "Found metadata was invalid or incomplete: #{inspect(data)}"}
end
defp clean_parsed_data(data) do
data
|> Enum.reject(fn {key, val} ->
case Jason.encode(%{key => val}) do
{:ok, _} -> false
_ -> true
end
end)
|> Map.new()
end
defp prevent_local_address(url) do
case URI.parse(url) do
%URI{host: host} when not is_nil(host) ->
host = String.downcase(host)
if validate_hostname_not_localhost(host) && validate_hostname_only(host) &&
validate_ip(host) do
{:ok, url}
else
{:error, "Host violates local access rules"}
end
_ ->
{:error, "Could not detect any host"}
end
end
defp validate_hostname_not_localhost(hostname),
do:
hostname != "localhost" && !String.ends_with?(hostname, ".local") &&
!String.ends_with?(hostname, ".localhost")
defp validate_hostname_only(hostname),
do: hostname |> String.graphemes() |> Enum.count(&(&1 == "o")) > 0
defp validate_ip(hostname) do
case hostname |> String.to_charlist() |> :inet.parse_address() do
{:ok, address} ->
!IpReserved.is_reserved?(address)
# Not a valid IP
{:error, _} ->
true
end
end
@spec maybe_add_favicon(map()) :: map()
defp maybe_add_favicon(%{url: url} = data) do
case Favicon.fetch(url) do
{:ok, favicon_url} ->
Logger.debug("Adding favicon #{favicon_url} to metadata")
Map.put(data, :favicon_url, favicon_url)
err ->
Logger.debug("Failed to add favicon to metadata")
Logger.debug(inspect(err))
data
end
end
@spec check_remote_picture_path(map()) :: map()
defp check_remote_picture_path(%{image_remote_url: image_remote_url, url: url} = data) do
Logger.debug("Checking image_remote_url #{image_remote_url}")
image_uri = URI.parse(image_remote_url)
uri = URI.parse(url)
image_remote_url =
cond do
is_nil(image_uri.host) -> "#{uri.scheme}://#{uri.host}#{image_remote_url}"
is_nil(image_uri.scheme) -> "#{uri.scheme}:#{image_remote_url}"
true -> image_remote_url
end
Map.put(data, :image_remote_url, image_remote_url)
end
defp check_remote_picture_path(data), do: data
end

View File

@@ -0,0 +1,41 @@
# Portions of this file are derived from Pleroma:
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mobilizon.Service.RichMedia.Parsers.Fallback do
@moduledoc """
Module to parse fallback data in HTML pages (plain old title and meta description)
"""
@spec parse(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
def parse(html, data) do
data =
data
|> maybe_put(html, :title)
|> maybe_put(html, :description)
if Enum.empty?(data) do
{:error, "Not even a title"}
else
{:ok, data}
end
end
defp maybe_put(meta, html, attr) do
case get_page(html, attr) do
"" -> meta
content -> Map.put_new(meta, attr, content)
end
end
defp get_page(html, :title) do
html |> Floki.find("html head title") |> List.first() |> Floki.text() |> String.trim()
end
defp get_page(html, :description) do
case html |> Floki.find("html head meta[name='description']") |> List.first() do
nil -> ""
elem -> elem |> Floki.attribute("content") |> List.first() |> String.trim()
end
end
end

View File

@@ -0,0 +1,76 @@
# Portions of this file are derived from Pleroma:
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mobilizon.Service.RichMedia.Parsers.MetaTagsParser do
@moduledoc """
Module to parse meta tags data in HTML pages
"""
def parse(html, data, prefix, error_message, key_name, value_name \\ "content") do
meta_data =
html
|> get_elements(key_name, prefix)
|> Enum.reduce(data, fn el, acc ->
attributes = normalize_attributes(el, prefix, key_name, value_name)
Map.merge(acc, attributes)
end)
|> maybe_put_title(html)
|> maybe_put_description(html)
if Enum.empty?(meta_data) do
{:error, error_message}
else
{:ok, meta_data}
end
end
defp get_elements(html, key_name, prefix) do
html |> Floki.find("meta[#{key_name}^='#{prefix}:']")
end
defp normalize_attributes(html_node, prefix, key_name, value_name) do
{_tag, attributes, _children} = html_node
data =
Enum.into(attributes, %{}, fn {name, value} ->
{name, String.trim_leading(value, "#{prefix}:")}
end)
%{String.to_atom(data[key_name]) => data[value_name]}
end
defp maybe_put_title(%{title: _} = meta, _), do: meta
defp maybe_put_title(meta, html) when meta != %{} do
case get_page_title(html) do
"" -> meta
title -> Map.put_new(meta, :title, title)
end
end
defp maybe_put_title(meta, _), do: meta
defp maybe_put_description(%{description: _} = meta, _), do: meta
defp maybe_put_description(meta, html) when meta != %{} do
case get_page_description(html) do
"" -> meta
description -> Map.put_new(meta, :description, description)
end
end
defp maybe_put_description(meta, _), do: meta
defp get_page_title(html) do
html |> Floki.find("html head title") |> List.first() |> Floki.text()
end
defp get_page_description(html) do
case html |> Floki.find("html head meta[name='description']") |> List.first() do
nil -> ""
elem -> Floki.attribute(elem, "content")
end
end
end

View File

@@ -0,0 +1,83 @@
# Portions of this file are derived from Pleroma:
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mobilizon.Service.RichMedia.Parsers.OEmbed do
@moduledoc """
Module to parse OEmbed data in HTML pages
"""
alias Mobilizon.Service.Formatter.HTML
require Logger
@http_options [
follow_redirect: true,
ssl: [{:versions, [:"tlsv1.2"]}]
]
def parse(html, _data) do
Logger.debug("Using OEmbed parser")
with elements = [_ | _] <- get_discovery_data(html),
{:ok, oembed_url} <- get_oembed_url(elements),
{:ok, oembed_data} <- get_oembed_data(oembed_url),
oembed_data <- filter_oembed_data(oembed_data) do
Logger.debug("Data found with OEmbed parser")
Logger.debug(inspect(oembed_data))
{:ok, oembed_data}
else
_e ->
{:error, "No OEmbed data found"}
end
end
defp get_discovery_data(html) do
html |> Floki.find("link[type='application/json+oembed']")
end
defp get_oembed_url(nodes) do
{"link", attributes, _children} = nodes |> hd()
{:ok, Enum.into(attributes, %{})["href"]}
end
defp get_oembed_data(url) do
with {:ok, %HTTPoison.Response{body: json}} <- HTTPoison.get(url, [], @http_options),
{:ok, data} <- Jason.decode(json),
data <- data |> Map.new(fn {k, v} -> {String.to_atom(k), v} end) do
{:ok, data}
end
end
defp filter_oembed_data(data) do
case Map.get(data, :type) do
nil ->
{:error, "No type declared for OEmbed data"}
"link" ->
Map.put(data, :image_remote_url, Map.get(data, :thumbnail_url))
"photo" ->
if Map.get(data, :url, "") == "" do
{:error, "No URL for photo OEmbed data"}
else
data
|> Map.put(:image_remote_url, Map.get(data, :url))
|> Map.put(:width, Map.get(data, :width, 0))
|> Map.put(:height, Map.get(data, :height, 0))
end
"video" ->
{:ok, html} = data |> Map.get(:html, "") |> HTML.filter_tags_for_oembed()
data
|> Map.put(:html, html)
|> Map.put(:width, Map.get(data, :width, 0))
|> Map.put(:height, Map.get(data, :height, 0))
|> Map.put(:image_remote_url, Map.get(data, :thumbnail_url))
"rich" ->
{:error, "OEmbed data has rich type, which we don't support"}
end
end
end

View File

@@ -0,0 +1,36 @@
# Portions of this file are derived from Pleroma:
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mobilizon.Service.RichMedia.Parsers.OGP do
@moduledoc """
Module to parse OpenGraph data in HTML pages
"""
require Logger
alias Mobilizon.Service.RichMedia.Parsers.MetaTagsParser
def parse(html, data) do
Logger.debug("Using OpenGraph card parser")
with {:ok, data} <-
MetaTagsParser.parse(
html,
data,
"og",
"No OGP metadata found",
"property"
) do
data = transform_tags(data)
Logger.debug("Data found with OpenGraph card parser")
{:ok, data}
end
end
defp transform_tags(data) do
data
|> Map.put(:image_remote_url, Map.get(data, :image))
|> Map.put(:width, Map.get(data, :"image:width"))
|> Map.put(:height, Map.get(data, :"image:height"))
end
end

View File

@@ -0,0 +1,34 @@
# Portions of this file are derived from Pleroma:
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mobilizon.Service.RichMedia.Parsers.TwitterCard do
@moduledoc """
Module to parse Twitter tags data in HTML pages
"""
alias Mobilizon.Service.RichMedia.Parsers.MetaTagsParser
require Logger
@spec parse(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
def parse(html, data) do
Logger.debug("Using Twitter card parser")
res =
data
|> parse_name_attrs(html)
|> parse_property_attrs(html)
Logger.debug("Data found with Twitter card parser")
Logger.debug(inspect(res))
res
end
defp parse_name_attrs(data, html) do
MetaTagsParser.parse(html, data, "twitter", %{}, "name")
end
defp parse_property_attrs({_, data}, html) do
MetaTagsParser.parse(html, data, "twitter", "No twitter card metadata found", "property")
end
end

View File

@@ -3,7 +3,7 @@ defmodule Mobilizon.Service.Statistics do
A module that provides cached statistics
"""
alias Mobilizon.{Events, Users}
alias Mobilizon.{Conversations, Events, Users}
def get_cached_value(key) do
case Cachex.fetch(:statistics, key, fn key ->
@@ -26,6 +26,6 @@ defmodule Mobilizon.Service.Statistics do
end
defp create_cache(:local_comments) do
Events.count_local_comments()
Conversations.count_local_comments()
end
end

View File

@@ -1,6 +1,6 @@
defmodule Mobilizon.Service.Workers.Background do
@moduledoc """
Worker to build search results
Worker to perform various actions in the background
"""
alias Mobilizon.Actors

View File

@@ -10,7 +10,6 @@ defmodule Mobilizon.Service.Workers.Helper do
alias Mobilizon.Config
alias Mobilizon.Service.Workers.Helper
alias Mobilizon.Storage.Repo
def worker_args(queue) do
case Config.get([:workers, :retries, queue]) do
@@ -45,7 +44,7 @@ defmodule Mobilizon.Service.Workers.Helper do
unquote(caller_module)
|> apply(:new, [params, worker_args])
|> Repo.insert()
|> Oban.insert()
end
end
end

View File

@@ -0,0 +1,63 @@
defmodule Mobilizon.Service.Workers.Notification do
@moduledoc """
Worker to send notifications
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Events
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Storage.Page
alias Mobilizon.Users
alias Mobilizon.Users.{Setting, User}
alias Mobilizon.Web.Email.{Mailer, Notification}
use Mobilizon.Service.Workers.Helper, queue: "mailers"
@impl Oban.Worker
def perform(%{"op" => "before_event_notification", "participant_id" => participant_id}, _job) do
with %Participant{actor: %Actor{user_id: user_id}, event: %Event{status: :confirmed}} =
participant <- Events.get_participant(participant_id),
%User{email: email, locale: locale, settings: %Setting{notification_before_event: true}} <-
Users.get_user_with_settings!(user_id) do
email
|> Notification.before_event_notification(participant, locale)
|> Mailer.deliver_later()
:ok
end
end
def perform(%{"op" => "on_day_notification", "user_id" => user_id}, _job) do
%User{locale: locale, settings: %Setting{timezone: timezone, notification_on_day: true}} =
user = Users.get_user_with_settings!(user_id)
now = DateTime.utc_now()
%DateTime{} = now_shifted = shift_zone(now, timezone)
start = %{now_shifted | hour: 8, minute: 0, second: 0, microsecond: {0, 0}}
tomorrow = DateTime.add(start, 3600 * 24)
with %Page{
elements: participations,
total: total
} <-
Events.list_participations_for_user(user_id, start, tomorrow, 1, 5),
true <-
Enum.all?(participations, fn participation ->
participation.event.status == :confirmed
end),
true <- total > 0 do
user
|> Notification.on_day_notification(participations, total, locale)
|> Mailer.deliver_later()
:ok
end
end
defp shift_zone(datetime, timezone) do
case DateTime.shift_zone(datetime, timezone) do
{:ok, shift_datetime} -> shift_datetime
{:error, _} -> datetime
end
end
end

View File

@@ -3,12 +3,13 @@ defmodule Mobilizon.Web.Cache.ActivityPub do
ActivityPub related cache.
"""
alias Mobilizon.{Actors, Events, Tombstone}
alias Mobilizon.{Actors, Conversations, Events, Resources, Todos, Tombstone}
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.{Comment, Event}
alias Mobilizon.Conversations.Comment
alias Mobilizon.Events.Event
alias Mobilizon.Federation.ActivityPub.Relay
alias Mobilizon.Resources.Resource
alias Mobilizon.Todos.{Todo, TodoList}
alias Mobilizon.Web.Endpoint
alias Mobilizon.Web.Router.Helpers, as: Routes
@@ -60,7 +61,7 @@ defmodule Mobilizon.Web.Cache.ActivityPub do
{:commit, Comment.t()} | {:ignore, nil}
def get_comment_by_uuid_with_preload(uuid) do
Cachex.fetch(@cache, "comment_" <> uuid, fn "comment_" <> uuid ->
case Events.get_comment_from_uuid_with_preload(uuid) do
case Conversations.get_comment_from_uuid_with_preload(uuid) do
%Comment{} = comment ->
{:commit, comment}
@@ -70,6 +71,57 @@ defmodule Mobilizon.Web.Cache.ActivityPub do
end)
end
@doc """
Gets a resource by its UUID, with all associations loaded.
"""
@spec get_resource_by_uuid_with_preload(String.t()) ::
{:commit, Resource.t()} | {:ignore, nil}
def get_resource_by_uuid_with_preload(uuid) do
Cachex.fetch(@cache, "resource_" <> uuid, fn "resource_" <> uuid ->
case Resources.get_resource_with_preloads(uuid) do
%Resource{} = resource ->
{:commit, resource}
nil ->
{:ignore, nil}
end
end)
end
@doc """
Gets a todo list by its UUID, with all associations loaded.
"""
@spec get_todo_list_by_uuid_with_preload(String.t()) ::
{:commit, TodoList.t()} | {:ignore, nil}
def get_todo_list_by_uuid_with_preload(uuid) do
Cachex.fetch(@cache, "todo_list_" <> uuid, fn "todo_list_" <> uuid ->
case Todos.get_todo_list(uuid) do
%TodoList{} = todo_list ->
{:commit, todo_list}
nil ->
{:ignore, nil}
end
end)
end
@doc """
Gets a todo by its UUID, with all associations loaded.
"""
@spec get_todo_by_uuid_with_preload(String.t()) ::
{:commit, Todo.t()} | {:ignore, nil}
def get_todo_by_uuid_with_preload(uuid) do
Cachex.fetch(@cache, "todo_" <> uuid, fn "todo_" <> uuid ->
case Todos.get_todo(uuid) do
%Todo{} = todo ->
{:commit, todo}
nil ->
{:ignore, nil}
end
end)
end
@doc """
Gets a relay.
"""

View File

@@ -20,5 +20,8 @@ defmodule Mobilizon.Web.Cache do
defdelegate get_local_actor_by_name(name), to: ActivityPub
defdelegate get_public_event_by_uuid_with_preload(uuid), to: ActivityPub
defdelegate get_comment_by_uuid_with_preload(uuid), to: ActivityPub
defdelegate get_resource_by_uuid_with_preload(uuid), to: ActivityPub
defdelegate get_todo_list_by_uuid_with_preload(uuid), to: ActivityPub
defdelegate get_todo_by_uuid_with_preload(uuid), to: ActivityPub
defdelegate get_relay, to: ActivityPub
end

View File

@@ -67,6 +67,47 @@ defmodule Mobilizon.Web.ActivityPubController do
end
end
def members(conn, %{"name" => name, "page" => page}) do
with {page, ""} <- Integer.parse(page),
%Actor{} = group <- Actors.get_local_actor_by_name_with_preload(name) do
conn
|> put_resp_header("content-type", "application/activity+json")
|> json(
ActorView.render("members.json", %{
group: group,
page: page,
actor_applicant: Map.get(conn.assigns, :actor)
})
)
end
end
def members(conn, %{"name" => name}) do
with %Actor{} = group <- Actors.get_local_actor_by_name_with_preload(name) do
conn
|> put_resp_header("content-type", "application/activity+json")
|> json(
ActorView.render("members.json", %{
group: group,
actor_applicant: Map.get(conn.assigns, :actor)
})
)
end
end
def resources(conn, %{"name" => name}) do
with %Actor{} = group <- Actors.get_local_actor_by_name_with_preload(name) do
conn
|> put_resp_header("content-type", "application/activity+json")
|> json(
ActorView.render("resources.json", %{
group: group,
actor_applicant: Map.get(conn.assigns, :actor)
})
)
end
end
def outbox(conn, %{"name" => name, "page" => page}) do
with {page, ""} <- Integer.parse(page),
%Actor{} = actor <- Actors.get_local_actor_by_name(name) do

View File

@@ -4,31 +4,65 @@ defmodule Mobilizon.Web.PageController do
"""
use Mobilizon.Web, :controller
alias Mobilizon.Events.{Comment, Event}
alias Mobilizon.Conversations.Comment
alias Mobilizon.Events.Event
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Tombstone
alias Mobilizon.Web.Cache
alias Mobilizon.Web.{ActivityPubController, Cache}
plug(:put_layout, false)
action_fallback(Mobilizon.Web.FallbackController)
@spec index(Plug.Conn.t(), any) :: Plug.Conn.t()
def index(conn, _params), do: render(conn, :index)
@spec actor(Plug.Conn.t(), map) :: {:error, :not_found} | Plug.Conn.t()
def actor(conn, %{"name" => name}) do
{status, actor} = Cache.get_local_actor_by_name(name)
render_or_error(conn, &ok_status?/3, status, :actor, actor)
end
@spec event(Plug.Conn.t(), map) :: {:error, :not_found} | Plug.Conn.t()
def event(conn, %{"uuid" => uuid}) do
{status, event} = Cache.get_public_event_by_uuid_with_preload(uuid)
render_or_error(conn, &checks?/3, status, :event, event)
end
@spec comment(Plug.Conn.t(), map) :: {:error, :not_found} | Plug.Conn.t()
def comment(conn, %{"uuid" => uuid}) do
{status, comment} = Cache.get_comment_by_uuid_with_preload(uuid)
render_or_error(conn, &checks?/3, status, :comment, comment)
end
@spec resource(Plug.Conn.t(), map()) :: Plug.Conn.t() | {:error, :not_found}
def resource(conn, %{"uuid" => uuid}) do
{status, resource} = Cache.get_resource_by_uuid_with_preload(uuid)
render_or_error(conn, &checks?/3, status, :resource, resource)
end
def resources(conn, %{"name" => _name}) do
case get_format(conn) do
"html" ->
render(conn, :index)
"activity-json" ->
ActivityPubController.call(conn, :resources)
end
end
@spec todo_list(Plug.Conn.t(), map) :: {:error, :not_found} | Plug.Conn.t()
def todo_list(conn, %{"uuid" => uuid}) do
{status, todo_list} = Cache.get_todo_list_by_uuid_with_preload(uuid)
render_or_error(conn, &checks?/3, status, :todo_list, todo_list)
end
@spec todo(Plug.Conn.t(), map) :: {:error, :not_found} | Plug.Conn.t()
def todo(conn, %{"uuid" => uuid}) do
{status, todo} = Cache.get_todo_by_uuid_with_preload(uuid)
render_or_error(conn, &checks?/3, status, :todo, todo)
end
@spec interact(Plug.Conn.t(), map()) :: Plug.Conn.t() | {:error, :not_found}
def interact(conn, %{"uri" => uri}) do
case ActivityPub.fetch_object_from_url(uri) do
{:ok, %Event{uuid: uuid}} -> redirect(conn, to: "/events/#{uuid}")
@@ -60,6 +94,7 @@ defmodule Mobilizon.Web.PageController do
defp is_visible?(%{visibility: v}), do: v in [:public, :unlisted]
defp is_visible?(%Tombstone{}), do: true
defp is_visible?(_), do: true
defp ok_status?(status), do: status in [:ok, :commit]
defp ok_status?(_conn, status, _), do: ok_status?(status)
@@ -75,6 +110,6 @@ defmodule Mobilizon.Web.PageController do
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)
defp is_local?(%{local: local}), do: if(local, do: true, else: :remote)
defp is_local?(_), do: false
end

View File

@@ -19,7 +19,7 @@ defmodule Mobilizon.Web.Email.Event do
@important_changes [:title, :begins_on, :ends_on, :status]
@spec event_updated(User.t(), Actor.t(), Event.t(), Event.t(), list(), String.t()) ::
@spec event_updated(User.t(), Actor.t(), Event.t(), Event.t(), MapSet.t(), String.t()) ::
Bamboo.Email.t()
def event_updated(
%User{} = user,
@@ -82,4 +82,12 @@ defmodule Mobilizon.Web.Email.Event do
|> Email.Event.event_updated(actor, old_event, event, diff, locale)
|> Email.Mailer.deliver_later()
end
defp send_notification_for_event_update_to_participant(user, old_event, new_event, diff) do
require Logger
Logger.error(inspect(user))
Logger.error(inspect(old_event))
Logger.error(inspect(new_event))
Logger.error(inspect(diff))
end
end

47
lib/web/email/group.ex Normal file
View File

@@ -0,0 +1,47 @@
defmodule Mobilizon.Web.Email.Group do
@moduledoc """
Handles emails sent about participation.
"""
use Bamboo.Phoenix, view: Mobilizon.Web.EmailView
import Bamboo.Phoenix
import Mobilizon.Web.Gettext
alias Mobilizon.{Actors, Users}
alias Mobilizon.Actors.{Actor, Member}
alias Mobilizon.Users.User
alias Mobilizon.Web.{Email, Gettext}
@doc """
Send emails to local user
"""
def send_invite_to_user(
%Member{actor: %Actor{user_id: user_id}, parent: %Actor{} = group, role: :invited} =
member,
locale \\ "en"
) do
with %User{email: email} <- Users.get_user!(user_id) do
Gettext.put_locale(locale)
%Actor{name: invited_by_name} = inviter = Actors.get_actor(member.invited_by_id)
subject =
gettext(
"You have been invited by %{inviter} to join group %{group}",
inviter: invited_by_name,
group: group.name
)
Email.base_email(to: email, subject: subject)
|> assign(:locale, locale)
|> assign(:inviter, inviter)
|> assign(:group, group)
|> assign(:subject, subject)
|> render(:group_invite)
|> Email.Mailer.deliver_later()
:ok
end
end
# TODO : def send_confirmation_to_inviter()
end

View File

@@ -0,0 +1,59 @@
defmodule Mobilizon.Web.Email.Notification do
@moduledoc """
Handles emails sent about event notifications.
"""
use Bamboo.Phoenix, view: Mobilizon.Web.EmailView
import Bamboo.Phoenix
import Mobilizon.Web.Gettext
alias Mobilizon.Events.Participant
alias Mobilizon.Users.{Setting, User}
alias Mobilizon.Web.{Email, Gettext}
@spec before_event_notification(String.t(), Participant.t(), String.t()) ::
Bamboo.Email.t()
def before_event_notification(
email,
%Participant{event: event, role: :participant} = participant,
locale \\ "en"
) do
Gettext.put_locale(locale)
subject =
gettext(
"Don't forget to go to %{title}",
title: event.title
)
Email.base_email(to: email, subject: subject)
|> assign(:locale, locale)
|> assign(:participant, participant)
|> assign(:subject, subject)
|> render(:before_event_notification)
end
def on_day_notification(
%User{email: email, settings: %Setting{timezone: timezone}},
participations,
total,
locale \\ "en"
) do
Gettext.put_locale(locale)
participation = hd(participations)
subject =
ngettext("One event planned today", "%{nb_events} events planned today", total,
nb_events: total
)
Email.base_email(to: email, subject: subject)
|> assign(:locale, locale)
|> assign(:participation, participation)
|> assign(:participations, participations)
|> assign(:total, total)
|> assign(:timezone, timezone)
|> assign(:subject, subject)
|> render(:on_day_notification)
end
end

View File

@@ -28,9 +28,11 @@ defmodule Mobilizon.Web.Endpoint do
plug(
Plug.Static,
at: "/",
from: :mobilizon,
from: {:mobilizon, "priv/static"},
gzip: false,
only: ~w(css fonts images js favicon.ico robots.txt)
only:
~w(index.html manifest.json service-worker.js css fonts images js favicon.ico robots.txt),
only_matching: ["precache-manifest"]
)
# Code reloading can be explicitly enabled under the

View File

@@ -30,6 +30,10 @@ defmodule Mobilizon.Web.Router do
pipeline :activity_pub_and_html do
plug(:accepts, ["html", "activity-json"])
plug(Cldr.Plug.AcceptLanguage,
cldr_backend: Mobilizon.Cldr
)
end
pipeline :atom_and_ical do
@@ -38,6 +42,11 @@ defmodule Mobilizon.Web.Router do
pipeline :browser do
plug(Plug.Static, at: "/", from: "priv/static")
plug(Cldr.Plug.AcceptLanguage,
cldr_backend: Mobilizon.Cldr
)
plug(:accepts, ["html"])
plug(:fetch_session)
plug(:fetch_flash)
@@ -72,8 +81,13 @@ defmodule Mobilizon.Web.Router do
pipe_through(:activity_pub_signature)
get("/@:name", PageController, :actor)
get("/events/me", PageController, :index)
get("/events/:uuid", PageController, :event)
get("/comments/:uuid", PageController, :comment)
get("/resource/:uuid", PageController, :resource, as: "resource")
get("/todo-list/:uuid", PageController, :todo_list, as: "todo_list")
get("/todo/:uuid", PageController, :todo, as: "todo")
get("/@:name/resources", PageController, :resources)
end
scope "/", Mobilizon.Web do
@@ -82,6 +96,8 @@ defmodule Mobilizon.Web.Router do
get("/@:name/outbox", ActivityPubController, :outbox)
get("/@:name/following", ActivityPubController, :following)
get("/@:name/followers", ActivityPubController, :followers)
get("/@:name/members", ActivityPubController, :members)
get("/@:name/todo-lists", ActivityPubController, :todo_lists)
end
scope "/", Mobilizon.Web do
@@ -131,6 +147,7 @@ defmodule Mobilizon.Web.Router do
)
get("/validate/email/:token", PageController, :index, as: "user_email_validation")
get("/groups/me", PageController, :index, as: "my_groups")
get("/interact", PageController, :interact)
end

View File

@@ -0,0 +1,74 @@
<!-- 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 "Upcoming event" %>
</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 "Get ready for %{title}", title: @participant.event.title %>
</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="<%= page_url(Mobilizon.Web.Endpoint, :event, @participant.event.uuid) %>" 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 "Go to event page" %>
</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,5 @@
<%= gettext "Upcoming event" %>
==
<%= gettext "Get ready for %{title}", title: @participant.event.title %>
<%= gettext "View the event on: %{link}", link: page_url(Mobilizon.Web.Endpoint, :event, @participant.event.uuid) %>
<%= gettext "If you need to cancel your participation, just access the event page through link above and click on the participation button." %>

Some files were not shown because too many files have changed in this diff Show More