From 12116ba6fa22f23e4e491f0c98c6eefac29dc7aa Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Thu, 25 Apr 2019 19:05:05 +0200 Subject: [PATCH] Add visibility to actors Also use url helpers to generate urls properly Signed-off-by: Thomas Citharel --- config/test.exs | 5 +- lib/mobilizon/actors/actor.ex | 82 ++++++++- lib/mobilizon/actors/actors.ex | 3 +- lib/mobilizon/events/comment.ex | 4 +- .../controllers/activity_pub_controller.ex | 8 +- .../views/activity_pub/actor_view.ex | 159 ++++++++---------- .../views/activity_pub/object_view.ex | 26 +++ lib/service/activity_pub/activity_pub.ex | 82 +-------- lib/service/activity_pub/utils.ex | 12 +- lib/service/export/feed.ex | 1 + lib/service/export/icalendar.ex | 6 +- ...20190425075451_add_visibility_to_actor.exs | 21 +++ test/mobilizon/actors/actors_test.exs | 4 +- .../service/activity_pub/utils_test.exs | 4 +- .../activity_pub_controller_test.exs | 123 +++++++++++--- .../controllers/feed_controller_test.exs | 50 +++++- .../controllers/page_controller_test.exs | 13 +- .../resolvers/group_resolver_test.exs | 9 +- test/support/factory.ex | 14 +- 19 files changed, 392 insertions(+), 234 deletions(-) create mode 100644 priv/repo/migrations/20190425075451_add_visibility_to_actor.exs diff --git a/config/test.exs b/config/test.exs index 1ace33015..9e1c31306 100644 --- a/config/test.exs +++ b/config/test.exs @@ -8,11 +8,10 @@ config :mobilizon, :instance, # you can enable the server option below. config :mobilizon, MobilizonWeb.Endpoint, http: [ - port: System.get_env("MOBILIZON_INSTANCE_PORT") || 4002 + port: System.get_env("MOBILIZON_INSTANCE_PORT") || 80 ], url: [ - host: System.get_env("MOBILIZON_INSTANCE_HOST") || "mobilizon.test", - port: System.get_env("MOBILIZON_INSTANCE_PORT") || 4002 + host: System.get_env("MOBILIZON_INSTANCE_HOST") || "mobilizon.test" ], server: false diff --git a/lib/mobilizon/actors/actor.ex b/lib/mobilizon/actors/actor.ex index d85f23f5f..9b8b5bd24 100644 --- a/lib/mobilizon/actors/actor.ex +++ b/lib/mobilizon/actors/actor.ex @@ -14,6 +14,14 @@ defenum(Mobilizon.Actors.ActorOpennessEnum, :actor_openness, [ :open ]) +defenum(Mobilizon.Actors.ActorVisibilityEnum, :actor_visibility_type, [ + :public, + :unlisted, + # Probably unused + :restricted, + :private +]) + defmodule Mobilizon.Actors.Actor do @moduledoc """ Represents an actor (local and remote actors) @@ -26,6 +34,9 @@ defmodule Mobilizon.Actors.Actor do alias Mobilizon.Actors.{Actor, Follower, Member} alias Mobilizon.Events.{Event, FeedToken} + alias MobilizonWeb.Router.Helpers, as: Routes + alias MobilizonWeb.Endpoint + import Ecto.Query import Mobilizon.Ecto alias Mobilizon.Repo @@ -49,6 +60,7 @@ defmodule Mobilizon.Actors.Actor do field(:keys, :string) field(:manually_approves_followers, :boolean, default: false) field(:openness, Mobilizon.Actors.ActorOpennessEnum, default: :moderated) + field(:visibility, Mobilizon.Actors.ActorVisibilityEnum, default: :private) field(:suspended, :boolean, default: false) field(:avatar_url, :string) field(:banner_url, :string) @@ -217,24 +229,43 @@ defmodule Mobilizon.Actors.Actor do @spec build_urls(Ecto.Changeset.t(), atom()) :: Ecto.Changeset.t() defp build_urls(changeset, type \\ :Person) - defp build_urls(%Ecto.Changeset{changes: %{preferred_username: username}} = changeset, type) do - symbol = if type == :Group, do: "~", else: "@" - + defp build_urls(%Ecto.Changeset{changes: %{preferred_username: username}} = changeset, _type) do changeset |> put_change( :outbox_url, - "#{MobilizonWeb.Endpoint.url()}/#{symbol}#{username}/outbox" + build_url(username, :outbox) ) |> put_change( :inbox_url, - "#{MobilizonWeb.Endpoint.url()}/#{symbol}#{username}/inbox" + build_url(username, :inbox) ) |> put_change(:shared_inbox_url, "#{MobilizonWeb.Endpoint.url()}/inbox") - |> put_change(:url, "#{MobilizonWeb.Endpoint.url()}/#{symbol}#{username}") + |> put_change(:url, build_url(username, :page)) end defp build_urls(%Ecto.Changeset{} = changeset, _type), do: changeset + @doc """ + Build an AP URL for an actor + """ + @spec build_url(String.t(), atom()) :: String.t() + def build_url(preferred_username, endpoint, args \\ []) + + def build_url(preferred_username, :page, args) do + Endpoint + |> Routes.page_url(:actor, preferred_username, args) + |> URI.decode() + end + + def build_url(username, :inbox, _args), do: "#{build_url(username, :page)}/inbox" + + def build_url(preferred_username, endpoint, args) + when endpoint in [:outbox, :following, :followers] do + Endpoint + |> Routes.activity_pub_url(endpoint, preferred_username, args) + |> URI.decode() + end + @doc """ Get a public key for a given ActivityPub actor ID (url) """ @@ -272,8 +303,24 @@ defmodule Mobilizon.Actors.Actor do If actor A and C both follow actor B, actor B's followers are A and C """ - @spec get_followers(struct(), number(), number()) :: list() + @spec get_followers(struct(), number(), number()) :: map() def get_followers(%Actor{id: actor_id} = _actor, page \\ nil, limit \\ nil) do + query = + from( + a in Actor, + join: f in Follower, + on: a.id == f.actor_id, + where: f.target_actor_id == ^actor_id + ) + + total = Task.async(fn -> Repo.aggregate(query, :count, :id) end) + elements = Task.async(fn -> Repo.all(paginate(query, page, limit)) end) + + %{total: Task.await(total), elements: Task.await(elements)} + end + + @spec get_full_followers(struct()) :: list() + def get_full_followers(%Actor{id: actor_id} = _actor) do Repo.all( from( a in Actor, @@ -281,7 +328,6 @@ defmodule Mobilizon.Actors.Actor do on: a.id == f.actor_id, where: f.target_actor_id == ^actor_id ) - |> paginate(page, limit) ) end @@ -292,6 +338,22 @@ defmodule Mobilizon.Actors.Actor do """ @spec get_followings(struct(), number(), number()) :: list() def get_followings(%Actor{id: actor_id} = _actor, page \\ nil, limit \\ nil) do + query = + from( + a in Actor, + join: f in Follower, + on: a.id == f.target_actor_id, + where: f.actor_id == ^actor_id + ) + + total = Task.async(fn -> Repo.aggregate(query, :count, :id) end) + elements = Task.async(fn -> Repo.all(paginate(query, page, limit)) end) + + %{total: Task.await(total), elements: Task.await(elements)} + end + + @spec get_full_followings(struct()) :: list() + def get_full_followings(%Actor{id: actor_id} = _actor) do Repo.all( from( a in Actor, @@ -299,7 +361,6 @@ defmodule Mobilizon.Actors.Actor do on: a.id == f.target_actor_id, where: f.actor_id == ^actor_id ) - |> paginate(page, limit) ) end @@ -390,6 +451,9 @@ defmodule Mobilizon.Actors.Actor do end end + @spec public_visibility?(struct()) :: boolean() + def public_visibility?(%Actor{visibility: visibility}), do: visibility in [:public, :unlisted] + @doc """ Return the preferred_username with the eventual @domain suffix if it's a distant actor """ diff --git a/lib/mobilizon/actors/actors.ex b/lib/mobilizon/actors/actors.ex index cc4221052..55aedc13f 100644 --- a/lib/mobilizon/actors/actors.ex +++ b/lib/mobilizon/actors/actors.ex @@ -158,7 +158,8 @@ defmodule Mobilizon.Actors do Repo.all( from( a in Actor, - where: a.type == ^:Group + where: a.type == ^:Group, + where: a.visibility in [^:public, ^:unlisted] ) |> paginate(page, limit) ) diff --git a/lib/mobilizon/events/comment.ex b/lib/mobilizon/events/comment.ex index 72bca80dd..5b2c038ee 100644 --- a/lib/mobilizon/events/comment.ex +++ b/lib/mobilizon/events/comment.ex @@ -19,6 +19,8 @@ defmodule Mobilizon.Events.Comment do alias Mobilizon.Events.Event alias Mobilizon.Actors.Actor alias Mobilizon.Events.Comment + alias MobilizonWeb.Router.Helpers, as: Routes + alias MobilizonWeb.Endpoint schema "comments" do field(:text, :string) @@ -46,7 +48,7 @@ defmodule Mobilizon.Events.Comment do url = if Map.has_key?(attrs, "url"), do: attrs["url"], - else: "#{MobilizonWeb.Endpoint.url()}/comments/#{uuid}" + else: Routes.page_url(Endpoint, :comment, uuid) comment |> Ecto.Changeset.cast(attrs, [ diff --git a/lib/mobilizon_web/controllers/activity_pub_controller.ex b/lib/mobilizon_web/controllers/activity_pub_controller.ex index 2f49d16ff..6adb0f8d2 100644 --- a/lib/mobilizon_web/controllers/activity_pub_controller.ex +++ b/lib/mobilizon_web/controllers/activity_pub_controller.ex @@ -111,8 +111,12 @@ defmodule MobilizonWeb.ActivityPubController do end end - def outbox(conn, %{"name" => username}) do - outbox(conn, %{"name" => username, "page" => "0"}) + def outbox(conn, %{"name" => name}) do + with %Actor{} = actor <- Actors.get_local_actor_by_name(name) do + conn + |> put_resp_header("content-type", "application/activity+json") + |> json(ActorView.render("outbox.json", %{actor: actor})) + end end # TODO: Ensure that this inbox is a recipient of the message diff --git a/lib/mobilizon_web/views/activity_pub/actor_view.ex b/lib/mobilizon_web/views/activity_pub/actor_view.ex index d66e13bd8..f2f772761 100644 --- a/lib/mobilizon_web/views/activity_pub/actor_view.ex +++ b/lib/mobilizon_web/views/activity_pub/actor_view.ex @@ -1,23 +1,23 @@ defmodule MobilizonWeb.ActivityPub.ActorView do use MobilizonWeb, :view - alias MobilizonWeb.ActivityPub.ActorView - alias MobilizonWeb.ActivityPub.ObjectView alias Mobilizon.Actors.Actor alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub.Utils alias Mobilizon.Activity + @private_visibility_empty_collection %{elements: [], total: 0} + def render("actor.json", %{actor: actor}) do public_key = Mobilizon.Service.ActivityPub.Utils.pem_to_public_key_pem(actor.keys) %{ - "id" => actor.url, + "id" => Actor.build_url(actor.preferred_username, :page), "type" => "Person", - "following" => actor.following_url, - "followers" => actor.followers_url, - "inbox" => actor.inbox_url, - "outbox" => actor.outbox_url, + "following" => Actor.build_url(actor.preferred_username, :following), + "followers" => Actor.build_url(actor.preferred_username, :followers), + "inbox" => Actor.build_url(actor.preferred_username, :inbox), + "outbox" => Actor.build_url(actor.preferred_username, :outbox), "preferredUsername" => actor.preferred_username, "name" => actor.name, "summary" => actor.summary, @@ -46,125 +46,102 @@ defmodule MobilizonWeb.ActivityPub.ActorView do end def render("following.json", %{actor: actor, page: page}) do - actor - |> Actor.get_followings(page) - |> collection(actor.following_url, page) + %{total: total, elements: following} = + if Actor.public_visibility?(actor), + do: Actor.get_followings(actor, page), + else: @private_visibility_empty_collection + + following + |> collection(actor.preferred_username, :following, page, total) |> Map.merge(Utils.make_json_ld_header()) end def render("following.json", %{actor: actor}) do - following = Actor.get_followings(actor) + %{total: total, elements: following} = + if Actor.public_visibility?(actor), + do: Actor.get_followings(actor), + else: @private_visibility_empty_collection %{ - "id" => actor.following_url, + "id" => Actor.build_url(actor.preferred_username, :following), "type" => "OrderedCollection", - "totalItems" => length(following), - "first" => collection(following, actor.following_url, 1) + "totalItems" => total, + "first" => collection(following, actor.preferred_username, :following, 1, total) } |> Map.merge(Utils.make_json_ld_header()) end def render("followers.json", %{actor: actor, page: page}) do - actor - |> Actor.get_followers(page) - |> collection(actor.followers_url, page) + %{total: total, elements: followers} = + if Actor.public_visibility?(actor), + do: Actor.get_followers(actor, page), + else: @private_visibility_empty_collection + + followers + |> collection(actor.preferred_username, :followers, page, total) |> Map.merge(Utils.make_json_ld_header()) end def render("followers.json", %{actor: actor}) do - followers = Actor.get_followers(actor) + %{total: total, elements: followers} = + if Actor.public_visibility?(actor), + do: Actor.get_followers(actor), + else: @private_visibility_empty_collection %{ - "id" => actor.followers_url, + "id" => Actor.build_url(actor.preferred_username, :followers), "type" => "OrderedCollection", - # TODO put me back - # "totalItems" => length(followers), - "first" => collection(followers, actor.followers_url, 1) + "totalItems" => total, + "first" => collection(followers, actor.preferred_username, :followers, 1, total) } |> Map.merge(Utils.make_json_ld_header()) end def render("outbox.json", %{actor: actor, page: page}) do - {page, no_page} = - if page == 0 do - {1, true} - else - {page, false} - end + %{total: total, elements: followers} = + if Actor.public_visibility?(actor), + do: ActivityPub.fetch_public_activities_for_actor(actor, page), + else: @private_visibility_empty_collection - {activities, total} = ActivityPub.fetch_public_activities_for_actor(actor, page) - - # collection = - # Enum.map(activities, fn act -> - # {:ok, data} = Transmogrifier.prepare_outgoing(act.data) - # data - # end) - - iri = "#{actor.url}/outbox" - - page = %{ - "id" => "#{iri}?page=#{page}", - "type" => "OrderedCollectionPage", - "partOf" => iri, - "totalItems" => total, - "orderedItems" => render_many(activities, ActorView, "activity.json", as: :activity), - "next" => "#{iri}?page=#{page + 1}" - } - - if no_page do - %{ - "id" => iri, - "type" => "OrderedCollection", - "totalItems" => total, - "first" => page - } - |> Map.merge(Utils.make_json_ld_header()) - else - page |> Map.merge(Utils.make_json_ld_header()) - end + followers + |> collection(actor.preferred_username, :outbox, page, total) + |> Map.merge(Utils.make_json_ld_header()) end - def render("activity.json", %{activity: %Activity{local: local, data: data} = activity}) do - %{ - "id" => data["id"], - "type" => - if local do - "Create" - else - "Announce" - end, - "actor" => activity.actor, - # Not sure if needed since this is used into outbox - "published" => Timex.now(), - "to" => activity.recipients, - "object" => - case data["type"] do - "Event" -> - render_one(data, ObjectView, "event.json", as: :event) + def render("outbox.json", %{actor: actor}) do + %{total: total, elements: followers} = + if Actor.public_visibility?(actor), + do: ActivityPub.fetch_public_activities_for_actor(actor), + else: @private_visibility_empty_collection - "Note" -> - render_one(data, ObjectView, "comment.json", as: :comment) - end + %{ + "id" => Actor.build_url(actor.preferred_username, :outbox), + "type" => "OrderedCollection", + "totalItems" => total, + "first" => collection(followers, actor.preferred_username, :outbox, 1, total) } |> Map.merge(Utils.make_json_ld_header()) end - def collection(collection, iri, page, _total \\ nil) do - items = Enum.map(collection, fn account -> account.url end) + @spec collection(list(), String.t(), atom(), integer(), integer()) :: map() + defp collection(collection, preferred_username, endpoint, page, total) + when endpoint in [:followers, :following, :outbox] do + offset = (page - 1) * 10 - # TODO : Add me back - # total = total || length(collection) - - %{ - "id" => "#{iri}?page=#{page}", + map = %{ + "id" => Actor.build_url(preferred_username, endpoint, page: page), "type" => "OrderedCollectionPage", - "partOf" => iri, - # "totalItems" => total, - "orderedItems" => items + "partOf" => Actor.build_url(preferred_username, endpoint), + "orderedItems" => Enum.map(collection, &item/1) } - # if offset < total do - # Map.put(map, "next", "#{iri}?page=#{page + 1}") - # end + if offset < total do + Map.put(map, "next", Actor.build_url(preferred_username, endpoint, page: page + 1)) + end + + map end + + def item(%Activity{data: %{"id" => id}}), do: id + def item(%Actor{url: url}), do: url end diff --git a/lib/mobilizon_web/views/activity_pub/object_view.ex b/lib/mobilizon_web/views/activity_pub/object_view.ex index a0372e96b..9e7de95b8 100644 --- a/lib/mobilizon_web/views/activity_pub/object_view.ex +++ b/lib/mobilizon_web/views/activity_pub/object_view.ex @@ -1,6 +1,7 @@ defmodule MobilizonWeb.ActivityPub.ObjectView do use MobilizonWeb, :view alias Mobilizon.Service.ActivityPub.Utils + alias Mobilizon.Activity def render("event.json", %{event: event}) do {:ok, html, []} = Earmark.as_html(event["summary"]) @@ -40,4 +41,29 @@ defmodule MobilizonWeb.ActivityPub.ObjectView do Map.merge(comment, Utils.make_json_ld_header()) end + + def render("activity.json", %{activity: %Activity{local: local, data: data} = activity}) do + %{ + "id" => data["id"], + "type" => + if local do + "Create" + else + "Announce" + end, + "actor" => activity.actor, + # Not sure if needed since this is used into outbox + "published" => Timex.now(), + "to" => activity.recipients, + "object" => + case data["type"] do + "Event" -> + render_one(data, ObjectView, "event.json", as: :event) + + "Note" -> + render_one(data, ObjectView, "comment.json", as: :comment) + end + } + |> Map.merge(Utils.make_json_ld_header()) + end end diff --git a/lib/service/activity_pub/activity_pub.ex b/lib/service/activity_pub/activity_pub.ex index 193806762..b360c58d8 100644 --- a/lib/service/activity_pub/activity_pub.ex +++ b/lib/service/activity_pub/activity_pub.ex @@ -383,7 +383,7 @@ defmodule Mobilizon.Service.ActivityPub do followers = if actor.followers_url in activity.recipients do - Actor.get_followers(actor) |> Enum.filter(fn follower -> is_nil(follower.domain) end) + Actor.get_full_followers(actor) |> Enum.filter(fn follower -> is_nil(follower.domain) end) else [] end @@ -492,50 +492,18 @@ defmodule Mobilizon.Service.ActivityPub do @doc """ Return all public activities (events & comments) for an actor """ - @spec fetch_public_activities_for_actor(Actor.t(), integer(), integer()) :: {list(), integer()} - def fetch_public_activities_for_actor(actor, page \\ nil, limit \\ nil) + @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.get_public_events_for_actor(actor, page, limit) + {:ok, comments, total_comments} = Events.get_public_comments_for_actor(actor, page, limit) - def fetch_public_activities_for_actor(%Actor{} = actor, page, limit) do - case actor.type do - :Person -> - {:ok, events, total_events} = Events.get_public_events_for_actor(actor, page, limit) - {:ok, comments, total_comments} = Events.get_public_comments_for_actor(actor, page, limit) + event_activities = Enum.map(events, &event_to_activity/1) - event_activities = Enum.map(events, &event_to_activity/1) + comment_activities = Enum.map(comments, &comment_to_activity/1) - comment_activities = Enum.map(comments, &comment_to_activity/1) + activities = event_activities ++ comment_activities - activities = event_activities ++ comment_activities - - {activities, total_events + total_comments} - - :Service -> - bot = Actors.get_bot_by_actor(actor) - - case bot.type do - "ics" -> - {:ok, %HTTPoison.Response{body: body} = _resp} = HTTPoison.get(bot.source) - - ical_events = - body - |> ExIcal.parse() - |> ExIcal.by_range( - DateTime.utc_now(), - DateTime.utc_now() |> DateTime.truncate(:second) |> Timex.shift(years: 1) - ) - - activities = - ical_events - |> Enum.chunk_every(limit) - |> Enum.at(page - 1) - |> Enum.map(fn event -> - {:ok, activity} = ical_event_to_activity(event, actor, bot.source) - activity - end) - - {activities, length(ical_events)} - end - end + %{elements: activities, total: total_events + total_comments} end # Create an activity from an event @@ -560,38 +528,6 @@ defmodule Mobilizon.Service.ActivityPub do } end - defp ical_event_to_activity(%ExIcal.Event{} = ical_event, %Actor{} = actor, _source) do - # Logger.debug(inspect ical_event) - # TODO : Use MobilizonWeb.API instead - # TODO : refactor me and move me somewhere else! - # TODO : also, there should be a form of cache that allows this to be more efficient - - # ical_event.categories should be tags - - {:ok, event} = - Events.create_event(%{ - begins_on: ical_event.start, - ends_on: ical_event.end, - inserted_at: ical_event.stamp, - updated_at: ical_event.stamp, - description: ical_event.description |> sanitize_ical_event_strings, - title: ical_event.summary |> sanitize_ical_event_strings, - organizer_actor: actor - }) - - event_to_activity(event, false) - end - - defp sanitize_ical_event_strings(string) when is_binary(string) do - string - |> String.replace(~s"\r\n", "") - |> String.replace(~s"\\,", ",") - end - - defp sanitize_ical_event_strings(nil) do - nil - end - # # Whether the Public audience is in the activity's audience # defp is_public?(activity) do # "https://www.w3.org/ns/activitystreams#Public" in (activity.data["to"] ++ diff --git a/lib/service/activity_pub/utils.ex b/lib/service/activity_pub/utils.ex index 35cb8e8e4..f9ee9915b 100644 --- a/lib/service/activity_pub/utils.ex +++ b/lib/service/activity_pub/utils.ex @@ -20,6 +20,8 @@ defmodule Mobilizon.Service.ActivityPub.Utils do alias Mobilizon.Service.ActivityPub alias Ecto.Changeset require Logger + alias MobilizonWeb.Router.Helpers, as: Routes + alias MobilizonWeb.Endpoint # Some implementations send the actor URI as the actor field, others send the entire actor object, # so figure out what the actor's URI is based on what we have. @@ -275,7 +277,7 @@ defmodule Mobilizon.Service.ActivityPub.Utils do "begins_on" => metadata.begins_on, "category" => category, "actor" => actor, - "id" => "#{MobilizonWeb.Endpoint.url()}/events/#{uuid}", + "id" => Routes.page_url(Endpoint, :event, uuid), "uuid" => uuid, "tag" => tags |> Enum.map(fn {_, tag} -> tag end) |> Enum.uniq() } @@ -296,7 +298,7 @@ defmodule Mobilizon.Service.ActivityPub.Utils do "summary" => event.description, "publish_at" => (event.publish_at || event.inserted_at) |> DateTime.to_iso8601(), "updated_at" => event.updated_at |> DateTime.to_iso8601(), - "id" => "#{MobilizonWeb.Endpoint.url()}/events/#{event.uuid}" + "id" => Routes.page_url(Endpoint, :event, event.uuid) } end @@ -320,7 +322,7 @@ defmodule Mobilizon.Service.ActivityPub.Utils do "actor" => actor.url, "attributedTo" => actor.url, "uuid" => uuid, - "id" => "#{MobilizonWeb.Endpoint.url()}/comments/#{uuid}" + "id" => Routes.page_url(Endpoint, :comment, uuid) } if reply_to do @@ -354,7 +356,7 @@ defmodule Mobilizon.Service.ActivityPub.Utils do # "summary" => cw, # "attachment" => attachments, "actor" => actor, - "id" => "#{MobilizonWeb.Endpoint.url()}/comments/#{uuid}", + "id" => Routes.page_url(Endpoint, :comment, uuid), "uuid" => uuid, "tag" => tags |> Enum.map(fn {_, tag} -> tag end) |> Enum.uniq() } @@ -386,7 +388,7 @@ defmodule Mobilizon.Service.ActivityPub.Utils do "summary" => content_html, "attributedTo" => actor, "preferredUsername" => preferred_username, - "id" => "#{MobilizonWeb.Endpoint.url()}/~#{preferred_username}", + "id" => Actor.build_url(preferred_username, :page), "uuid" => uuid, "tag" => tags |> Enum.map(fn {_, tag} -> tag end) |> Enum.uniq() } diff --git a/lib/service/export/feed.ex b/lib/service/export/feed.ex index 398e67408..ef52dd8c0 100644 --- a/lib/service/export/feed.ex +++ b/lib/service/export/feed.ex @@ -41,6 +41,7 @@ defmodule Mobilizon.Service.Export.Feed do @spec fetch_actor_event_feed(String.t()) :: String.t() defp fetch_actor_event_feed(name) do with %Actor{} = actor <- Actors.get_local_actor_by_name(name), + {:visibility, true} <- {:visibility, Actor.public_visibility?(actor)}, {:ok, events, _count} <- Events.get_public_events_for_actor(actor) do {:ok, build_actor_feed(actor, events)} else diff --git a/lib/service/export/icalendar.ex b/lib/service/export/icalendar.ex index 2027e576c..d275ab9a0 100644 --- a/lib/service/export/icalendar.ex +++ b/lib/service/export/icalendar.ex @@ -40,12 +40,12 @@ defmodule Mobilizon.Service.Export.ICalendar do @doc """ Export a public actor's events to iCalendar format. - The events must have a visibility of `:public` or `:unlisted` + The actor must have a visibility of `:public` or `:unlisted`, as well as the events """ - # TODO: The actor should also have visibility options @spec export_public_actor(Actor.t()) :: String.t() def export_public_actor(%Actor{} = actor) do - with {:ok, events, _} <- Events.get_public_events_for_actor(actor) do + with true <- Actor.public_visibility?(actor), + {:ok, events, _} <- Events.get_public_events_for_actor(actor) do {:ok, %ICalendar{events: events |> Enum.map(&do_export_event/1)} |> ICalendar.to_ics()} end end diff --git a/priv/repo/migrations/20190425075451_add_visibility_to_actor.exs b/priv/repo/migrations/20190425075451_add_visibility_to_actor.exs new file mode 100644 index 000000000..d100b8191 --- /dev/null +++ b/priv/repo/migrations/20190425075451_add_visibility_to_actor.exs @@ -0,0 +1,21 @@ +defmodule Mobilizon.Repo.Migrations.AddVisibilityToActor do + use Ecto.Migration + + alias Mobilizon.Actors.ActorVisibilityEnum + + def up do + ActorVisibilityEnum.create_type() + + alter table(:actors) do + add(:visibility, ActorVisibilityEnum.type(), default: "private") + end + end + + def down do + alter table(:actors) do + remove(:visibility) + end + + ActorVisibilityEnum.drop_type() + end +end diff --git a/test/mobilizon/actors/actors_test.exs b/test/mobilizon/actors/actors_test.exs index 80289ec75..5c4bf41b4 100644 --- a/test/mobilizon/actors/actors_test.exs +++ b/test/mobilizon/actors/actors_test.exs @@ -421,8 +421,8 @@ defmodule Mobilizon.ActorsTest do assert follower.approved == true assert follower.score == 42 - assert [target_actor] = Actor.get_followings(actor) - assert [actor] = Actor.get_followers(target_actor) + assert %{total: 1, elements: [target_actor]} = Actor.get_followings(actor) + assert %{total: 1, elements: [actor]} = Actor.get_followers(target_actor) end test "create_follower/1 with valid data but same actors fails to create a follower", %{ diff --git a/test/mobilizon/service/activity_pub/utils_test.exs b/test/mobilizon/service/activity_pub/utils_test.exs index 642531ef8..08a197fc3 100644 --- a/test/mobilizon/service/activity_pub/utils_test.exs +++ b/test/mobilizon/service/activity_pub/utils_test.exs @@ -3,6 +3,8 @@ defmodule Mobilizon.Service.ActivityPub.UtilsTest do import Mobilizon.Factory alias Mobilizon.Service.ActivityPub.Utils use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney + alias MobilizonWeb.Router.Helpers, as: Routes + alias MobilizonWeb.Endpoint setup_all do HTTPoison.start() @@ -19,7 +21,7 @@ defmodule Mobilizon.Service.ActivityPub.UtilsTest do "content" => reply.text, "actor" => reply.actor.url, "uuid" => reply.uuid, - "id" => "#{MobilizonWeb.Endpoint.url()}/comments/#{reply.uuid}", + "id" => Routes.page_url(Endpoint, :comment, reply.uuid), "inReplyTo" => comment.url, "attributedTo" => reply.actor.url } == Utils.make_comment_data(reply) diff --git a/test/mobilizon_web/controllers/activity_pub_controller_test.exs b/test/mobilizon_web/controllers/activity_pub_controller_test.exs index 096cce389..ea99a3ae0 100644 --- a/test/mobilizon_web/controllers/activity_pub_controller_test.exs +++ b/test/mobilizon_web/controllers/activity_pub_controller_test.exs @@ -12,6 +12,8 @@ defmodule MobilizonWeb.ActivityPubControllerTest do alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub.Utils use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney + alias MobilizonWeb.Router.Helpers, as: Routes + alias MobilizonWeb.Endpoint setup do conn = build_conn() |> put_req_header("accept", "application/activity+json") @@ -24,7 +26,7 @@ defmodule MobilizonWeb.ActivityPubControllerTest do conn = conn - |> get("/@#{actor.preferred_username}") + |> get(Actor.build_url(actor.preferred_username, :page)) actor = Actors.get_actor!(actor.id) @@ -38,7 +40,7 @@ defmodule MobilizonWeb.ActivityPubControllerTest do conn = conn - |> get("/events/#{event.uuid}") + |> get(Routes.page_url(Endpoint, :event, event.uuid)) assert json_response(conn, 200) == ObjectView.render("event.json", %{event: event |> Utils.make_event_data()}) @@ -49,7 +51,7 @@ defmodule MobilizonWeb.ActivityPubControllerTest do conn = conn - |> get("/events/#{event.uuid}") + |> get(Routes.page_url(Endpoint, :event, event.uuid)) assert json_response(conn, 404) end @@ -61,7 +63,7 @@ defmodule MobilizonWeb.ActivityPubControllerTest do conn = conn - |> get("/comments/#{comment.uuid}") + |> get(Routes.page_url(Endpoint, :comment, comment.uuid)) assert json_response(conn, 200) == ObjectView.render("comment.json", %{comment: comment |> Utils.make_comment_data()}) @@ -88,7 +90,7 @@ defmodule MobilizonWeb.ActivityPubControllerTest do conn = conn |> assign(:valid_signature, true) - |> post("/inbox", data) + |> post("#{MobilizonWeb.Endpoint.url()}/inbox", data) assert "ok" == json_response(conn, 200) :timer.sleep(500) @@ -99,44 +101,106 @@ defmodule MobilizonWeb.ActivityPubControllerTest do describe "/@:preferred_username/outbox" do test "it returns a note activity in a collection", %{conn: conn} do - actor = insert(:actor) + actor = insert(:actor, visibility: :public) comment = insert(:comment, actor: actor) conn = conn - |> get("/@#{actor.preferred_username}/outbox") + |> get(Actor.build_url(actor.preferred_username, :outbox)) - assert response(conn, 200) =~ comment.text + assert json_response(conn, 200)["totalItems"] == 1 + assert json_response(conn, 200)["first"]["orderedItems"] == [comment.url] end test "it returns an event activity in a collection", %{conn: conn} do - actor = insert(:actor) + actor = insert(:actor, visibility: :public) event = insert(:event, organizer_actor: actor) conn = conn - |> get("/@#{actor.preferred_username}/outbox") + |> get(Actor.build_url(actor.preferred_username, :outbox)) - assert response(conn, 200) =~ event.title + assert json_response(conn, 200)["totalItems"] == 1 + assert json_response(conn, 200)["first"]["orderedItems"] == [event.url] + end + + test "it works for more than 10 events", %{conn: conn} do + actor = insert(:actor, visibility: :public) + + Enum.each(1..15, fn _ -> + insert(:event, organizer_actor: actor) + end) + + result = + conn + |> get(Actor.build_url(actor.preferred_username, :outbox)) + |> json_response(200) + + assert length(result["first"]["orderedItems"]) == 10 + assert result["totalItems"] == 15 + + result = + conn + |> get(Actor.build_url(actor.preferred_username, :outbox, page: 2)) + |> json_response(200) + + assert length(result["orderedItems"]) == 5 + end + + test "it returns an empty collection if the actor has private visibility", %{conn: conn} do + actor = insert(:actor, visibility: :private) + insert(:event, organizer_actor: actor) + + conn = + conn + |> get(Actor.build_url(actor.preferred_username, :outbox)) + + assert json_response(conn, 200)["totalItems"] == 0 + assert json_response(conn, 200)["first"]["orderedItems"] == [] + end + + test "it doesn't returns an event activity in a collection if actor has private visibility", + %{conn: conn} do + actor = insert(:actor, visibility: :private) + insert(:event, organizer_actor: actor) + + conn = + conn + |> get(Actor.build_url(actor.preferred_username, :outbox)) + + assert json_response(conn, 200)["totalItems"] == 0 end end describe "/@actor/followers" do test "it returns the followers in a collection", %{conn: conn} do - actor = insert(:actor) + actor = insert(:actor, visibility: :public) actor2 = insert(:actor) Actor.follow(actor, actor2) result = conn - |> get("/@#{actor.preferred_username}/followers") + |> get(Actor.build_url(actor.preferred_username, :followers)) |> json_response(200) assert result["first"]["orderedItems"] == [actor2.url] end + test "it returns no followers for a private actor", %{conn: conn} do + actor = insert(:actor, visibility: :private) + actor2 = insert(:actor) + Actor.follow(actor, actor2) + + result = + conn + |> get(Actor.build_url(actor.preferred_username, :followers)) + |> json_response(200) + + assert result["first"]["orderedItems"] == [] + end + test "it works for more than 10 actors", %{conn: conn} do - actor = insert(:actor) + actor = insert(:actor, visibility: :public) Enum.each(1..15, fn _ -> other_actor = insert(:actor) @@ -145,39 +209,50 @@ defmodule MobilizonWeb.ActivityPubControllerTest do result = conn - |> get("/@#{actor.preferred_username}/followers") + |> get(Actor.build_url(actor.preferred_username, :followers)) |> json_response(200) assert length(result["first"]["orderedItems"]) == 10 - # assert result["first"]["totalItems"] == 15 - # assert result["totalItems"] == 15 + assert result["totalItems"] == 15 result = conn - |> get("/@#{actor.preferred_username}/followers?page=2") + |> get(Actor.build_url(actor.preferred_username, :followers, page: 2)) |> json_response(200) assert length(result["orderedItems"]) == 5 - # assert result["totalItems"] == 15 end end describe "/@actor/following" do test "it returns the followings in a collection", %{conn: conn} do actor = insert(:actor) - actor2 = insert(:actor) + actor2 = insert(:actor, visibility: :public) Actor.follow(actor, actor2) result = conn - |> get("/@#{actor2.preferred_username}/following") + |> get(Actor.build_url(actor2.preferred_username, :following)) |> json_response(200) assert result["first"]["orderedItems"] == [actor.url] end - test "it works for more than 10 actors", %{conn: conn} do + test "it returns no followings for a private actor", %{conn: conn} do actor = insert(:actor) + actor2 = insert(:actor, visibility: :private) + Actor.follow(actor, actor2) + + result = + conn + |> get(Actor.build_url(actor2.preferred_username, :following)) + |> json_response(200) + + assert result["first"]["orderedItems"] == [] + end + + test "it works for more than 10 actors", %{conn: conn} do + actor = insert(:actor, visibility: :public) Enum.each(1..15, fn _ -> other_actor = insert(:actor) @@ -186,7 +261,7 @@ defmodule MobilizonWeb.ActivityPubControllerTest do result = conn - |> get("/@#{actor.preferred_username}/following") + |> get(Actor.build_url(actor.preferred_username, :following)) |> json_response(200) assert length(result["first"]["orderedItems"]) == 10 @@ -195,7 +270,7 @@ defmodule MobilizonWeb.ActivityPubControllerTest do result = conn - |> get("/@#{actor.preferred_username}/following?page=2") + |> get(Actor.build_url(actor.preferred_username, :following, page: 2)) |> json_response(200) assert length(result["orderedItems"]) == 5 diff --git a/test/mobilizon_web/controllers/feed_controller_test.exs b/test/mobilizon_web/controllers/feed_controller_test.exs index fb0504586..05aeeb031 100644 --- a/test/mobilizon_web/controllers/feed_controller_test.exs +++ b/test/mobilizon_web/controllers/feed_controller_test.exs @@ -5,8 +5,9 @@ defmodule MobilizonWeb.FeedControllerTest do alias MobilizonWeb.Endpoint describe "/@:preferred_username/feed/atom" do - test "it returns an RSS representation of the actor's public events", %{conn: conn} do - actor = insert(:actor) + test "it returns an RSS representation of the actor's public events if the actor is publicly visible", + %{conn: conn} do + actor = insert(:actor, visibility: :public) tag1 = insert(:tag, title: "RSS", slug: "rss") tag2 = insert(:tag, title: "ATOM", slug: "atom") event1 = insert(:event, organizer_actor: actor, tags: [tag1]) @@ -36,9 +37,27 @@ defmodule MobilizonWeb.FeedControllerTest do assert entry2.categories == [tag1.slug] end - test "it returns an RSS representation of the actor's public events with the proper accept header", + test "it returns a 404 for the actor's public events Atom feed if the actor is not publicly visible", %{conn: conn} do actor = insert(:actor) + tag1 = insert(:tag, title: "RSS", slug: "rss") + tag2 = insert(:tag, title: "ATOM", slug: "atom") + insert(:event, organizer_actor: actor, tags: [tag1]) + insert(:event, organizer_actor: actor, tags: [tag1, tag2]) + + conn = + conn + |> get( + Routes.feed_url(Endpoint, :actor, actor.preferred_username, "atom") + |> URI.decode() + ) + + assert response(conn, 404) + end + + test "it returns an RSS representation of the actor's public events with the proper accept header", + %{conn: conn} do + actor = insert(:actor, visibility: :unlisted) conn = conn @@ -63,8 +82,9 @@ defmodule MobilizonWeb.FeedControllerTest do end describe "/@:preferred_username/feed/ics" do - test "it returns an iCalendar representation of the actor's public events", %{conn: conn} do - actor = insert(:actor) + test "it returns an iCalendar representation of the actor's public events with an actor publicly visible", + %{conn: conn} do + actor = insert(:actor, visibility: :public) tag1 = insert(:tag, title: "iCalendar", slug: "icalendar") tag2 = insert(:tag, title: "Apple", slug: "apple") event1 = insert(:event, organizer_actor: actor, tags: [tag1]) @@ -90,9 +110,27 @@ defmodule MobilizonWeb.FeedControllerTest do assert entry2.categories == [event2.category, tag1.slug, tag2.slug] end + test "it returns a 404 page for the actor's public events iCal feed with an actor not publicly visible", + %{conn: conn} do + actor = insert(:actor, visibility: :private) + tag1 = insert(:tag, title: "iCalendar", slug: "icalendar") + tag2 = insert(:tag, title: "Apple", slug: "apple") + insert(:event, organizer_actor: actor, tags: [tag1]) + insert(:event, organizer_actor: actor, tags: [tag1, tag2]) + + conn = + conn + |> get( + Routes.feed_url(Endpoint, :actor, actor.preferred_username, "ics") + |> URI.decode() + ) + + assert response(conn, 404) + end + test "it returns an iCalendar representation of the actor's public events with the proper accept header", %{conn: conn} do - actor = insert(:actor) + actor = insert(:actor, visibility: :unlisted) conn = conn diff --git a/test/mobilizon_web/controllers/page_controller_test.exs b/test/mobilizon_web/controllers/page_controller_test.exs index ef2d96d54..30b7d512a 100644 --- a/test/mobilizon_web/controllers/page_controller_test.exs +++ b/test/mobilizon_web/controllers/page_controller_test.exs @@ -1,6 +1,9 @@ defmodule MobilizonWeb.PageControllerTest do use MobilizonWeb.ConnCase import Mobilizon.Factory + alias Mobilizon.Actors.Actor + alias MobilizonWeb.Router.Helpers, as: Routes + alias MobilizonWeb.Endpoint setup do conn = build_conn() |> put_req_header("accept", "text/html") @@ -14,29 +17,29 @@ defmodule MobilizonWeb.PageControllerTest do test "GET /@actor with existing actor", %{conn: conn} do actor = insert(:actor) - conn = get(conn, "/@#{actor.preferred_username}") + conn = get(conn, Actor.build_url(actor.preferred_username, :page)) assert html_response(conn, 200) end test "GET /@actor with not existing actor", %{conn: conn} do - conn = get(conn, "/@notexisting") + conn = get(conn, Actor.build_url("not_existing", :page)) assert html_response(conn, 404) end test "GET /events/:uuid", %{conn: conn} do event = insert(:event) - conn = get(conn, "/events/#{event.uuid}") + conn = get(conn, Routes.page_url(Endpoint, :event, event.uuid)) assert html_response(conn, 200) end test "GET /events/:uuid with not existing event", %{conn: conn} do - conn = get(conn, "/events/not_existing_event") + conn = get(conn, Routes.page_url(Endpoint, :event, "not_existing_event")) assert html_response(conn, 404) end test "GET /events/:uuid with event not public", %{conn: conn} do event = insert(:event, visibility: :restricted) - conn = get(conn, "/events/#{event.uuid}") + conn = get(conn, Routes.page_url(Endpoint, :event, event.uuid)) assert html_response(conn, 404) end diff --git a/test/mobilizon_web/resolvers/group_resolver_test.exs b/test/mobilizon_web/resolvers/group_resolver_test.exs index 8bfa255be..00334af92 100644 --- a/test/mobilizon_web/resolvers/group_resolver_test.exs +++ b/test/mobilizon_web/resolvers/group_resolver_test.exs @@ -58,8 +58,9 @@ defmodule MobilizonWeb.Resolvers.GroupResolverTest do assert hd(json_response(res, 200)["errors"])["message"] == "existing_group_name" end - test "list_groups/3 returns all groups", context do - group = insert(:group) + test "list_groups/3 returns all public or unlisted groups", context do + group = insert(:group, visibility: :unlisted) + insert(:group, visibility: :private) query = """ { @@ -71,7 +72,9 @@ defmodule MobilizonWeb.Resolvers.GroupResolverTest do res = context.conn - |> get("/api", AbsintheHelpers.query_skeleton(query, "person")) + |> get("/api", AbsintheHelpers.query_skeleton(query, "groups")) + + assert length(json_response(res, 200)["data"]["groups"]) == 1 assert hd(json_response(res, 200)["data"]["groups"])["preferredUsername"] == group.preferred_username diff --git a/test/support/factory.ex b/test/support/factory.ex index a94e271ef..8bcb7f9ac 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -4,6 +4,9 @@ defmodule Mobilizon.Factory do """ # with Ecto use ExMachina.Ecto, repo: Mobilizon.Repo + alias Mobilizon.Actors.Actor + alias MobilizonWeb.Router.Helpers, as: Routes + alias MobilizonWeb.Endpoint def user_factory do %Mobilizon.Users.User{ @@ -30,9 +33,10 @@ defmodule Mobilizon.Factory do followings: [], keys: pem, type: :Person, - url: MobilizonWeb.Endpoint.url() <> "/@#{preferred_username}", - followers_url: MobilizonWeb.Endpoint.url() <> "/@#{preferred_username}/followers", - following_url: MobilizonWeb.Endpoint.url() <> "/@#{preferred_username}/following", + url: Actor.build_url(preferred_username, :page), + followers_url: Actor.build_url(preferred_username, :followers), + following_url: Actor.build_url(preferred_username, :following), + outbox_url: Actor.build_url(preferred_username, :outbox), user: nil } end @@ -89,7 +93,7 @@ defmodule Mobilizon.Factory do event: build(:event), uuid: uuid, in_reply_to_comment: nil, - url: "#{MobilizonWeb.Endpoint.url()}/comments/#{uuid}" + url: Routes.page_url(Endpoint, :comment, uuid) } end @@ -109,7 +113,7 @@ defmodule Mobilizon.Factory do physical_address: build(:address), visibility: :public, tags: build_list(3, :tag), - url: "#{actor.url}/#{uuid}", + url: Routes.page_url(Endpoint, :event, uuid), uuid: uuid } end