wip events

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2022-07-22 23:40:53 +02:00
parent 0c9e64265c
commit 3a6274f29a
17 changed files with 544 additions and 26 deletions

View file

@ -19,7 +19,9 @@ defmodule Pleroma.Constants do
"context_id",
"deleted_activity_id",
"pleroma_internal",
"generator"
"generator",
"participants",
"participant_count"
]
)

View file

@ -19,6 +19,30 @@ defmodule Pleroma.Web.ActivityPub.Builder do
require Pleroma.Constants
def accept_or_reject(%User{ap_id: ap_id}, activity, type) do
data = %{
"id" => Utils.generate_activity_id(),
"actor" => ap_id,
"type" => type,
"object" => activity.data["id"],
"to" => [activity.actor]
}
{:ok, data, []}
end
def accept_or_reject(%Object{data: %{"actor" => actor}} = object, activity, type) do
data = %{
"id" => Utils.generate_activity_id(),
"actor" => actor,
"type" => type,
"object" => activity.data["id"],
"to" => [activity.actor]
}
{:ok, data, []}
end
def accept_or_reject(actor, activity, type) do
data = %{
"id" => Utils.generate_activity_id(),
@ -31,14 +55,14 @@ def accept_or_reject(actor, activity, type) do
{:ok, data, []}
end
@spec reject(User.t(), Activity.t()) :: {:ok, map(), keyword()}
def reject(actor, rejected_activity) do
accept_or_reject(actor, rejected_activity, "Reject")
@spec reject(User.t() | Object.t(), Activity.t()) :: {:ok, map(), keyword()}
def reject(object, rejected_activity) do
accept_or_reject(object, rejected_activity, "Reject")
end
@spec accept(User.t(), Activity.t()) :: {:ok, map(), keyword()}
def accept(actor, accepted_activity) do
accept_or_reject(actor, accepted_activity, "Accept")
@spec accept(User.t() | Object.t(), Activity.t()) :: {:ok, map(), keyword()}
def accept(object, accepted_activity) do
accept_or_reject(object, accepted_activity, "Accept")
end
@spec follow(User.t(), User.t()) :: {:ok, map(), keyword()}
@ -337,4 +361,14 @@ def unpin(%User{} = user, object) do
defp pinned_url(nickname) when is_binary(nickname) do
Pleroma.Web.Router.Helpers.activity_pub_url(Pleroma.Web.Endpoint, :pinned, nickname)
end
def join(actor, object) do
with {:ok, data, meta} <- object_action(actor, object) do
data =
data
|> Map.put("type", "Join")
{:ok, data, meta}
end
end
end

View file

@ -30,6 +30,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
alias Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator
alias Pleroma.Web.ActivityPub.ObjectValidators.EventValidator
alias Pleroma.Web.ActivityPub.ObjectValidators.FollowValidator
alias Pleroma.Web.ActivityPub.ObjectValidators.JoinValidator
alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator
alias Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator
alias Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator
@ -143,7 +144,7 @@ def validate(%{"type" => type} = object, meta)
def validate(%{"type" => type} = object, meta)
when type in ~w[Accept Reject Follow Update Like EmojiReact Announce
ChatMessage Answer] do
ChatMessage Answer Join] do
validator =
case type do
"Accept" -> AcceptRejectValidator
@ -155,6 +156,7 @@ def validate(%{"type" => type} = object, meta)
"Announce" -> AnnounceValidator
"ChatMessage" -> ChatMessageValidator
"Answer" -> AnswerValidator
"Join" -> JoinValidator
end
with {:ok, object} <-

View file

