From 12116ba6fa22f23e4e491f0c98c6eefac29dc7aa Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
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 <tcit@tcit.fr>
---
 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