Add visibility to actors

Also use url helpers to generate urls properly

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2019-04-25 19:05:05 +02:00
parent 7cd4df0ce9
commit 12116ba6fa
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
19 changed files with 392 additions and 234 deletions

View File

@ -8,11 +8,10 @@ config :mobilizon, :instance,
# you can enable the server option below. # you can enable the server option below.
config :mobilizon, MobilizonWeb.Endpoint, config :mobilizon, MobilizonWeb.Endpoint,
http: [ http: [
port: System.get_env("MOBILIZON_INSTANCE_PORT") || 4002 port: System.get_env("MOBILIZON_INSTANCE_PORT") || 80
], ],
url: [ url: [
host: System.get_env("MOBILIZON_INSTANCE_HOST") || "mobilizon.test", host: System.get_env("MOBILIZON_INSTANCE_HOST") || "mobilizon.test"
port: System.get_env("MOBILIZON_INSTANCE_PORT") || 4002
], ],
server: false server: false

View File

@ -14,6 +14,14 @@ defenum(Mobilizon.Actors.ActorOpennessEnum, :actor_openness, [
:open :open
]) ])
defenum(Mobilizon.Actors.ActorVisibilityEnum, :actor_visibility_type, [
:public,
:unlisted,
# Probably unused
:restricted,
:private
])
defmodule Mobilizon.Actors.Actor do defmodule Mobilizon.Actors.Actor do
@moduledoc """ @moduledoc """
Represents an actor (local and remote actors) Represents an actor (local and remote actors)
@ -26,6 +34,9 @@ defmodule Mobilizon.Actors.Actor do
alias Mobilizon.Actors.{Actor, Follower, Member} alias Mobilizon.Actors.{Actor, Follower, Member}
alias Mobilizon.Events.{Event, FeedToken} alias Mobilizon.Events.{Event, FeedToken}
alias MobilizonWeb.Router.Helpers, as: Routes
alias MobilizonWeb.Endpoint
import Ecto.Query import Ecto.Query
import Mobilizon.Ecto import Mobilizon.Ecto
alias Mobilizon.Repo alias Mobilizon.Repo
@ -49,6 +60,7 @@ defmodule Mobilizon.Actors.Actor do
field(:keys, :string) field(:keys, :string)
field(:manually_approves_followers, :boolean, default: false) field(:manually_approves_followers, :boolean, default: false)
field(:openness, Mobilizon.Actors.ActorOpennessEnum, default: :moderated) field(:openness, Mobilizon.Actors.ActorOpennessEnum, default: :moderated)
field(:visibility, Mobilizon.Actors.ActorVisibilityEnum, default: :private)
field(:suspended, :boolean, default: false) field(:suspended, :boolean, default: false)
field(:avatar_url, :string) field(:avatar_url, :string)
field(:banner_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() @spec build_urls(Ecto.Changeset.t(), atom()) :: Ecto.Changeset.t()
defp build_urls(changeset, type \\ :Person) 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
symbol = if type == :Group, do: "~", else: "@"
changeset changeset
|> put_change( |> put_change(
:outbox_url, :outbox_url,
"#{MobilizonWeb.Endpoint.url()}/#{symbol}#{username}/outbox" build_url(username, :outbox)
) )
|> put_change( |> put_change(
:inbox_url, :inbox_url,
"#{MobilizonWeb.Endpoint.url()}/#{symbol}#{username}/inbox" build_url(username, :inbox)
) )
|> put_change(:shared_inbox_url, "#{MobilizonWeb.Endpoint.url()}/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 end
defp build_urls(%Ecto.Changeset{} = changeset, _type), do: changeset 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 """ @doc """
Get a public key for a given ActivityPub actor ID (url) 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 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 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( Repo.all(
from( from(
a in Actor, a in Actor,
@ -281,7 +328,6 @@ defmodule Mobilizon.Actors.Actor do
on: a.id == f.actor_id, on: a.id == f.actor_id,
where: f.target_actor_id == ^actor_id where: f.target_actor_id == ^actor_id
) )
|> paginate(page, limit)
) )
end end
@ -292,6 +338,22 @@ defmodule Mobilizon.Actors.Actor do
""" """
@spec get_followings(struct(), number(), number()) :: list() @spec get_followings(struct(), number(), number()) :: list()
def get_followings(%Actor{id: actor_id} = _actor, page \\ nil, limit \\ nil) do 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( Repo.all(
from( from(
a in Actor, a in Actor,
@ -299,7 +361,6 @@ defmodule Mobilizon.Actors.Actor do
on: a.id == f.target_actor_id, on: a.id == f.target_actor_id,
where: f.actor_id == ^actor_id where: f.actor_id == ^actor_id
) )
|> paginate(page, limit)
) )
end end
@ -390,6 +451,9 @@ defmodule Mobilizon.Actors.Actor do
end end
end end
@spec public_visibility?(struct()) :: boolean()
def public_visibility?(%Actor{visibility: visibility}), do: visibility in [:public, :unlisted]
@doc """ @doc """
Return the preferred_username with the eventual @domain suffix if it's a distant actor Return the preferred_username with the eventual @domain suffix if it's a distant actor
""" """

View File

@ -158,7 +158,8 @@ defmodule Mobilizon.Actors do
Repo.all( Repo.all(
from( from(
a in Actor, a in Actor,
where: a.type == ^:Group where: a.type == ^:Group,
where: a.visibility in [^:public, ^:unlisted]
) )
|> paginate(page, limit) |> paginate(page, limit)
) )

View File

@ -19,6 +19,8 @@ defmodule Mobilizon.Events.Comment do
alias Mobilizon.Events.Event alias Mobilizon.Events.Event
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Events.Comment alias Mobilizon.Events.Comment
alias MobilizonWeb.Router.Helpers, as: Routes
alias MobilizonWeb.Endpoint
schema "comments" do schema "comments" do
field(:text, :string) field(:text, :string)
@ -46,7 +48,7 @@ defmodule Mobilizon.Events.Comment do
url = url =
if Map.has_key?(attrs, "url"), if Map.has_key?(attrs, "url"),
do: attrs["url"], do: attrs["url"],
else: "#{MobilizonWeb.Endpoint.url()}/comments/#{uuid}" else: Routes.page_url(Endpoint, :comment, uuid)
comment comment
|> Ecto.Changeset.cast(attrs, [ |> Ecto.Changeset.cast(attrs, [

View File

@ -111,8 +111,12 @@ defmodule MobilizonWeb.ActivityPubController do
end end
end end
def outbox(conn, %{"name" => username}) do def outbox(conn, %{"name" => name}) do
outbox(conn, %{"name" => username, "page" => "0"}) 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 end
# TODO: Ensure that this inbox is a recipient of the message # TODO: Ensure that this inbox is a recipient of the message

View File

@ -1,23 +1,23 @@
defmodule MobilizonWeb.ActivityPub.ActorView do defmodule MobilizonWeb.ActivityPub.ActorView do
use MobilizonWeb, :view use MobilizonWeb, :view
alias MobilizonWeb.ActivityPub.ActorView
alias MobilizonWeb.ActivityPub.ObjectView
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub
alias Mobilizon.Service.ActivityPub.Utils alias Mobilizon.Service.ActivityPub.Utils
alias Mobilizon.Activity alias Mobilizon.Activity
@private_visibility_empty_collection %{elements: [], total: 0}
def render("actor.json", %{actor: actor}) do def render("actor.json", %{actor: actor}) do
public_key = Mobilizon.Service.ActivityPub.Utils.pem_to_public_key_pem(actor.keys) 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", "type" => "Person",
"following" => actor.following_url, "following" => Actor.build_url(actor.preferred_username, :following),
"followers" => actor.followers_url, "followers" => Actor.build_url(actor.preferred_username, :followers),
"inbox" => actor.inbox_url, "inbox" => Actor.build_url(actor.preferred_username, :inbox),
"outbox" => actor.outbox_url, "outbox" => Actor.build_url(actor.preferred_username, :outbox),
"preferredUsername" => actor.preferred_username, "preferredUsername" => actor.preferred_username,
"name" => actor.name, "name" => actor.name,
"summary" => actor.summary, "summary" => actor.summary,
@ -46,125 +46,102 @@ defmodule MobilizonWeb.ActivityPub.ActorView do
end end
def render("following.json", %{actor: actor, page: page}) do def render("following.json", %{actor: actor, page: page}) do
actor %{total: total, elements: following} =
|> Actor.get_followings(page) if Actor.public_visibility?(actor),
|> collection(actor.following_url, page) 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()) |> Map.merge(Utils.make_json_ld_header())
end end
def render("following.json", %{actor: actor}) do 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", "type" => "OrderedCollection",
"totalItems" => length(following), "totalItems" => total,
"first" => collection(following, actor.following_url, 1) "first" => collection(following, actor.preferred_username, :following, 1, total)
} }
|> Map.merge(Utils.make_json_ld_header()) |> Map.merge(Utils.make_json_ld_header())
end end
def render("followers.json", %{actor: actor, page: page}) do def render("followers.json", %{actor: actor, page: page}) do
actor %{total: total, elements: followers} =
|> Actor.get_followers(page) if Actor.public_visibility?(actor),
|> collection(actor.followers_url, page) 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()) |> Map.merge(Utils.make_json_ld_header())
end end
def render("followers.json", %{actor: actor}) do 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", "type" => "OrderedCollection",
# TODO put me back "totalItems" => total,
# "totalItems" => length(followers), "first" => collection(followers, actor.preferred_username, :followers, 1, total)
"first" => collection(followers, actor.followers_url, 1)
} }
|> Map.merge(Utils.make_json_ld_header()) |> Map.merge(Utils.make_json_ld_header())
end end
def render("outbox.json", %{actor: actor, page: page}) do def render("outbox.json", %{actor: actor, page: page}) do
{page, no_page} = %{total: total, elements: followers} =
if page == 0 do if Actor.public_visibility?(actor),
{1, true} do: ActivityPub.fetch_public_activities_for_actor(actor, page),
else else: @private_visibility_empty_collection
{page, false}
end
{activities, total} = ActivityPub.fetch_public_activities_for_actor(actor, page) followers
|> collection(actor.preferred_username, :outbox, page, total)
# collection = |> Map.merge(Utils.make_json_ld_header())
# 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
end end
def render("activity.json", %{activity: %Activity{local: local, data: data} = activity}) do def render("outbox.json", %{actor: actor}) do
%{ %{total: total, elements: followers} =
"id" => data["id"], if Actor.public_visibility?(actor),
"type" => do: ActivityPub.fetch_public_activities_for_actor(actor),
if local do else: @private_visibility_empty_collection
"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) "id" => Actor.build_url(actor.preferred_username, :outbox),
end "type" => "OrderedCollection",
"totalItems" => total,
"first" => collection(followers, actor.preferred_username, :outbox, 1, total)
} }
|> Map.merge(Utils.make_json_ld_header()) |> Map.merge(Utils.make_json_ld_header())
end end
def collection(collection, iri, page, _total \\ nil) do @spec collection(list(), String.t(), atom(), integer(), integer()) :: map()
items = Enum.map(collection, fn account -> account.url end) defp collection(collection, preferred_username, endpoint, page, total)
when endpoint in [:followers, :following, :outbox] do
offset = (page - 1) * 10
# TODO : Add me back map = %{
# total = total || length(collection) "id" => Actor.build_url(preferred_username, endpoint, page: page),
%{
"id" => "#{iri}?page=#{page}",
"type" => "OrderedCollectionPage", "type" => "OrderedCollectionPage",
"partOf" => iri, "partOf" => Actor.build_url(preferred_username, endpoint),
# "totalItems" => total, "orderedItems" => Enum.map(collection, &item/1)
"orderedItems" => items
} }
# if offset < total do if offset < total do
# Map.put(map, "next", "#{iri}?page=#{page + 1}") Map.put(map, "next", Actor.build_url(preferred_username, endpoint, page: page + 1))
# end end
map
end end
def item(%Activity{data: %{"id" => id}}), do: id
def item(%Actor{url: url}), do: url
end end