@ -6,6 +6,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AcceptRejectValidator do
use Ecto.Schema
alias Pleroma.Activity
alias Pleroma.Object
import Ecto.Changeset
import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations
@ -32,7 +33,7 @@ defp validate_data(cng) do
|> validate_required([:id, :type, :actor, :to, :cc, :object])
|> validate_inclusion(:type, ["Accept", "Reject"])
|> validate_actor_presence()
|> validate_object_presence(allowed_types: ["Follow"])
|> validate_object_presence(allowed_types: ["Follow", "Join"])
|> validate_accept_reject_rights()
end
@ -44,8 +45,8 @@ def cast_and_validate(data) do
def validate_accept_reject_rights(cng) do
with object_id when is_binary(object_id) <- get_field(cng, :object),
%Activity{data: %{"object" => followed_actor}} <- Activity.get_by_ap_id(object_id),
true <- followed_actor == get_field(cng, :actor) do
activity <- Activity.get_by_ap_id(object_id),
true <- validate_actor(activity, get_field(cng, :actor)) do
cng
else
_e ->
@ -53,4 +54,13 @@ def validate_accept_reject_rights(cng) do
|> add_error(:actor, "can't accept or reject the given activity")
end
end
defp validate_actor(%Activity{data: %{"type" => "Follow", "object" => followed_actor}}, actor) do
followed_actor == actor
end
defp validate_actor(%Activity{data: %{"type" => "Join", "object" => joined_event}}, actor) do
%Object{data: %{"actor" => event_author}} = Object.get_cached_by_ap_id(joined_event)
event_author == actor
end
end

View file

@ -72,6 +72,8 @@ defmacro event_object_fields do
field(:startTime, ObjectValidators.DateTime)
field(:endTime, ObjectValidators.DateTime)
field(:joinMode, :string, default: "free")
embeds_one(:location, PlaceValidator)
end
end

View file

@ -64,6 +64,7 @@ def changeset(struct, data) do
defp validate_data(data_cng) do
data_cng
|> validate_inclusion(:type, ["Event"])
|> validate_inclusion(:joinMode, ~w[free restricted invite])
|> validate_required([:id, :actor, :attributedTo, :type, :context, :context_id])
|> CommonValidations.validate_any_presence([:cc, :to])
|> CommonValidations.validate_fields_match([:actor, :attributedTo])

View file

@ -0,0 +1,87 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.ObjectValidators.JoinValidator do
use Ecto.Schema
alias Pleroma.Object
alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes
alias Pleroma.Web.ActivityPub.Utils
import Ecto.Changeset
import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations
@primary_key false
embedded_schema do
quote do
unquote do
import Elixir.Pleroma.Web.ActivityPub.ObjectValidators.CommonFields
message_fields()
activity_fields()
end
end
field(:state, :string, default: "pending")
field(:participationMessage, :string)
end
def cast_data(data) do
data =
data
|> fix()
%__MODULE__{}
|> changeset(data)
end
def changeset(struct, data) do
struct
|> cast(data, __schema__(:fields))
end
defp fix(data) do
data =
data
|> CommonFixes.fix_actor()
|> CommonFixes.fix_activity_addressing()
with %Object{} = object <- Object.normalize(data["object"]) do
data
|> CommonFixes.fix_activity_context(object)
|> CommonFixes.fix_object_action_recipients(object)
else
_ -> data
end
end
defp validate_data(data_cng) do
data_cng
|> validate_inclusion(:type, ["Join"])
|> validate_inclusion(:state, ~w{pending reject accept})
|> validate_required([:id, :type, :object, :actor, :context, :to, :cc])
|> validate_actor_presence()
|> validate_object_presence(allowed_types: ["Event"])
|> validate_existing_join()
end
def cast_and_validate(data) do
data
|> cast_data()
|> validate_data()
end
defp validate_existing_join(%{changes: %{actor: actor, object: object}} = cng) do
if Utils.get_existing_join(actor, %{data: %{"id" => object}}) do
cng
|> add_error(:actor, "already joined this event")
|> add_error(:object, "already joined by this actor")
else
cng
end
end
defp validate_existing_join(cng), do: cng
end

View file

