2018-10-11 17:37:39 +02:00
|
|
|
defmodule Mobilizon.Actors.Actor do
|
2018-05-18 09:56:21 +02:00
|
|
|
@moduledoc """
|
2019-09-09 00:52:49 +02:00
|
|
|
Represents an actor (local and remote).
|
2018-05-18 09:56:21 +02:00
|
|
|
"""
|
2019-09-08 01:49:56 +02:00
|
|
|
|
2018-05-18 09:56:21 +02:00
|
|
|
use Ecto.Schema
|
2019-09-08 01:49:56 +02:00
|
|
|
|
2018-05-18 09:56:21 +02:00
|
|
|
import Ecto.Changeset
|
2018-12-14 11:23:36 +01:00
|
|
|
|
2019-09-09 00:52:49 +02:00
|
|
|
alias Mobilizon.{Actors, Config, Crypto}
|
|
|
|
alias Mobilizon.Actors.{Actor, ActorOpenness, ActorType, ActorVisibility, Follower, Member}
|
2019-03-08 12:25:06 +01:00
|
|
|
alias Mobilizon.Events.{Event, FeedToken}
|
2019-05-22 14:12:11 +02:00
|
|
|
alias Mobilizon.Media.File
|
2019-07-23 13:49:22 +02:00
|
|
|
alias Mobilizon.Reports.{Report, Note}
|
2019-09-08 01:49:56 +02:00
|
|
|
alias Mobilizon.Users.User
|
2019-07-23 13:49:22 +02:00
|
|
|
|
2019-04-25 19:05:05 +02:00
|
|
|
alias MobilizonWeb.Router.Helpers, as: Routes
|
|
|
|
alias MobilizonWeb.Endpoint
|
|
|
|
|
2018-11-08 16:11:23 +01:00
|
|
|
require Logger
|
2018-05-18 09:56:21 +02:00
|
|
|
|
2019-09-09 00:52:49 +02:00
|
|
|
@type t :: %__MODULE__{
|
|
|
|
url: String.t(),
|
|
|
|
outbox_url: String.t(),
|
|
|
|
inbox_url: String.t(),
|
|
|
|
following_url: String.t(),
|
|
|
|
followers_url: String.t(),
|
|
|
|
shared_inbox_url: String.t(),
|
|
|
|
type: ActorType.t(),
|
|
|
|
name: String.t(),
|
|
|
|
domain: String.t(),
|
|
|
|
summary: String.t(),
|
|
|
|
preferred_username: String.t(),
|
|
|
|
keys: String.t(),
|
|
|
|
manually_approves_followers: boolean,
|
|
|
|
openness: ActorOpenness.t(),
|
|
|
|
visibility: ActorVisibility.t(),
|
|
|
|
suspended: boolean,
|
|
|
|
avatar: File.t(),
|
|
|
|
banner: File.t(),
|
|
|
|
user: User.t(),
|
|
|
|
followers: [Follower.t()],
|
|
|
|
followings: [Follower.t()],
|
|
|
|
organized_events: [Event.t()],
|
|
|
|
feed_tokens: [FeedToken.t()],
|
|
|
|
created_reports: [Report.t()],
|
|
|
|
subject_reports: [Report.t()],
|
|
|
|
report_notes: [Note.t()],
|
|
|
|
memberships: [Actor.t()]
|
|
|
|
}
|
|
|
|
|
|
|
|
@required_attrs [:preferred_username, :keys, :suspended, :url]
|
|
|
|
@optional_attrs [
|
|
|
|
:outbox_url,
|
|
|
|
:inbox_url,
|
|
|
|
:shared_inbox_url,
|
|
|
|
:following_url,
|
|
|
|
:followers_url,
|
|
|
|
:type,
|
|
|
|
:name,
|
|
|
|
:domain,
|
|
|
|
:summary,
|
|
|
|
:manually_approves_followers,
|
|
|
|
:user_id
|
|
|
|
]
|
|
|
|
@attrs @required_attrs ++ @optional_attrs
|
|
|
|
|
|
|
|
@update_required_attrs @required_attrs
|
|
|
|
@update_optional_attrs [:name, :summary, :manually_approves_followers, :user_id]
|
|
|
|
@update_attrs @update_required_attrs ++ @update_optional_attrs
|
|
|
|
|
|
|
|
@registration_required_attrs [:preferred_username, :keys, :suspended, :url, :type]
|
|
|
|
@registration_optional_attrs [:domain, :name, :summary, :user_id]
|
|
|
|
@registration_attrs @registration_required_attrs ++ @registration_optional_attrs
|
|
|
|
|
|
|
|
@remote_actor_creation_required_attrs [
|
|
|
|
:url,
|
|
|
|
:inbox_url,
|
|
|
|
:type,
|
|
|
|
:domain,
|
|
|
|
:preferred_username,
|
|
|
|
:keys
|
|
|
|
]
|
|
|
|
@remote_actor_creation_optional_attrs [
|
|
|
|
:outbox_url,
|
|
|
|
:shared_inbox_url,
|
|
|
|
:following_url,
|
|
|
|
:followers_url,
|
|
|
|
:name,
|
|
|
|
:summary,
|
|
|
|
:manually_approves_followers
|
|
|
|
]
|
|
|
|
@remote_actor_creation_attrs @remote_actor_creation_required_attrs ++
|
|
|
|
@remote_actor_creation_optional_attrs
|
|
|
|
|
|
|
|
@relay_creation_attrs [
|
|
|
|
:type,
|
|
|
|
:name,
|
|
|
|
:summary,
|
|
|
|
:url,
|
|
|
|
:keys,
|
|
|
|
:preferred_username,
|
|
|
|
:domain,
|
|
|
|
:inbox_url,
|
|
|
|
:followers_url,
|
|
|
|
:following_url,
|
|
|
|
:shared_inbox_url
|
|
|
|
]
|
|
|
|
|
|
|
|
@group_creation_required_attrs [:url, :outbox_url, :inbox_url, :type, :preferred_username]
|
|
|
|
@group_creation_optional_attrs [:shared_inbox_url, :name, :domain, :summary]
|
|
|
|
@group_creation_attrs @group_creation_required_attrs ++ @group_creation_optional_attrs
|
2018-05-18 09:56:21 +02:00
|
|
|
|
|
|
|
schema "actors" do
|
2018-07-27 10:45:35 +02:00
|
|
|
field(:url, :string)
|
|
|
|
field(:outbox_url, :string)
|
|
|
|
field(:inbox_url, :string)
|
|
|
|
field(:following_url, :string)
|
|
|
|
field(:followers_url, :string)
|
|
|
|
field(:shared_inbox_url, :string)
|
2019-09-09 00:52:49 +02:00
|
|
|
field(:type, ActorType, default: :Person)
|
2018-07-27 10:45:35 +02:00
|
|
|
field(:name, :string)
|
2019-01-21 15:08:22 +01:00
|
|
|
field(:domain, :string, default: nil)
|
2018-07-27 10:45:35 +02:00
|
|
|
field(:summary, :string)
|
|
|
|
field(:preferred_username, :string)
|
|
|
|
field(:keys, :string)
|
|
|
|
field(:manually_approves_followers, :boolean, default: false)
|
2019-09-09 00:52:49 +02:00
|
|
|
field(:openness, ActorOpenness, default: :moderated)
|
|
|
|
field(:visibility, ActorVisibility, default: :private)
|
2018-07-27 10:45:35 +02:00
|
|
|
field(:suspended, :boolean, default: false)
|
2019-09-09 00:52:49 +02:00
|
|
|
|
|
|
|
embeds_one(:avatar, File, on_replace: :update)
|
|
|
|
embeds_one(:banner, File, on_replace: :update)
|
|
|
|
belongs_to(:user, User)
|
2018-11-27 14:02:51 +01:00
|
|
|
has_many(:followers, Follower, foreign_key: :target_actor_id)
|
|
|
|
has_many(:followings, Follower, foreign_key: :actor_id)
|
2018-07-27 10:45:35 +02:00
|
|
|
has_many(:organized_events, Event, foreign_key: :organizer_actor_id)
|
2019-03-08 12:25:06 +01:00
|
|
|
has_many(:feed_tokens, FeedToken, foreign_key: :actor_id)
|
2019-07-23 13:49:22 +02:00
|
|
|
has_many(:created_reports, Report, foreign_key: :reporter_id)
|
|
|
|
has_many(:subject_reports, Report, foreign_key: :reported_id)
|
|
|
|
has_many(:report_notes, Note, foreign_key: :moderator_id)
|
2019-09-09 00:52:49 +02:00
|
|
|
many_to_many(:memberships, Actor, join_through: Member)
|
2018-05-18 09:56:21 +02:00
|
|
|
|
|
|
|
timestamps()
|
|
|
|
end
|
|
|
|
|
2019-09-09 00:52:49 +02:00
|
|
|
@doc """
|
|
|
|
Checks whether actor visibility is public.
|
|
|
|
"""
|
|
|
|
@spec is_public_visibility(Actor.t()) :: boolean
|
|
|
|
def is_public_visibility(%Actor{visibility: visibility}) do
|
|
|
|
visibility in [:public, :unlisted]
|
|
|
|
end
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
Returns the display name if available, or the preferred username
|
|
|
|
(with the eventual @domain suffix if it's a distant actor).
|
|
|
|
"""
|
|
|
|
@spec display_name(Actor.t()) :: String.t()
|
|
|
|
def display_name(%Actor{name: name} = actor) when name in [nil, ""] do
|
|
|
|
preferred_username_and_domain(actor)
|
|
|
|
end
|
|
|
|
|
|
|
|
def display_name(%Actor{name: name}), do: name
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
Returns display name and username.
|
|
|
|
"""
|
|
|
|
@spec display_name_and_username(Actor.t()) :: String.t()
|
|
|
|
def display_name_and_username(%Actor{name: name} = actor) when name in [nil, ""] do
|
|
|
|
preferred_username_and_domain(actor)
|
|
|
|
end
|
|
|
|
|
|
|
|
def display_name_and_username(%Actor{name: name} = actor) do
|
|
|
|
"#{name} (#{preferred_username_and_domain(actor)})"
|
|
|
|
end
|
|
|
|
|
|
|
|
@doc """
|
|
|
|
Returns the preferred username with the eventual @domain suffix if it's
|
|
|
|
a distant actor.
|
|
|
|
"""
|
|
|
|
@spec preferred_username_and_domain(Actor.t()) :: String.t()
|
|
|
|
def preferred_username_and_domain(%Actor{preferred_username: preferred_username, domain: nil}) do
|
|
|
|
preferred_username
|
|
|
|
end
|
|
|
|
|
|
|
|
def preferred_username_and_domain(%Actor{preferred_username: preferred_username, domain: domain}) do
|
|
|
|
"#{preferred_username}@#{domain}"
|
|
|
|
end
|
|
|
|
|
2018-05-18 09:56:21 +02:00
|
|
|
@doc false
|
2019-09-09 00:52:49 +02:00
|
|
|
@spec changeset(t | Ecto.Changeset.t(), map) :: Ecto.Changeset.t()
|
2018-05-18 09:56:21 +02:00
|
|
|
def changeset(%Actor{} = actor, attrs) do
|
|
|
|
actor
|
2019-09-09 00:52:49 +02:00
|
|
|
|> cast(attrs, @attrs)
|
2018-12-03 11:58:57 +01:00
|
|
|
|> build_urls()
|
2019-05-22 14:12:11 +02:00
|
|
|
|> cast_embed(:avatar)
|
|
|
|
|> cast_embed(:banner)
|
2019-02-25 18:35:00 +01:00
|
|
|
|> unique_username_validator()
|
2019-09-09 00:52:49 +02:00
|
|
|
|> validate_required(@required_attrs)
|
|
|
|
|> unique_constraint(:preferred_username,
|
|
|
|
name: :actors_preferred_username_domain_type_index
|
|
|
|
)
|
2018-12-03 11:58:57 +01:00
|
|
|
|> unique_constraint(:url, name: :actors_url_index)
|
2018-05-18 09:56:21 +02:00
|
|
|
end
|
|
|
|
|
2019-09-04 18:24:31 +02:00
|
|
|
@doc false
|
2019-09-09 00:52:49 +02:00
|
|
|
@spec update_changeset(t | Ecto.Changeset.t(), map) :: Ecto.Changeset.t()
|
2019-09-04 18:24:31 +02:00
|
|
|
def update_changeset(%Actor{} = actor, attrs) do
|
|
|
|
actor
|
2019-09-09 00:52:49 +02:00
|
|
|
|> cast(attrs, @update_attrs)
|
2019-09-04 18:24:31 +02:00
|
|
|
|> cast_embed(:avatar)
|
|
|
|
|> cast_embed(:banner)
|
2019-09-09 00:52:49 +02:00
|
|
|
|> validate_required(@update_required_attrs)
|
|
|
|
|> unique_constraint(:preferred_username,
|
|
|
|
name: :actors_preferred_username_domain_type_index
|
|
|
|
)
|
2019-09-04 18:24:31 +02:00
|
|
|
|> unique_constraint(:url, name: :actors_url_index)
|
|
|
|
end
|
|
|
|
|
2019-03-19 11:16:03 +01:00
|
|
|
@doc """
|
2019-09-09 00:52:49 +02:00
|
|
|
Changeset for person registration.
|
2019-03-19 11:16:03 +01:00
|
|
|
"""
|
2019-09-09 00:52:49 +02:00
|
|
|
@spec registration_changeset(t | Ecto.Changeset.t(), map) :: Ecto.Changeset.t()
|
2018-05-18 09:56:21 +02:00
|
|
|
def registration_changeset(%Actor{} = actor, attrs) do
|
|
|
|
actor
|
2019-09-09 00:52:49 +02:00
|
|
|
|> cast(attrs, @registration_attrs)
|
2018-12-03 11:58:57 +01:00
|
|
|
|> build_urls()
|
2019-05-22 14:12:11 +02:00
|
|
|
|> cast_embed(:avatar)
|
|
|
|
|> cast_embed(:banner)
|
2019-01-29 11:02:32 +01:00
|
|
|
|> unique_username_validator()
|
2019-09-09 00:52:49 +02:00
|
|
|
|> unique_constraint(:preferred_username,
|
|
|
|
name: :actors_preferred_username_domain_type_index
|
|
|
|
)
|
2018-12-03 11:58:57 +01:00
|
|
|
|> unique_constraint(:url, name: :actors_url_index)
|
2019-09-09 00:52:49 +02:00
|
|
|
|> validate_required(@registration_required_attrs)
|
2018-05-18 09:56:21 +02:00
|
|
|
end
|
|
|
|
|
2019-03-19 11:16:03 +01:00
|
|
|
@doc """
|
2019-09-09 00:52:49 +02:00
|
|
|
Changeset for remote actor creation.
|
2019-03-19 11:16:03 +01:00
|
|
|
"""
|
2019-09-09 00:52:49 +02:00
|
|
|
@spec remote_actor_creation_changeset(map) :: Ecto.Changeset.t()
|
|
|
|
def remote_actor_creation_changeset(attrs) do
|
|
|
|
changeset =
|
2018-05-18 09:56:21 +02:00
|
|
|
%Actor{}
|
2019-09-09 00:52:49 +02:00
|
|
|
|> cast(attrs, @remote_actor_creation_attrs)
|
|
|
|
|> validate_required(@remote_actor_creation_required_attrs)
|
2019-05-22 14:12:11 +02:00
|
|
|
|> cast_embed(:avatar)
|
|
|
|
|> cast_embed(:banner)
|
2019-02-25 18:35:00 +01:00
|
|
|
|> unique_username_validator()
|
2019-09-09 00:52:49 +02:00
|
|
|
|> unique_constraint(:preferred_username,
|
|
|
|
name: :actors_preferred_username_domain_type_index
|
|
|
|
)
|
2018-12-03 11:58:57 +01:00
|
|
|
|> unique_constraint(:url, name: :actors_url_index)
|
2018-05-18 09:56:21 +02:00
|
|
|
|> validate_length(:summary, max: 5000)
|
|
|
|
|> validate_length(:preferred_username, max: 100)
|
|
|
|
|
2019-09-09 00:52:49 +02:00
|
|
|
Logger.debug("Remote actor creation: #{inspect(changeset)}")
|
2018-05-18 09:56:21 +02:00
|
|
|
|
2019-09-09 00:52:49 +02:00
|
|
|
changeset
|
|
|
|
end
|
2019-07-30 16:40:59 +02:00
|
|
|
|
2019-09-09 00:52:49 +02:00
|
|
|
@doc """
|
|
|
|
Changeset for relay creation.
|
|
|
|
"""
|
|
|
|
@spec relay_creation_changeset(map) :: Ecto.Changeset.t()
|
|
|
|
def relay_creation_changeset(attrs) do
|
|
|
|
relay_creation_attrs = build_relay_creation_attrs(attrs)
|
2019-07-30 16:40:59 +02:00
|
|
|
|
2019-09-09 00:52:49 +02:00
|
|
|
cast(%Actor{}, relay_creation_attrs, @relay_creation_attrs)
|
2019-07-30 16:40:59 +02:00
|
|
|
end
|
|
|
|
|
2019-03-19 11:16:03 +01:00
|
|
|
@doc """
|
|
|
|
Changeset for group creation
|
|
|
|
"""
|
|
|
|
@spec group_creation(struct(), map()) :: Ecto.Changeset.t()
|
2018-05-30 18:59:13 +02:00
|
|
|
def group_creation(%Actor{} = actor, params) do
|
|
|
|
actor
|
2019-09-09 00:52:49 +02:00
|
|
|
|> cast(params, @group_creation_attrs)
|
2019-05-22 14:12:11 +02:00
|
|
|
|> cast_embed(:avatar)
|
|
|
|
|> cast_embed(:banner)
|
2018-12-03 11:58:57 +01:00
|
|
|
|> build_urls(:Group)
|
|
|
|
|> put_change(:domain, nil)
|
2019-09-09 00:52:49 +02:00
|
|
|
|> put_change(:keys, Crypto.generate_rsa_2048_private_key())
|
2018-12-03 11:58:57 +01:00
|
|
|
|> put_change(:type, :Group)
|
2019-02-25 18:35:00 +01:00
|
|
|
|> unique_username_validator()
|
2019-09-09 00:52:49 +02:00
|
|
|
|> validate_required(@group_creation_required_attrs)
|
|
|
|
|> unique_constraint(:preferred_username,
|
|
|
|
name: :actors_preferred_username_domain_type_index
|
|
|
|
)
|
2018-12-03 11:58:57 +01:00
|
|
|
|> unique_constraint(:url, name: :actors_url_index)
|
|
|
|
|> validate_length(:summary, max: 5000)
|
|
|
|
|> validate_length(:preferred_username, max: 100)
|
|
|
|
end
|
|
|
|
|
2019-09-09 00:52:49 +02:00
|
|
|
# Needed because following constraint can't work for domain null values (local)
|
|
|
|
@spec unique_username_validator(Ecto.Changeset.t()) :: Ecto.Changeset.t()
|
2019-02-25 18:35:00 +01:00
|
|
|
defp unique_username_validator(
|
|
|
|
%Ecto.Changeset{changes: %{preferred_username: username} = changes} = changeset
|
|
|
|
) do
|
|
|
|
with nil <- Map.get(changes, :domain, nil),
|
2019-09-09 00:52:49 +02:00
|
|
|
%Actor{preferred_username: _} <- Actors.get_local_actor_by_name(username) do
|
|
|
|
add_error(changeset, :preferred_username, "Username is already taken")
|
2019-01-29 11:02:32 +01:00
|
|
|
else
|
2019-02-25 18:35:00 +01:00
|
|
|
_ -> changeset
|
2019-01-29 11:02:32 +01:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2019-02-25 18:35:00 +01:00
|
|
|
# When we don't even have any preferred_username, don't even try validating preferred_username
|
2019-09-09 00:52:49 +02:00
|
|
|
defp unique_username_validator(changeset), do: changeset
|
2019-02-25 18:35:00 +01:00
|
|
|
|
2019-09-09 00:52:49 +02:00
|
|
|
@spec build_urls(Ecto.Changeset.t(), ActorType.t()) :: Ecto.Changeset.t()
|
2018-12-03 11:58:57 +01:00
|
|
|
defp build_urls(changeset, type \\ :Person)
|
2018-12-03 12:08:18 +01:00
|
|
|
|
2019-04-25 19:05:05 +02:00
|
|
|
defp build_urls(%Ecto.Changeset{changes: %{preferred_username: username}} = changeset, _type) do
|
2018-12-03 11:58:57 +01:00
|
|
|
changeset
|
2019-09-09 00:52:49 +02:00
|
|
|
|> put_change(:outbox_url, build_url(username, :outbox))
|
|
|
|
|> put_change(:followers_url, build_url(username, :followers))
|
|
|
|
|> put_change(:following_url, build_url(username, :following))
|
|
|
|
|> put_change(:inbox_url, build_url(username, :inbox))
|
2018-10-11 17:37:39 +02:00
|
|
|
|> put_change(:shared_inbox_url, "#{MobilizonWeb.Endpoint.url()}/inbox")
|
2019-04-25 19:05:05 +02:00
|
|
|
|> put_change(:url, build_url(username, :page))
|
2018-05-30 18:59:13 +02:00
|
|
|
end
|
|
|
|
|
2018-12-03 11:58:57 +01:00
|
|
|
defp build_urls(%Ecto.Changeset{} = changeset, _type), do: changeset
|
|
|
|
|
2019-04-25 19:05:05 +02:00
|
|
|
@doc """
|
2019-09-09 00:52:49 +02:00
|
|
|
Builds an AP URL for an actor.
|
2019-04-25 19:05:05 +02:00
|
|
|
"""
|
2019-09-09 00:52:49 +02:00
|
|
|
@spec build_url(String.t(), atom, keyword) :: String.t()
|
2019-04-25 19:05:05 +02:00
|
|
|
def build_url(preferred_username, endpoint, args \\ [])
|
|
|
|
|
2019-09-09 00:52:49 +02:00
|
|
|
def build_url(username, :inbox, _args), do: "#{build_url(username, :page)}/inbox"
|
|
|
|
|
2019-04-25 19:05:05 +02:00
|
|
|
def build_url(preferred_username, :page, args) do
|
|
|
|
Endpoint
|
|
|
|
|> Routes.page_url(:actor, preferred_username, args)
|
|
|
|
|> URI.decode()
|
|
|
|
end
|
|
|
|
|
|
|
|
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
|
|
|
|
|
2019-03-19 11:16:03 +01:00
|
|
|
@doc """
|
|
|
|
Clear multiple caches for an actor
|
|
|
|
"""
|
2019-09-09 00:52:49 +02:00
|
|
|
# TODO: move to MobilizonWeb
|
2019-03-19 11:16:03 +01:00
|
|
|
@spec clear_cache(struct()) :: {:ok, true}
|
2019-03-01 18:30:46 +01:00
|
|
|
def clear_cache(%Actor{preferred_username: preferred_username, domain: nil}) do
|
|
|
|
Cachex.del(:activity_pub, "actor_" <> preferred_username)
|
2019-03-19 11:16:03 +01:00
|
|
|
Cachex.del(:feed, "actor_" <> preferred_username)
|
|
|
|
Cachex.del(:ics, "actor_" <> preferred_username)
|
2019-03-01 18:30:46 +01:00
|
|
|
end
|
2019-09-09 00:52:49 +02:00
|
|
|
|
|
|
|
@spec build_relay_creation_attrs(map) :: map
|
|
|
|
defp build_relay_creation_attrs(%{url: url, preferred_username: preferred_username}) do
|
|
|
|
%{
|
|
|
|
"name" => Config.get([:instance, :name], "Mobilizon"),
|
|
|
|
"summary" =>
|
|
|
|
Config.get(
|
|
|
|
[:instance, :description],
|
|
|
|
"An internal service actor for this Mobilizon instance"
|
|
|
|
),
|
|
|
|
"url" => url,
|
|
|
|
"keys" => Crypto.generate_rsa_2048_private_key(),
|
|
|
|
"preferred_username" => preferred_username,
|
|
|
|
"domain" => nil,
|
|
|
|
"inbox_url" => "#{MobilizonWeb.Endpoint.url()}/inbox",
|
|
|
|
"followers_url" => "#{url}/followers",
|
|
|
|
"following_url" => "#{url}/following",
|
|
|
|
"shared_inbox_url" => "#{MobilizonWeb.Endpoint.url()}/inbox",
|
|
|
|
"type" => :Application
|
|
|
|
}
|
|
|
|
end
|
2018-05-18 09:56:21 +02:00
|
|
|
end
|