View File

@ -1,6 +1,7 @@
defmodule MobilizonWeb.ActivityPub.ObjectView do defmodule MobilizonWeb.ActivityPub.ObjectView do
use MobilizonWeb, :view use MobilizonWeb, :view
alias Mobilizon.Service.ActivityPub.Utils alias Mobilizon.Service.ActivityPub.Utils
alias Mobilizon.Activity
def render("event.json", %{event: event}) do def render("event.json", %{event: event}) do
{:ok, html, []} = Earmark.as_html(event["summary"]) {:ok, html, []} = Earmark.as_html(event["summary"])
@ -40,4 +41,29 @@ defmodule MobilizonWeb.ActivityPub.ObjectView do
Map.merge(comment, Utils.make_json_ld_header()) Map.merge(comment, Utils.make_json_ld_header())
end 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 end

View File

@ -383,7 +383,7 @@ defmodule Mobilizon.Service.ActivityPub do
followers = followers =
if actor.followers_url in activity.recipients do 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 else
[] []
end end
@ -492,50 +492,18 @@ defmodule Mobilizon.Service.ActivityPub do
@doc """ @doc """
Return all public activities (events & comments) for an actor Return all public activities (events & comments) for an actor
""" """
@spec fetch_public_activities_for_actor(Actor.t(), integer(), integer()) :: {list(), integer()} @spec fetch_public_activities_for_actor(Actor.t(), integer(), integer()) :: map()
def fetch_public_activities_for_actor(actor, page \\ nil, limit \\ nil) 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 event_activities = Enum.map(events, &event_to_activity/1)
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) 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 %{elements: activities, total: total_events + total_comments}
{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
end end
# Create an activity from an event # Create an activity from an event
@ -560,38 +528,6 @@ defmodule Mobilizon.Service.ActivityPub do
} }
end 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 # # Whether the Public audience is in the activity's audience
# defp is_public?(activity) do # defp is_public?(activity) do
# "https://www.w3.org/ns/activitystreams#Public" in (activity.data["to"] ++ # "https://www.w3.org/ns/activitystreams#Public" in (activity.data["to"] ++