@ -46,19 +46,14 @@ def handle(
data: %{
"actor" => actor,
"type" => "Accept",
"object" => follow_activity_id
"object" => activity_id
}
} = object,
meta
) do
with %Activity{actor: follower_id} = follow_activity <-
Activity.get_by_ap_id(follow_activity_id),
%User{} = followed <- User.get_cached_by_ap_id(actor),
%User{} = follower <- User.get_cached_by_ap_id(follower_id),
{:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "accept"),
{:ok, _follower, followed} <-
FollowingRelationship.update(follower, followed, :follow_accept) do
Notification.update_notification_type(followed, follow_activity)
with %Activity{} = activity <-
Activity.get_by_ap_id(activity_id) do
handle_accepted(activity, actor)
end
{:ok, object, meta}
@ -74,21 +69,63 @@ def handle(
data: %{
"actor" => actor,
"type" => "Reject",
"object" => follow_activity_id
"object" => activity_id
}
} = object,
meta
) do
with %Activity{actor: follower_id} = follow_activity <-
Activity.get_by_ap_id(follow_activity_id),
%User{} = followed <- User.get_cached_by_ap_id(actor),
with %Activity{actor: follower_id} = activity <-
Activity.get_by_ap_id(activity_id) do
handle_rejected(activity, actor)
end
{:ok, object, meta}
end
defp handle_accepted(
%Activity{actor: follower_id, data: %{"type" => "Follow"}} = follow_activity,
actor
) do
with %User{} = followed <- User.get_cached_by_ap_id(actor),
%User{} = follower <- User.get_cached_by_ap_id(follower_id),
{:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "accept"),
{:ok, _follower, followed} <-
FollowingRelationship.update(follower, followed, :follow_accept) do
Notification.update_notification_type(followed, follow_activity)
end
end
defp handle_accepted(
%Activity{data: %{"type" => "Join", "object" => event_id}} = join_activity,
_actor
) do
with joined_event <- Object.get_by_ap_id(event_id),
{:o, join_activity} <- Utils.update_follow_state(join_activity, "accept") do
Utils.add_participation_to_object(join_activity, joined_event)
# Notification.update_notification_type(followed, follow_activity)
end
end
defp handle_rejected(
%Activity{actor: follower_id, data: %{"type" => "Follow"}} = follow_activity,
actor
) do
with %User{} = followed <- User.get_cached_by_ap_id(actor),
%User{} = follower <- User.get_cached_by_ap_id(follower_id),
{:ok, _follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "reject") do
FollowingRelationship.update(follower, followed, :follow_reject)
Notification.dismiss(follow_activity)
end
end
{:ok, object, meta}
defp handle_rejected(
%Activity{data: %{"type" => "Join", "object" => event_id}} = join_activity,
_actor
) do
with joined_event <- Object.get_by_ap_id(event_id),
{:o, join_activity} <- Utils.update_join_state(join_activity, "reject") do
Utils.remove_participation_from_object(join_activity, joined_event)
end
end
# Tasks this handle
@ -384,6 +421,20 @@ def handle(%{data: %{"type" => "Remove"} = data} = object, meta) do
end
end
# Tasks this handles:
# accepts join if event is local
@impl true
def handle(%{data: %{"type" => "Join"}} = object, meta) do
joined_event = Object.get_by_ap_id(object.data["object"])
if Object.local?(joined_event) and joined_event.data["joinMode"] == "free" do
{:ok, accept_data, _} = Builder.accept(joined_event, object)
{:ok, _activity, _} = Pipeline.common_pipeline(accept_data, local: true)
end
{:ok, object, meta}
end
# Nothing to do
@impl true
def handle(object, meta) do

View file

