mobilizon/lib/federation/activity_stream/converter/event.ex
Thomas Citharel 5e8f9afb62
fix(activitypub): compact ical:status in activitystream data
Was kept for no other reason than disambiguation with https://schema.org/status, but no need if we
specify the correct alias in the context.

Keeps a fallback for older versions compatibility

Closes #1378

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
2023-12-04 14:05:27 +01:00

343 lines
11 KiB
Elixir

defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
@moduledoc """
Event converter.
This module allows to convert events from ActivityStream format to our own
internal one, and back.
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Addresses.Address
alias Mobilizon.Events.Categories
alias Mobilizon.Events.Event, as: EventModel
alias Mobilizon.Medias.Media
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Federation.ActivityStream.Converter.Address, as: AddressConverter
alias Mobilizon.Federation.ActivityStream.Converter.EventMetadata, as: EventMetadataConverter
alias Mobilizon.Federation.ActivityStream.Converter.Media, as: MediaConverter
alias Mobilizon.Service.TimezoneDetector
alias Mobilizon.Web.Endpoint
import Mobilizon.Federation.ActivityPub.Utils, only: [get_url: 1]
import Mobilizon.Federation.ActivityStream.Converter.Utils,
only: [
fetch_tags: 1,
fetch_mentions: 1,
build_tags: 1,
maybe_fetch_actor_and_attributed_to_id: 1,
process_pictures: 2,
get_address: 1,
fetch_actor: 1
]
require Logger
@behaviour Converter
defimpl Convertible, for: EventModel do
alias Mobilizon.Federation.ActivityStream.Converter.Event, as: EventConverter
defdelegate model_to_as(event), to: EventConverter
end
@online_address_name "Website"
@banner_picture_name "Banner"
@ap_public "https://www.w3.org/ns/activitystreams#Public"
@doc """
Converts an AP object data to our internal data structure.
"""
@impl Converter
@spec as_to_model_data(map) :: map() | {:error, atom()}
def as_to_model_data(object) do
case maybe_fetch_actor_and_attributed_to_id(object) do
{:ok, %Actor{id: actor_id}, attributed_to} ->
address = get_address(object["location"])
tags = fetch_tags(object["tag"])
mentions = fetch_mentions(object["tag"])
visibility = get_visibility(object)
options = get_options(object, address)
metadata = get_metdata(object)
contacts = get_contacts(object)
[description: description, picture_id: picture_id, medias: medias] =
process_pictures(object, actor_id)
%{
title: object["name"],
description: description,
organizer_actor_id: actor_id,
attributed_to_id: if(is_nil(attributed_to), do: nil, else: attributed_to.id),
picture_id: picture_id,
medias: medias,
begins_on: object["startTime"],
ends_on: object["endTime"],
category: Categories.get_category(object["category"]),
visibility: visibility,
join_options: Map.get(object, "joinMode", "free"),
local: is_local?(object["id"]),
external_participation_url: object["externalParticipationUrl"],
options: options,
metadata: metadata,
# Remove fallback in MBZ 5.x
status:
object
|> Map.get("status", Map.get(object, "ical:status", "CONFIRMED"))
|> String.downcase(),
online_address: object |> Map.get("attachment", []) |> get_online_address(),
phone_address: object["phoneAddress"],
draft: object["draft"] == true,
url: object["id"],
uuid: object["uuid"],
tags: tags,
mentions: mentions,
physical_address_id: if(address, do: address.id, else: nil),
updated_at: object["updated"],
publish_at: object["published"],
language: object["inLanguage"],
contacts: contacts
}
{:error, err} ->
{:error, err}
end
end
@doc """
Convert an event struct to an ActivityStream representation.
"""
@impl Converter
@spec model_to_as(EventModel.t()) :: map
def model_to_as(%EventModel{} = event) do
{to, cc} =
if event.visibility == :public,
do: {[@ap_public], [event.organizer_actor.followers_url]},
else: {[attributed_to_or_default(event).followers_url], [@ap_public]}
participant_count = Mobilizon.Events.count_participant_participants(event.id)
%{
"type" => "Event",
"to" => to,
"cc" => cc,
"attributedTo" => attributed_to_or_default(event).url,
"name" => event.title,
"actor" =>
if(Ecto.assoc_loaded?(event.organizer_actor), do: event.organizer_actor.url, else: nil),
"uuid" => event.uuid,
"category" => event.category,
"content" => event.description,
"published" => (event.publish_at || event.inserted_at) |> date_to_string(),
"updated" => event.updated_at |> date_to_string(),
"mediaType" => "text/html",
"startTime" => event.begins_on |> shift_tz(event.options.timezone) |> date_to_string(),
"joinMode" => to_string(event.join_options),
"externalParticipationUrl" => event.external_participation_url,
"endTime" => event.ends_on |> shift_tz(event.options.timezone) |> date_to_string(),
"tag" => event.tags |> build_tags(),
"maximumAttendeeCapacity" => event.options.maximum_attendee_capacity,
"remainingAttendeeCapacity" =>
remaining_attendee_capacity(event.options, participant_count),
"participantCount" => participant_count,
"repliesModerationOption" => event.options.comment_moderation,
"commentsEnabled" => event.options.comment_moderation == :allow_all,
"anonymousParticipationEnabled" => event.options.anonymous_participation,
"attachment" => Enum.map(event.metadata, &EventMetadataConverter.metadata_to_as/1),
"draft" => event.draft,
# Remove me in MBZ 5.x
"ical:status" => event.status |> to_string |> String.upcase(),
"status" => event.status |> to_string |> String.upcase(),
"id" => event.url,
"url" => event.url,
"inLanguage" => event.language,
"timezone" => event.options.timezone,
"contacts" => Enum.map(event.contacts, & &1.url),
"isOnline" => event.options.is_online
}
|> maybe_add_physical_address(event)
|> maybe_add_event_picture(event)
|> maybe_add_online_address(event)
|> maybe_add_inline_media(event)
end
@spec attributed_to_or_default(EventModel.t()) :: Actor.t()
defp attributed_to_or_default(%EventModel{} = event) do
if(is_nil(event.attributed_to) or not Ecto.assoc_loaded?(event.attributed_to),
do: nil,
else: event.attributed_to
) ||
event.organizer_actor
end
# Get only elements that we have in EventOptions
@spec get_options(map, Address.t() | nil) :: map
defp get_options(object, address) do
%{
maximum_attendee_capacity: object["maximumAttendeeCapacity"],
anonymous_participation: object["anonymousParticipationEnabled"],
comment_moderation:
Map.get(
object,
"repliesModerationOption",
if(Map.get(object, "commentsEnabled", true), do: :allow_all, else: :closed)
),
timezone: calculate_timezone(object, address),
is_online: object["isOnline"] == true
}
end
defp calculate_timezone(%{"timezone" => timezone}, %Address{geom: geom}) do
TimezoneDetector.detect(
timezone,
geom,
"Etc/UTC"
)
end
defp calculate_timezone(_object, nil), do: nil
defp calculate_timezone(_object, %Address{geom: geom}) do
TimezoneDetector.detect(
nil,
geom,
"Etc/UTC"
)
end
defp get_metdata(%{"attachment" => attachments}) do
attachments
|> Enum.filter(&(&1["type"] == "PropertyValue"))
|> Enum.map(&EventMetadataConverter.as_to_metadata/1)
end
defp get_metdata(_), do: []
defp get_visibility(object), do: if(@ap_public in object["to"], do: :public, else: :unlisted)
@spec date_to_string(DateTime.t() | nil) :: String.t()
defp date_to_string(nil), do: nil
defp date_to_string(%DateTime{} = date), do: DateTime.to_iso8601(date)
@spec shift_tz(DateTime.t(), String.t() | nil) :: DateTime.t()
defp shift_tz(%DateTime{} = date, timezone) when is_binary(timezone) do
DateTime.shift_zone!(date, timezone)
end
defp shift_tz(datetime, _tz), do: datetime
defp get_online_address(attachments) do
Enum.find_value(attachments, fn attachment ->
case attachment do
%{
"type" => "Link",
"href" => url,
"mediaType" => "text/html",
"name" => @online_address_name
} ->
url
_ ->
nil
end
end)
end
@spec maybe_add_physical_address(map(), EventModel.t()) :: map()
defp maybe_add_physical_address(res, %EventModel{
physical_address: %Address{} = physical_address
}) do
Map.put(res, "location", AddressConverter.model_to_as(physical_address))
end
defp maybe_add_physical_address(res, %EventModel{physical_address: _}), do: res
@spec maybe_add_event_picture(map(), EventModel.t()) :: map()
defp maybe_add_event_picture(res, %EventModel{picture: %Media{} = picture}) do
Map.update(
res,
"attachment",
[],
&(&1 ++
[
picture
|> MediaConverter.model_to_as()
|> Map.put("name", @banner_picture_name)
])
)
end
defp maybe_add_event_picture(res, %EventModel{picture: _}), do: res
@spec maybe_add_online_address(map(), EventModel.t()) :: map()
defp maybe_add_online_address(res, %EventModel{online_address: online_address})
when is_binary(online_address) do
Map.update(
res,
"attachment",
[],
&(&1 ++
[
%{
"type" => "Link",
"href" => online_address,
"mediaType" => "text/html",
"name" => @online_address_name
}
])
)
end
defp maybe_add_online_address(res, %EventModel{online_address: _}), do: res
@spec maybe_add_inline_media(map(), EventModel.t()) :: map()
defp maybe_add_inline_media(res, %EventModel{media: media}) do
medias = Enum.map(media, &MediaConverter.model_to_as/1)
Map.update(
res,
"attachment",
[],
&(&1 ++ medias)
)
end
@spec is_local?(String.t()) :: boolean()
defp is_local?(url) do
%URI{host: url_domain} = URI.parse(url)
%URI{host: local_domain} = URI.parse(Endpoint.url())
url_domain == local_domain
end
@spec get_contacts(map()) :: list(Actor.t())
defp get_contacts(object) do
object
|> Map.get("contacts", [])
|> Enum.map(&get_contact/1)
|> Enum.filter(&match?({:ok, _}, &1))
|> Enum.map(fn {:ok, contact} -> contact end)
end
defp get_contact(contact) do
contact
|> get_url()
|> fetch_actor()
end
@spec remaining_attendee_capacity(map(), integer()) :: integer() | nil
defp remaining_attendee_capacity(
%{maximum_attendee_capacity: maximum_attendee_capacity},
participant_count
)
when is_integer(maximum_attendee_capacity) and maximum_attendee_capacity > 0 do
maximum_attendee_capacity - participant_count
end
defp remaining_attendee_capacity(
%{maximum_attendee_capacity: _},
_participant_count
),
do: nil
end