View File

@ -20,6 +20,8 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub
alias Ecto.Changeset alias Ecto.Changeset
require Logger 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, # 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. # 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, "begins_on" => metadata.begins_on,
"category" => category, "category" => category,
"actor" => actor, "actor" => actor,
"id" => "#{MobilizonWeb.Endpoint.url()}/events/#{uuid}", "id" => Routes.page_url(Endpoint, :event, uuid),
"uuid" => uuid, "uuid" => uuid,
"tag" => tags |> Enum.map(fn {_, tag} -> tag end) |> Enum.uniq() "tag" => tags |> Enum.map(fn {_, tag} -> tag end) |> Enum.uniq()
} }
@ -296,7 +298,7 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
"summary" => event.description, "summary" => event.description,
"publish_at" => (event.publish_at || event.inserted_at) |> DateTime.to_iso8601(), "publish_at" => (event.publish_at || event.inserted_at) |> DateTime.to_iso8601(),
"updated_at" => event.updated_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 end
@ -320,7 +322,7 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
"actor" => actor.url, "actor" => actor.url,
"attributedTo" => actor.url, "attributedTo" => actor.url,
"uuid" => uuid, "uuid" => uuid,
"id" => "#{MobilizonWeb.Endpoint.url()}/comments/#{uuid}" "id" => Routes.page_url(Endpoint, :comment, uuid)
} }
if reply_to do if reply_to do
@ -354,7 +356,7 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
# "summary" => cw, # "summary" => cw,
# "attachment" => attachments, # "attachment" => attachments,
"actor" => actor, "actor" => actor,
"id" => "#{MobilizonWeb.Endpoint.url()}/comments/#{uuid}", "id" => Routes.page_url(Endpoint, :comment, uuid),
"uuid" => uuid, "uuid" => uuid,
"tag" => tags |> Enum.map(fn {_, tag} -> tag end) |> Enum.uniq() "tag" => tags |> Enum.map(fn {_, tag} -> tag end) |> Enum.uniq()
} }
@ -386,7 +388,7 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
"summary" => content_html, "summary" => content_html,
"attributedTo" => actor, "attributedTo" => actor,
"preferredUsername" => preferred_username, "preferredUsername" => preferred_username,
"id" => "#{MobilizonWeb.Endpoint.url()}/~#{preferred_username}", "id" => Actor.build_url(preferred_username, :page),
"uuid" => uuid, "uuid" => uuid,
"tag" => tags |> Enum.map(fn {_, tag} -> tag end) |> Enum.uniq() "tag" => tags |> Enum.map(fn {_, tag} -> tag end) |> Enum.uniq()
} }