@ -432,6 +432,29 @@ defp fetch_likes(object) do
end
end
def add_participation_to_object(%Activity{data: %{"actor" => actor}}, object) do
[actor | fetch_participations(object)]
|> Enum.uniq()
|> update_participations_in_object(object)
end
def remove_participation_from_object(%Activity{data: %{"actor" => actor}}, object) do
List.delete(fetch_participations(object), actor)
|> update_participations_in_object(object)
end
defp update_participations_in_object(participations, object) do
update_element_in_object("participation", participations, object)
end
defp fetch_participations(object) do
if is_list(object.data["participations"]) do
object.data["participations"]
else
[]
end
end
#### Follow-related helpers
@doc """
@ -887,4 +910,26 @@ def get_existing_votes(actor, %{data: %{"id" => id}}) do
|> where([a, object: o], fragment("(?)->>'type' = 'Answer'", o.data))
|> Repo.all()
end
### Join-related helpers
def get_existing_join(actor, %{data: %{"id" => id}}) do
actor
|> Activity.Queries.by_actor()
|> Activity.Queries.by_object_id(id)
|> Activity.Queries.by_type("Join")
|> limit(1)
|> Repo.one()
end
def update_join_state(
%Activity{} = activity,
state
) do
new_data = Map.put(activity.data, "state", state)
changeset = Changeset.change(activity, data: new_data)
with {:ok, activity} <- Repo.update(changeset) do
{:ok, activity}
end
end
end

View file

@ -0,0 +1,100 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.PleromaEventOperation do
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.ApiError
alias Pleroma.Web.ApiSpec.Schemas.FlakeID
alias Pleroma.Web.ApiSpec.Schemas.Status
import Pleroma.Web.ApiSpec.Helpers
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
def create_operation do
%Operation{
tags: ["Event actions"],
summary: "Publish new status",
security: [%{"oAuth" => ["write"]}],
description: "Create a new event",
operationId: "PleromaAPI.EventController.create",
requestBody: request_body("Parameters", create_request(), required: true),
responses: %{
200 => event_response(),
422 => Operation.response("Bad Request", "application/json", ApiError)
}
}
end
def participate_operation do
%Operation{
tags: ["Event actions"],
summary: "Participate",
security: [%{"oAuth" => ["write"]}],
description: "Participate in an event",
operationId: "PleromaAPI.EventController.participate",
parameters: [id_param()],
responses: %{
200 => event_response(),
404 => Operation.response("Not Found", "application/json", ApiError)
}
}
end
defp create_request do
%Schema{
title: "EventCreateRequest",
type: :object,
properties: %{
name: %Schema{
type: :string,
description: "Name of the event."
},
content: %Schema{
type: :string,
description: "Text description of the event."
},
start_time: %Schema{
type: :string,
format: :"date-time",
description: "Start time."
},
end_time: %Schema{
type: :string,
format: :"date-time",
description: "End time."
},
join_mode: %Schema{
type: :string,
enum: ["free", "restricted"]
}
},
example: %{
"name" => "Example event",
"content" => "No information for now.",
"start_time" => "21-02-2022 22:00:00",
"end_time" => "21-02-2022 23:00:00"
}
}
end
defp event_response do
Operation.response(
"Status",
"application/json",
Status
)
end
defp id_param do
Operation.parameter(:id, :path, FlakeID, "Event ID",
example: "9umDrYheeY451cQnEe",
required: true
)
end
end

View file

@ -304,6 +304,43 @@ def vote(user, %{data: %{"type" => "Question"}} = object, choices) do
end
end
def join(%User{} = user, id) do
case join_helper(user, id) do
{:ok, _} = res ->
res
{:error, :not_found} = res ->
res
{:error, e} ->
Logger.error("Could not join #{id}. Error: #{inspect(e, pretty: true)}")
{:error, dgettext("errors", "Could not join")}
end
end
def join_helper(user, id) do
with {_, %Activity{object: object}} <- {:find_object, Activity.get_by_id_with_object(id)},
{_, {:ok, join_object, meta}} <- {:build_object, Builder.join(user, object)},
{_, {:ok, %Activity{} = activity, _meta}} <-
{:common_pipeline,
Pipeline.common_pipeline(join_object, Keyword.put(meta, :local, true))} do
{:ok, activity}
else
{:find_object, _} ->
{:error, :not_found}
{:common_pipeline, {:error, {:validate, {:error, changeset}}}} = e ->
if {:object, {"already joined by this actor", []}} in changeset.errors do
{:ok, :already_joined}
else
{:error, e}
end
e ->
{:error, e}
end
end
defp validate_not_author(%{data: %{"actor" => ap_id}}, %{ap_id: ap_id}),
do: {:error, dgettext("errors", "Poll's author can't vote")}
@ -598,4 +635,10 @@ def get_user(ap_id, fake_record_fallback \\ true) do
nil
end
end
def event(user, data) do
with {:ok, draft} <- ActivityDraft.event(user, data) do
ActivityPub.create(draft.changes)
end
end
end

View file

@ -88,6 +88,32 @@ defp listen_object(draft) do
%__MODULE__{draft | object: object}
end
def event(user, params) do
user
|> new(params)
|> visibility()
|> to_and_cc()
|> context()
|> event_object()
|> with_valid(&changes/1)
|> validate()
end
defp event_object(draft) do
object =
draft.params
|> Map.take([:title, :content])
|> Map.put("type", "Event")
|> Map.put("to", draft.to)
|> Map.put("cc", draft.cc)
|> Map.put("actor", draft.user.ap_id)
|> Map.put("startTime", draft.params[:start_time])
|> Map.put("endTime", draft.params[:end_time])
|> Map.put("joinMode", draft.params[:join_mode])
%__MODULE__{draft | object: object}
end
defp put_params(draft, params) do
params = Map.put_new(params, :in_reply_to_status_id, params[:in_reply_to_id])
%__MODULE__{draft | params: params}

View file

@ -562,6 +562,8 @@ def build_event(%{"type" => "Event"} = data) do
%{
start_time: data["startTime"],
end_time: data["endTime"],
join_mode: data["joinMode"],
participants_count: data["participant_count"]
}
end

View file

@ -0,0 +1,77 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.PleromaAPI.EventController do
use Pleroma.Web, :controller
import Pleroma.Web.ControllerHelper,
only: [try_render: 3]
alias Pleroma.Activity
alias Pleroma.Web.MastodonAPI.StatusView
alias Pleroma.Web.Plugs.OAuthScopesPlug
alias Pleroma.Web.CommonAPI
plug(Pleroma.Web.ApiSpec.CastAndValidate)
plug(
OAuthScopesPlug,
%{scopes: ["write"]}
when action in [:create, :participate]
)
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaEventOperation
def create(%{assigns: %{user: user}, body_params: params} = conn, _) do
with {:ok, activity} <- CommonAPI.event(user, params) do
conn
|> put_view(StatusView)
|> try_render("show.json",
activity: activity,
for: user,
as: :activity
)
else
{:error, {:reject, message}} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: message})
{:error, message} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: message})
end
end
def participations(conn, %{"id" => activity_id}) do
end
def participation_requests(conn, %{"id" => activity_id}) do
%Activity{object: %Object{data: %{"id" => ap_id}}} = activity <-
Activity.get_by_id_with_object(activity_id)
params =
params
|> Map.put(:type, "Join")
|> Map.put(:object, ap_id)
|> Map.put(:state, "pending")
activities =
recipients
|> ActivityPub.fetch_activities(params)
|> Enum.reverse()
end
def participate(%{assigns: %{user: user}} = conn, %{"id" => activity_id}) do
with {:ok, _} <- CommonAPI.join(user, activity_id),
%Activity{} = activity <- Activity.get_by_id(activity_id) do
conn
|> put_view(StatusView)
|> try_render("show.json", activity: activity, for: user, as: :activity)
end
end
end

View file

@ -0,0 +1,24 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.PleromaAPI.EventView do
use Pleroma.Web, :view
alias Pleroma.Web.MastodonAPI
def render(
"participation_requests.json",
%{participation_requests: participation_requests} = opts
) do
render_many(
participation_requests,
__MODULE__,
"participation_request.json",
Map.delete(opts, :participation_requests)
)
end
def render("participation_request.json", %{participation_request: participation_request} = opts) do
%{}
end
end

View file

@ -441,6 +441,9 @@ defmodule Pleroma.Web.Router do
get("/backups", BackupController, :index)
post("/backups", BackupController, :create)
post("/events", EventController, :create)
post("/events/:id/participate", EventController, :participate)
end
scope [] do

View file

@ -36,7 +36,16 @@
"@id": "as:alsoKnownAs",
"@type": "@id"
},
"vcard": "http://www.w3.org/2006/vcard/ns#"
"vcard": "http://www.w3.org/2006/vcard/ns#",
"mz": "https://joinmobilizon.org/ns#",
"joinMode": {
"@id": "mz:joinMode",
"@type": "mz:joinModeType"
},
"joinModeType": {
"@id": "mz:joinModeType",
"@type": "rdfs:Class"
}
}
]
}