View File

@ -41,6 +41,7 @@ defmodule Mobilizon.Service.Export.Feed do
@spec fetch_actor_event_feed(String.t()) :: String.t() @spec fetch_actor_event_feed(String.t()) :: String.t()
defp fetch_actor_event_feed(name) do defp fetch_actor_event_feed(name) do
with %Actor{} = actor <- Actors.get_local_actor_by_name(name), with %Actor{} = actor <- Actors.get_local_actor_by_name(name),
{:visibility, true} <- {:visibility, Actor.public_visibility?(actor)},
{:ok, events, _count} <- Events.get_public_events_for_actor(actor) do {:ok, events, _count} <- Events.get_public_events_for_actor(actor) do
{:ok, build_actor_feed(actor, events)} {:ok, build_actor_feed(actor, events)}
else else

View File

@ -40,12 +40,12 @@ defmodule Mobilizon.Service.Export.ICalendar do
@doc """ @doc """
Export a public actor's events to iCalendar format. 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() @spec export_public_actor(Actor.t()) :: String.t()
def export_public_actor(%Actor{} = actor) do 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()} {:ok, %ICalendar{events: events |> Enum.map(&do_export_event/1)} |> ICalendar.to_ics()}
end end
end end

View File

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

View File

@ -421,8 +421,8 @@ defmodule Mobilizon.ActorsTest do
assert follower.approved == true assert follower.approved == true
assert follower.score == 42 assert follower.score == 42
assert [target_actor] = Actor.get_followings(actor) assert %{total: 1, elements: [target_actor]} = Actor.get_followings(actor)
assert [actor] = Actor.get_followers(target_actor) assert %{total: 1, elements: [actor]} = Actor.get_followers(target_actor)
end end
test "create_follower/1 with valid data but same actors fails to create a follower", %{ test "create_follower/1 with valid data but same actors fails to create a follower", %{

View File

@ -3,6 +3,8 @@ defmodule Mobilizon.Service.ActivityPub.UtilsTest do
import Mobilizon.Factory import Mobilizon.Factory
alias Mobilizon.Service.ActivityPub.Utils alias Mobilizon.Service.ActivityPub.Utils
use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney
alias MobilizonWeb.Router.Helpers, as: Routes
alias MobilizonWeb.Endpoint
setup_all do setup_all do
HTTPoison.start() HTTPoison.start()
@ -19,7 +21,7 @@ defmodule Mobilizon.Service.ActivityPub.UtilsTest do
"content" => reply.text, "content" => reply.text,
"actor" => reply.actor.url, "actor" => reply.actor.url,
"uuid" => reply.uuid, "uuid" => reply.uuid,
"id" => "#{MobilizonWeb.Endpoint.url()}/comments/#{reply.uuid}", "id" => Routes.page_url(Endpoint, :comment, reply.uuid),
"inReplyTo" => comment.url, "inReplyTo" => comment.url,
"attributedTo" => reply.actor.url "attributedTo" => reply.actor.url
} == Utils.make_comment_data(reply) } == Utils.make_comment_data(reply)

View File

@ -12,6 +12,8 @@ defmodule MobilizonWeb.ActivityPubControllerTest do
alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub
alias Mobilizon.Service.ActivityPub.Utils alias Mobilizon.Service.ActivityPub.Utils
use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney
alias MobilizonWeb.Router.Helpers, as: Routes
alias MobilizonWeb.Endpoint
setup do setup do
conn = build_conn() |> put_req_header("accept", "application/activity+json") conn = build_conn() |> put_req_header("accept", "application/activity+json")
@ -24,7 +26,7 @@ defmodule MobilizonWeb.ActivityPubControllerTest do
conn = conn =
conn conn
|> get("/@#{actor.preferred_username}") |> get(Actor.build_url(actor.preferred_username, :page))
actor = Actors.get_actor!(actor.id) actor = Actors.get_actor!(actor.id)
@ -38,7 +40,7 @@ defmodule MobilizonWeb.ActivityPubControllerTest do
conn = conn =
conn conn
|> get("/events/#{event.uuid}") |> get(Routes.page_url(Endpoint, :event, event.uuid))
assert json_response(conn, 200) == assert json_response(conn, 200) ==
ObjectView.render("event.json", %{event: event |> Utils.make_event_data()}) ObjectView.render("event.json", %{event: event |> Utils.make_event_data()})
@ -49,7 +51,7 @@ defmodule MobilizonWeb.ActivityPubControllerTest do
conn = conn =
conn conn
|> get("/events/#{event.uuid}") |> get(Routes.page_url(Endpoint, :event, event.uuid))
assert json_response(conn, 404) assert json_response(conn, 404)
end end
@ -61,7 +63,7 @@ defmodule MobilizonWeb.ActivityPubControllerTest do
conn = conn =
conn conn
|> get("/comments/#{comment.uuid}") |> get(Routes.page_url(Endpoint, :comment, comment.uuid))
assert json_response(conn, 200) == assert json_response(conn, 200) ==
ObjectView.render("comment.json", %{comment: comment |> Utils.make_comment_data()}) ObjectView.render("comment.json", %{comment: comment |> Utils.make_comment_data()})
@ -88,7 +90,7 @@ defmodule MobilizonWeb.ActivityPubControllerTest do
conn = conn =
conn conn
|> assign(:valid_signature, true) |> assign(:valid_signature, true)
|> post("/inbox", data) |> post("#{MobilizonWeb.Endpoint.url()}/inbox", data)
assert "ok" == json_response(conn, 200) assert "ok" == json_response(conn, 200)
:timer.sleep(500) :timer.sleep(500)
@ -99,44 +101,106 @@ defmodule MobilizonWeb.ActivityPubControllerTest do
describe "/@:preferred_username/outbox" do describe "/@:preferred_username/outbox" do
test "it returns a note activity in a collection", %{conn: conn} 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) comment = insert(:comment, actor: actor)
conn = conn =
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 end
test "it returns an event activity in a collection", %{conn: conn} do 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) event = insert(:event, organizer_actor: actor)
conn = conn =
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
end end
describe "/@actor/followers" do describe "/@actor/followers" do
test "it returns the followers in a collection", %{conn: conn} do test "it returns the followers in a collection", %{conn: conn} do
actor = insert(:actor) actor = insert(:actor, visibility: :public)
actor2 = insert(:actor) actor2 = insert(:actor)
Actor.follow(actor, actor2) Actor.follow(actor, actor2)
result = result =
conn conn
|> get("/@#{actor.preferred_username}/followers") |> get(Actor.build_url(actor.preferred_username, :followers))
|> json_response(200) |> json_response(200)
assert result["first"]["orderedItems"] == [actor2.url] assert result["first"]["orderedItems"] == [actor2.url]
end 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 test "it works for more than 10 actors", %{conn: conn} do
actor = insert(:actor) actor = insert(:actor, visibility: :public)
Enum.each(1..15, fn _ -> Enum.each(1..15, fn _ ->
other_actor = insert(:actor) other_actor = insert(:actor)
@ -145,39 +209,50 @@ defmodule MobilizonWeb.ActivityPubControllerTest do
result = result =
conn conn
|> get("/@#{actor.preferred_username}/followers") |> get(Actor.build_url(actor.preferred_username, :followers))
|> json_response(200) |> json_response(200)
assert length(result["first"]["orderedItems"]) == 10 assert length(result["first"]["orderedItems"]) == 10
# assert result["first"]["totalItems"] == 15 assert result["totalItems"] == 15
# assert result["totalItems"] == 15
result = result =
conn conn
|> get("/@#{actor.preferred_username}/followers?page=2") |> get(Actor.build_url(actor.preferred_username, :followers, page: 2))
|> json_response(200) |> json_response(200)
assert length(result["orderedItems"]) == 5 assert length(result["orderedItems"]) == 5
# assert result["totalItems"] == 15
end end
end end
describe "/@actor/following" do describe "/@actor/following" do
test "it returns the followings in a collection", %{conn: conn} do test "it returns the followings in a collection", %{conn: conn} do
actor = insert(:actor) actor = insert(:actor)
actor2 = insert(:actor) actor2 = insert(:actor, visibility: :public)
Actor.follow(actor, actor2) Actor.follow(actor, actor2)
result = result =
conn conn
|> get("/@#{actor2.preferred_username}/following") |> get(Actor.build_url(actor2.preferred_username, :following))
|> json_response(200) |> json_response(200)
assert result["first"]["orderedItems"] == [actor.url] assert result["first"]["orderedItems"] == [actor.url]
end 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) 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 _ -> Enum.each(1..15, fn _ ->
other_actor = insert(:actor) other_actor = insert(:actor)
@ -186,7 +261,7 @@ defmodule MobilizonWeb.ActivityPubControllerTest do
result = result =
conn conn
|> get("/@#{actor.preferred_username}/following") |> get(Actor.build_url(actor.preferred_username, :following))
|> json_response(200) |> json_response(200)
assert length(result["first"]["orderedItems"]) == 10 assert length(result["first"]["orderedItems"]) == 10
@ -195,7 +270,7 @@ defmodule MobilizonWeb.ActivityPubControllerTest do
result = result =
conn conn
|> get("/@#{actor.preferred_username}/following?page=2") |> get(Actor.build_url(actor.preferred_username, :following, page: 2))
|> json_response(200) |> json_response(200)
assert length(result["orderedItems"]) == 5 assert length(result["orderedItems"]) == 5

View File

@ -5,8 +5,9 @@ defmodule MobilizonWeb.FeedControllerTest do
alias MobilizonWeb.Endpoint alias MobilizonWeb.Endpoint
describe "/@:preferred_username/feed/atom" do describe "/@:preferred_username/feed/atom" do
test "it returns an RSS representation of the actor's public events", %{conn: conn} do test "it returns an RSS representation of the actor's public events if the actor is publicly visible",
actor = insert(:actor) %{conn: conn} do
actor = insert(:actor, visibility: :public)
tag1 = insert(:tag, title: "RSS", slug: "rss") tag1 = insert(:tag, title: "RSS", slug: "rss")
tag2 = insert(:tag, title: "ATOM", slug: "atom") tag2 = insert(:tag, title: "ATOM", slug: "atom")
event1 = insert(:event, organizer_actor: actor, tags: [tag1]) event1 = insert(:event, organizer_actor: actor, tags: [tag1])
@ -36,9 +37,27 @@ defmodule MobilizonWeb.FeedControllerTest do
assert entry2.categories == [tag1.slug] assert entry2.categories == [tag1.slug]
end 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 %{conn: conn} do
actor = insert(:actor) 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 =
conn conn
@ -63,8 +82,9 @@ defmodule MobilizonWeb.FeedControllerTest do
end end
describe "/@:preferred_username/feed/ics" do describe "/@:preferred_username/feed/ics" do
test "it returns an iCalendar representation of the actor's public events", %{conn: conn} do test "it returns an iCalendar representation of the actor's public events with an actor publicly visible",
actor = insert(:actor) %{conn: conn} do
actor = insert(:actor, visibility: :public)
tag1 = insert(:tag, title: "iCalendar", slug: "icalendar") tag1 = insert(:tag, title: "iCalendar", slug: "icalendar")
tag2 = insert(:tag, title: "Apple", slug: "apple") tag2 = insert(:tag, title: "Apple", slug: "apple")
event1 = insert(:event, organizer_actor: actor, tags: [tag1]) 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] assert entry2.categories == [event2.category, tag1.slug, tag2.slug]
end 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", test "it returns an iCalendar representation of the actor's public events with the proper accept header",
%{conn: conn} do %{conn: conn} do
actor = insert(:actor) actor = insert(:actor, visibility: :unlisted)
conn = conn =
conn conn

View File

@ -1,6 +1,9 @@
defmodule MobilizonWeb.PageControllerTest do defmodule MobilizonWeb.PageControllerTest do
use MobilizonWeb.ConnCase use MobilizonWeb.ConnCase
import Mobilizon.Factory import Mobilizon.Factory
alias Mobilizon.Actors.Actor
alias MobilizonWeb.Router.Helpers, as: Routes
alias MobilizonWeb.Endpoint
setup do setup do
conn = build_conn() |> put_req_header("accept", "text/html") 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 test "GET /@actor with existing actor", %{conn: conn} do
actor = insert(:actor) actor = insert(:actor)
conn = get(conn, "/@#{actor.preferred_username}") conn = get(conn, Actor.build_url(actor.preferred_username, :page))
assert html_response(conn, 200) assert html_response(conn, 200)
end end
test "GET /@actor with not existing actor", %{conn: conn} do 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) assert html_response(conn, 404)
end end
test "GET /events/:uuid", %{conn: conn} do test "GET /events/:uuid", %{conn: conn} do
event = insert(:event) event = insert(:event)
conn = get(conn, "/events/#{event.uuid}") conn = get(conn, Routes.page_url(Endpoint, :event, event.uuid))
assert html_response(conn, 200) assert html_response(conn, 200)
end end
test "GET /events/:uuid with not existing event", %{conn: conn} do 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) assert html_response(conn, 404)
end end
test "GET /events/:uuid with event not public", %{conn: conn} do test "GET /events/:uuid with event not public", %{conn: conn} do
event = insert(:event, visibility: :restricted) 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) assert html_response(conn, 404)
end end

View File

@ -58,8 +58,9 @@ defmodule MobilizonWeb.Resolvers.GroupResolverTest do
assert hd(json_response(res, 200)["errors"])["message"] == "existing_group_name" assert hd(json_response(res, 200)["errors"])["message"] == "existing_group_name"
end end
test "list_groups/3 returns all groups", context do test "list_groups/3 returns all public or unlisted groups", context do
group = insert(:group) group = insert(:group, visibility: :unlisted)
insert(:group, visibility: :private)
query = """ query = """
{ {
@ -71,7 +72,9 @@ defmodule MobilizonWeb.Resolvers.GroupResolverTest do
res = res =
context.conn 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"] == assert hd(json_response(res, 200)["data"]["groups"])["preferredUsername"] ==
group.preferred_username group.preferred_username

View File

@ -4,6 +4,9 @@ defmodule Mobilizon.Factory do
""" """
# with Ecto # with Ecto
use ExMachina.Ecto, repo: Mobilizon.Repo use ExMachina.Ecto, repo: Mobilizon.Repo
alias Mobilizon.Actors.Actor
alias MobilizonWeb.Router.Helpers, as: Routes
alias MobilizonWeb.Endpoint
def user_factory do def user_factory do
%Mobilizon.Users.User{ %Mobilizon.Users.User{
@ -30,9 +33,10 @@ defmodule Mobilizon.Factory do
followings: [], followings: [],
keys: pem, keys: pem,
type: :Person, type: :Person,
url: MobilizonWeb.Endpoint.url() <> "/@#{preferred_username}", url: Actor.build_url(preferred_username, :page),
followers_url: MobilizonWeb.Endpoint.url() <> "/@#{preferred_username}/followers", followers_url: Actor.build_url(preferred_username, :followers),
following_url: MobilizonWeb.Endpoint.url() <> "/@#{preferred_username}/following", following_url: Actor.build_url(preferred_username, :following),
outbox_url: Actor.build_url(preferred_username, :outbox),
user: nil user: nil
} }
end end
@ -89,7 +93,7 @@ defmodule Mobilizon.Factory do
event: build(:event), event: build(:event),
uuid: uuid, uuid: uuid,
in_reply_to_comment: nil, in_reply_to_comment: nil,
url: "#{MobilizonWeb.Endpoint.url()}/comments/#{uuid}" url: Routes.page_url(Endpoint, :comment, uuid)
} }
end end
@ -109,7 +113,7 @@ defmodule Mobilizon.Factory do
physical_address: build(:address), physical_address: build(:address),
visibility: :public, visibility: :public,
tags: build_list(3, :tag), tags: build_list(3, :tag),
url: "#{actor.url}/#{uuid}", url: Routes.page_url(Endpoint, :event, uuid),
uuid: uuid uuid: uuid
} }
end end