Polls 2 Electric Boogalo Closes #657 See merge request pleroma/pleroma!1190tags/v1.1.4
@@ -16,7 +16,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). | |||||
- Mix Tasks: `mix pleroma.database remove_embedded_objects` | - Mix Tasks: `mix pleroma.database remove_embedded_objects` | ||||
- Mix Tasks: `mix pleroma.database update_users_following_followers_counts` | - Mix Tasks: `mix pleroma.database update_users_following_followers_counts` | ||||
- Mix Tasks: `mix pleroma.user toggle_confirmed` | - Mix Tasks: `mix pleroma.user toggle_confirmed` | ||||
- Federation: Support for `Question` and `Answer` objects | |||||
- Federation: Support for reports | - Federation: Support for reports | ||||
- Configuration: `poll_limits` option | |||||
- Configuration: `safe_dm_mentions` option | - Configuration: `safe_dm_mentions` option | ||||
- Configuration: `link_name` option | - Configuration: `link_name` option | ||||
- Configuration: `fetch_initial_posts` option | - Configuration: `fetch_initial_posts` option | ||||
@@ -37,6 +39,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). | |||||
- Mastodon API: `/api/v1/pleroma/accounts/:id/favourites` (API extension) | - Mastodon API: `/api/v1/pleroma/accounts/:id/favourites` (API extension) | ||||
- Mastodon API: [Reports](https://docs.joinmastodon.org/api/rest/reports/) | - Mastodon API: [Reports](https://docs.joinmastodon.org/api/rest/reports/) | ||||
- Mastodon API: `POST /api/v1/accounts` (account creation API) | - Mastodon API: `POST /api/v1/accounts` (account creation API) | ||||
- Mastodon API: [Polls](https://docs.joinmastodon.org/api/rest/polls/) | |||||
- ActivityPub C2S: OAuth endpoints | - ActivityPub C2S: OAuth endpoints | ||||
- Metadata: RelMe provider | - Metadata: RelMe provider | ||||
- OAuth: added support for refresh tokens | - OAuth: added support for refresh tokens | ||||
@@ -208,6 +208,12 @@ config :pleroma, :instance, | |||||
avatar_upload_limit: 2_000_000, | avatar_upload_limit: 2_000_000, | ||||
background_upload_limit: 4_000_000, | background_upload_limit: 4_000_000, | ||||
banner_upload_limit: 4_000_000, | banner_upload_limit: 4_000_000, | ||||
poll_limits: %{ | |||||
max_options: 20, | |||||
max_option_chars: 200, | |||||
min_expiration: 0, | |||||
max_expiration: 365 * 24 * 60 * 60 | |||||
}, | |||||
registrations_open: true, | registrations_open: true, | ||||
federating: true, | federating: true, | ||||
federation_reachability_timeout_days: 7, | federation_reachability_timeout_days: 7, | ||||
@@ -71,6 +71,11 @@ config :pleroma, Pleroma.Emails.Mailer, | |||||
* `avatar_upload_limit`: File size limit of user’s profile avatars | * `avatar_upload_limit`: File size limit of user’s profile avatars | ||||
* `background_upload_limit`: File size limit of user’s profile backgrounds | * `background_upload_limit`: File size limit of user’s profile backgrounds | ||||
* `banner_upload_limit`: File size limit of user’s profile banners | * `banner_upload_limit`: File size limit of user’s profile banners | ||||
* `poll_limits`: A map with poll limits for **local** polls | |||||
* `max_options`: Maximum number of options | |||||
* `max_option_chars`: Maximum number of characters per option | |||||
* `min_expiration`: Minimum expiration time (in seconds) | |||||
* `max_expiration`: Maximum expiration time (in seconds) | |||||
* `registrations_open`: Enable registrations for anyone, invitations can be enabled when false. | * `registrations_open`: Enable registrations for anyone, invitations can be enabled when false. | ||||
* `invites_enabled`: Enable user invitations for admins (depends on `registrations_open: false`). | * `invites_enabled`: Enable user invitations for admins (depends on `registrations_open: false`). | ||||
* `account_activation_required`: Require users to confirm their emails before signing in. | * `account_activation_required`: Require users to confirm their emails before signing in. | ||||
@@ -49,7 +49,7 @@ defmodule Pleroma.Conversation do | |||||
with true <- Pleroma.Web.ActivityPub.Visibility.is_direct?(activity), | with true <- Pleroma.Web.ActivityPub.Visibility.is_direct?(activity), | ||||
"Create" <- activity.data["type"], | "Create" <- activity.data["type"], | ||||
object <- Pleroma.Object.normalize(activity), | object <- Pleroma.Object.normalize(activity), | ||||
"Note" <- object.data["type"], | |||||
true <- object.data["type"] in ["Note", "Question"], | |||||
ap_id when is_binary(ap_id) and byte_size(ap_id) > 0 <- object.data["context"] do | ap_id when is_binary(ap_id) and byte_size(ap_id) > 0 <- object.data["context"] do | ||||
{:ok, conversation} = create_for_ap_id(ap_id) | {:ok, conversation} = create_for_ap_id(ap_id) | ||||
@@ -127,10 +127,15 @@ defmodule Pleroma.Notification do | |||||
def create_notifications(%Activity{data: %{"to" => _, "type" => type}} = activity) | def create_notifications(%Activity{data: %{"to" => _, "type" => type}} = activity) | ||||
when type in ["Create", "Like", "Announce", "Follow"] do | when type in ["Create", "Like", "Announce", "Follow"] do | ||||
users = get_notified_from_activity(activity) | |||||
notifications = Enum.map(users, fn user -> create_notification(activity, user) end) | |||||
{:ok, notifications} | |||||
object = Object.normalize(activity) | |||||
unless object && object.data["type"] == "Answer" do | |||||
users = get_notified_from_activity(activity) | |||||
notifications = Enum.map(users, fn user -> create_notification(activity, user) end) | |||||
{:ok, notifications} | |||||
else | |||||
{:ok, []} | |||||
end | |||||
end | end | ||||
def create_notifications(_), do: {:ok, []} | def create_notifications(_), do: {:ok, []} | ||||
@@ -35,6 +35,9 @@ defmodule Pleroma.Object do | |||||
|> unique_constraint(:ap_id, name: :objects_unique_apid_index) | |> unique_constraint(:ap_id, name: :objects_unique_apid_index) | ||||
end | end | ||||
def get_by_id(nil), do: nil | |||||
def get_by_id(id), do: Repo.get(Object, id) | |||||
def get_by_ap_id(nil), do: nil | def get_by_ap_id(nil), do: nil | ||||
def get_by_ap_id(ap_id) do | def get_by_ap_id(ap_id) do | ||||
@@ -195,4 +198,34 @@ defmodule Pleroma.Object do | |||||
_ -> {:error, "Not found"} | _ -> {:error, "Not found"} | ||||
end | end | ||||
end | end | ||||
def increase_vote_count(ap_id, name) do | |||||
with %Object{} = object <- Object.normalize(ap_id), | |||||
"Question" <- object.data["type"] do | |||||
multiple = Map.has_key?(object.data, "anyOf") | |||||
options = | |||||
(object.data["anyOf"] || object.data["oneOf"] || []) | |||||
|> Enum.map(fn | |||||
%{"name" => ^name} = option -> | |||||
Kernel.update_in(option["replies"]["totalItems"], &(&1 + 1)) | |||||
option -> | |||||
option | |||||
end) | |||||
data = | |||||
if multiple do | |||||
Map.put(object.data, "anyOf", options) | |||||
else | |||||
Map.put(object.data, "oneOf", options) | |||||
end | |||||
object | |||||
|> Object.change(%{data: data}) | |||||
|> update_and_set_cache() | |||||
else | |||||
_ -> :noop | |||||
end | |||||
end | |||||
end | end |
@@ -108,6 +108,15 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do | |||||
def decrease_replies_count_if_reply(_object), do: :noop | def decrease_replies_count_if_reply(_object), do: :noop | ||||
def increase_poll_votes_if_vote(%{ | |||||
"object" => %{"inReplyTo" => reply_ap_id, "name" => name}, | |||||
"type" => "Create" | |||||
}) do | |||||
Object.increase_vote_count(reply_ap_id, name) | |||||
end | |||||
def increase_poll_votes_if_vote(_create_data), do: :noop | |||||
def insert(map, local \\ true, fake \\ false) when is_map(map) do | def insert(map, local \\ true, fake \\ false) when is_map(map) do | ||||
with nil <- Activity.normalize(map), | with nil <- Activity.normalize(map), | ||||
map <- lazy_put_activity_defaults(map, fake), | map <- lazy_put_activity_defaults(map, fake), | ||||
@@ -183,40 +192,42 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do | |||||
public = "https://www.w3.org/ns/activitystreams#Public" | public = "https://www.w3.org/ns/activitystreams#Public" | ||||
if activity.data["type"] in ["Create", "Announce", "Delete"] do | if activity.data["type"] in ["Create", "Announce", "Delete"] do | ||||
Pleroma.Web.Streamer.stream("user", activity) | |||||
Pleroma.Web.Streamer.stream("list", activity) | |||||
object = Object.normalize(activity) | |||||
# Do not stream out poll replies | |||||
unless object.data["type"] == "Answer" do | |||||
Pleroma.Web.Streamer.stream("user", activity) | |||||
Pleroma.Web.Streamer.stream("list", activity) | |||||
if Enum.member?(activity.data["to"], public) do | |||||
Pleroma.Web.Streamer.stream("public", activity) | |||||
if Enum.member?(activity.data["to"], public) do | |||||
Pleroma.Web.Streamer.stream("public", activity) | |||||
if activity.local do | |||||
Pleroma.Web.Streamer.stream("public:local", activity) | |||||
end | |||||
if activity.data["type"] in ["Create"] do | |||||
object = Object.normalize(activity) | |||||
if activity.local do | |||||
Pleroma.Web.Streamer.stream("public:local", activity) | |||||
end | |||||
object.data | |||||
|> Map.get("tag", []) | |||||
|> Enum.filter(fn tag -> is_bitstring(tag) end) | |||||
|> Enum.each(fn tag -> Pleroma.Web.Streamer.stream("hashtag:" <> tag, activity) end) | |||||
if activity.data["type"] in ["Create"] do | |||||
object.data | |||||
|> Map.get("tag", []) | |||||
|> Enum.filter(fn tag -> is_bitstring(tag) end) | |||||
|> Enum.each(fn tag -> Pleroma.Web.Streamer.stream("hashtag:" <> tag, activity) end) | |||||
if object.data["attachment"] != [] do | |||||
Pleroma.Web.Streamer.stream("public:media", activity) | |||||
if object.data["attachment"] != [] do | |||||
Pleroma.Web.Streamer.stream("public:media", activity) | |||||
if activity.local do | |||||
Pleroma.Web.Streamer.stream("public:local:media", activity) | |||||
if activity.local do | |||||
Pleroma.Web.Streamer.stream("public:local:media", activity) | |||||
end | |||||
end | end | ||||
end | end | ||||
else | |||||
# TODO: Write test, replace with visibility test | |||||
if !Enum.member?(activity.data["cc"] || [], public) && | |||||
!Enum.member?( | |||||
activity.data["to"], | |||||
User.get_cached_by_ap_id(activity.data["actor"]).follower_address | |||||
), | |||||
do: Pleroma.Web.Streamer.stream("direct", activity) | |||||
end | end | ||||
else | |||||
# TODO: Write test, replace with visibility test | |||||
if !Enum.member?(activity.data["cc"] || [], public) && | |||||
!Enum.member?( | |||||
activity.data["to"], | |||||
User.get_cached_by_ap_id(activity.data["actor"]).follower_address | |||||
), | |||||
do: Pleroma.Web.Streamer.stream("direct", activity) | |||||
end | end | ||||
end | end | ||||
end | end | ||||
@@ -235,6 +246,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do | |||||
{:ok, activity} <- insert(create_data, local, fake), | {:ok, activity} <- insert(create_data, local, fake), | ||||
{:fake, false, activity} <- {:fake, fake, activity}, | {:fake, false, activity} <- {:fake, fake, activity}, | ||||
_ <- increase_replies_count_if_reply(create_data), | _ <- increase_replies_count_if_reply(create_data), | ||||
_ <- increase_poll_votes_if_vote(create_data), | |||||
# Changing note count prior to enqueuing federation task in order to avoid | # Changing note count prior to enqueuing federation task in order to avoid | ||||
# race conditions on updating user.info | # race conditions on updating user.info | ||||
{:ok, _actor} <- increase_note_count_if_public(actor, activity), | {:ok, _actor} <- increase_note_count_if_public(actor, activity), | ||||
@@ -476,6 +488,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do | |||||
if opts["user"], do: [opts["user"].ap_id | opts["user"].following] ++ public, else: public | if opts["user"], do: [opts["user"].ap_id | opts["user"].following] ++ public, else: public | ||||
from(activity in Activity) | from(activity in Activity) | ||||
|> maybe_preload_objects(opts) | |||||
|> restrict_blocked(opts) | |> restrict_blocked(opts) | ||||
|> restrict_recipients(recipients, opts["user"]) | |> restrict_recipients(recipients, opts["user"]) | ||||
|> where( | |> where( | ||||
@@ -488,6 +501,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do | |||||
^context | ^context | ||||
) | ) | ||||
) | ) | ||||
|> exclude_poll_votes(opts) | |||||
|> order_by([activity], desc: activity.id) | |> order_by([activity], desc: activity.id) | ||||
end | end | ||||
@@ -495,7 +509,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do | |||||
def fetch_activities_for_context(context, opts \\ %{}) do | def fetch_activities_for_context(context, opts \\ %{}) do | ||||
context | context | ||||
|> fetch_activities_for_context_query(opts) | |> fetch_activities_for_context_query(opts) | ||||
|> Activity.with_preloaded_object() | |||||
|> Repo.all() | |> Repo.all() | ||||
end | end | ||||
@@ -503,7 +516,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do | |||||
Pleroma.FlakeId.t() | nil | Pleroma.FlakeId.t() | nil | ||||
def fetch_latest_activity_id_for_context(context, opts \\ %{}) do | def fetch_latest_activity_id_for_context(context, opts \\ %{}) do | ||||
context | context | ||||
|> fetch_activities_for_context_query(opts) | |||||
|> fetch_activities_for_context_query(Map.merge(%{"skip_preload" => true}, opts)) | |||||
|> limit(1) | |> limit(1) | ||||
|> select([a], a.id) | |> select([a], a.id) | ||||
|> Repo.one() | |> Repo.one() | ||||
@@ -802,6 +815,18 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do | |||||
defp restrict_muted_reblogs(query, _), do: query | defp restrict_muted_reblogs(query, _), do: query | ||||
defp exclude_poll_votes(query, %{"include_poll_votes" => "true"}), do: query | |||||
defp exclude_poll_votes(query, _) do | |||||
if has_named_binding?(query, :object) do | |||||
from([activity, object: o] in query, | |||||
where: fragment("not(?->>'type' = ?)", o.data, "Answer") | |||||
) | |||||
else | |||||
query | |||||
end | |||||
end | |||||
defp maybe_preload_objects(query, %{"skip_preload" => true}), do: query | defp maybe_preload_objects(query, %{"skip_preload" => true}), do: query | ||||
defp maybe_preload_objects(query, _) do | defp maybe_preload_objects(query, _) do | ||||
@@ -863,6 +888,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do | |||||
|> restrict_pinned(opts) | |> restrict_pinned(opts) | ||||
|> restrict_muted_reblogs(opts) | |> restrict_muted_reblogs(opts) | ||||
|> Activity.restrict_deactivated_users() | |> Activity.restrict_deactivated_users() | ||||
|> exclude_poll_votes(opts) | |||||
end | end | ||||
def fetch_activities(recipients, opts \\ %{}) do | def fetch_activities(recipients, opts \\ %{}) do | ||||
@@ -35,6 +35,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do | |||||
|> fix_likes | |> fix_likes | ||||
|> fix_addressing | |> fix_addressing | ||||
|> fix_summary | |> fix_summary | ||||
|> fix_type | |||||
end | end | ||||
def fix_summary(%{"summary" => nil} = object) do | def fix_summary(%{"summary" => nil} = object) do | ||||
@@ -335,6 +336,18 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do | |||||
def fix_content_map(object), do: object | def fix_content_map(object), do: object | ||||
def fix_type(%{"inReplyTo" => reply_id} = object) when is_binary(reply_id) do | |||||
reply = Object.normalize(reply_id) | |||||
if reply.data["type"] == "Question" and object["name"] do | |||||
Map.put(object, "type", "Answer") | |||||
else | |||||
object | |||||
end | |||||
end | |||||
def fix_type(object), do: object | |||||
defp mastodon_follow_hack(%{"id" => id, "actor" => follower_id}, followed) do | defp mastodon_follow_hack(%{"id" => id, "actor" => follower_id}, followed) do | ||||
with true <- id =~ "follows", | with true <- id =~ "follows", | ||||
%User{local: true} = follower <- User.get_cached_by_ap_id(follower_id), | %User{local: true} = follower <- User.get_cached_by_ap_id(follower_id), | ||||
@@ -405,7 +418,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do | |||||
# - tags | # - tags | ||||
# - emoji | # - emoji | ||||
def handle_incoming(%{"type" => "Create", "object" => %{"type" => objtype} = object} = data) | def handle_incoming(%{"type" => "Create", "object" => %{"type" => objtype} = object} = data) | ||||
when objtype in ["Article", "Note", "Video", "Page"] do | |||||
when objtype in ["Article", "Note", "Video", "Page", "Question", "Answer"] do | |||||
actor = Containment.get_actor(data) | actor = Containment.get_actor(data) | ||||
data = | data = | ||||
@@ -738,6 +751,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do | |||||
|> set_reply_to_uri | |> set_reply_to_uri | ||||
|> strip_internal_fields | |> strip_internal_fields | ||||
|> strip_internal_tags | |> strip_internal_tags | ||||
|> set_type | |||||
end | end | ||||
# @doc | # @doc | ||||
@@ -902,6 +916,12 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do | |||||
Map.put(object, "sensitive", "nsfw" in tags) | Map.put(object, "sensitive", "nsfw" in tags) | ||||
end | end | ||||
def set_type(%{"type" => "Answer"} = object) do | |||||
Map.put(object, "type", "Note") | |||||
end | |||||
def set_type(object), do: object | |||||
def add_attributed_to(object) do | def add_attributed_to(object) do | ||||
attributed_to = object["attributedTo"] || object["actor"] | attributed_to = object["attributedTo"] || object["actor"] | ||||
@@ -19,7 +19,7 @@ defmodule Pleroma.Web.ActivityPub.Utils do | |||||
require Logger | require Logger | ||||
@supported_object_types ["Article", "Note", "Video", "Page"] | |||||
@supported_object_types ["Article", "Note", "Video", "Page", "Question", "Answer"] | |||||
@supported_report_states ~w(open closed resolved) | @supported_report_states ~w(open closed resolved) | ||||
@valid_visibilities ~w(public unlisted private direct) | @valid_visibilities ~w(public unlisted private direct) | ||||
@@ -789,4 +789,21 @@ defmodule Pleroma.Web.ActivityPub.Utils do | |||||
[to, cc, recipients] | [to, cc, recipients] | ||||
end | end | ||||
end | end | ||||
def get_existing_votes(actor, %{data: %{"id" => id}}) do | |||||
query = | |||||
from( | |||||
[activity, object: object] in Activity.with_preloaded_object(Activity), | |||||
where: fragment("(?)->>'actor' = ?", activity.data, ^actor), | |||||
where: | |||||
fragment( | |||||
"(?)->'inReplyTo' = ?", | |||||
object.data, | |||||
^to_string(id) | |||||
), | |||||
where: fragment("(?)->>'type' = 'Answer'", object.data) | |||||
) | |||||
Repo.all(query) | |||||
end | |||||
end | end |
@@ -119,6 +119,53 @@ defmodule Pleroma.Web.CommonAPI do | |||||
end | end | ||||
end | end | ||||
def vote(user, object, choices) do | |||||
with "Question" <- object.data["type"], | |||||
{:author, false} <- {:author, object.data["actor"] == user.ap_id}, | |||||
{:existing_votes, []} <- {:existing_votes, Utils.get_existing_votes(user.ap_id, object)}, | |||||
{options, max_count} <- get_options_and_max_count(object), | |||||
option_count <- Enum.count(options), | |||||
{:choice_check, {choices, true}} <- | |||||
{:choice_check, normalize_and_validate_choice_indices(choices, option_count)}, | |||||
{:count_check, true} <- {:count_check, Enum.count(choices) <= max_count} do | |||||
answer_activities = | |||||
Enum.map(choices, fn index -> | |||||
answer_data = make_answer_data(user, object, Enum.at(options, index)["name"]) | |||||
ActivityPub.create(%{ | |||||
to: answer_data["to"], | |||||
actor: user, | |||||
context: object.data["context"], | |||||
object: answer_data, | |||||
additional: %{"cc" => answer_data["cc"]} | |||||
}) | |||||
end) | |||||
object = Object.get_cached_by_ap_id(object.data["id"]) | |||||
{:ok, answer_activities, object} | |||||
else | |||||
{:author, _} -> {:error, "Poll's author can't vote"} | |||||
{:existing_votes, _} -> {:error, "Already voted"} | |||||
{:choice_check, {_, false}} -> {:error, "Invalid indices"} | |||||
{:count_check, false} -> {:error, "Too many choices"} | |||||
end | |||||
end | |||||
defp get_options_and_max_count(object) do | |||||
if Map.has_key?(object.data, "anyOf") do | |||||
{object.data["anyOf"], Enum.count(object.data["anyOf"])} | |||||
else | |||||
{object.data["oneOf"], 1} | |||||
end | |||||
end | |||||
defp normalize_and_validate_choice_indices(choices, count) do | |||||
Enum.map_reduce(choices, true, fn index, valid -> | |||||
index = if is_binary(index), do: String.to_integer(index), else: index | |||||
{index, if(valid, do: index < count, else: valid)} | |||||
end) | |||||
end | |||||
def get_visibility(%{"visibility" => visibility}, in_reply_to) | def get_visibility(%{"visibility" => visibility}, in_reply_to) | ||||
when visibility in ~w{public unlisted private direct}, | when visibility in ~w{public unlisted private direct}, | ||||
do: {visibility, get_replied_to_visibility(in_reply_to)} | do: {visibility, get_replied_to_visibility(in_reply_to)} | ||||
@@ -154,6 +201,7 @@ defmodule Pleroma.Web.CommonAPI do | |||||
data, | data, | ||||
visibility | visibility | ||||
), | ), | ||||
{poll, poll_emoji} <- make_poll_data(data), | |||||
{to, cc} <- to_for_user_and_mentions(user, mentions, in_reply_to, visibility), | {to, cc} <- to_for_user_and_mentions(user, mentions, in_reply_to, visibility), | ||||
context <- make_context(in_reply_to), | context <- make_context(in_reply_to), | ||||
cw <- data["spoiler_text"] || "", | cw <- data["spoiler_text"] || "", | ||||
@@ -171,13 +219,14 @@ defmodule Pleroma.Web.CommonAPI do | |||||
tags, | tags, | ||||
cw, | cw, | ||||
cc, | cc, | ||||
sensitive | |||||
sensitive, | |||||
poll | |||||
), | ), | ||||
object <- | object <- | ||||
Map.put( | Map.put( | ||||
object, | object, | ||||
"emoji", | "emoji", | ||||
Formatter.get_emoji_map(full_payload) | |||||
Map.merge(Formatter.get_emoji_map(full_payload), poll_emoji) | |||||
) do | ) do | ||||
res = | res = | ||||
ActivityPub.create( | ActivityPub.create( | ||||
@@ -102,6 +102,72 @@ defmodule Pleroma.Web.CommonAPI.Utils do | |||||
end | end | ||||
end | end | ||||
def make_poll_data(%{"poll" => %{"options" => options, "expires_in" => expires_in}} = data) | |||||
when is_list(options) do | |||||
%{max_expiration: max_expiration, min_expiration: min_expiration} = | |||||
limits = Pleroma.Config.get([:instance, :poll_limits]) | |||||
# XXX: There is probably a cleaner way of doing this | |||||
try do | |||||
# In some cases mastofe sends out strings instead of integers | |||||
expires_in = if is_binary(expires_in), do: String.to_integer(expires_in), else: expires_in | |||||
if Enum.count(options) > limits.max_options do | |||||
raise ArgumentError, message: "Poll can't contain more than #{limits.max_options} options" | |||||
end | |||||
{poll, emoji} = | |||||
Enum.map_reduce(options, %{}, fn option, emoji -> | |||||
if String.length(option) > limits.max_option_chars do | |||||
raise ArgumentError, | |||||
message: | |||||
"Poll options cannot be longer than #{limits.max_option_chars} characters each" | |||||
end | |||||
{%{ | |||||
"name" => option, | |||||
"type" => "Note", | |||||
"replies" => %{"type" => "Collection", "totalItems" => 0} | |||||
}, Map.merge(emoji, Formatter.get_emoji_map(option))} | |||||
end) | |||||
case expires_in do | |||||
expires_in when expires_in > max_expiration -> | |||||
raise ArgumentError, message: "Expiration date is too far in the future" | |||||
expires_in when expires_in < min_expiration -> | |||||
raise ArgumentError, message: "Expiration date is too soon" | |||||
_ -> | |||||
:noop | |||||
end | |||||
end_time = | |||||
NaiveDateTime.utc_now() | |||||
|> NaiveDateTime.add(expires_in) | |||||
|> NaiveDateTime.to_iso8601() | |||||
poll = | |||||
if Pleroma.Web.ControllerHelper.truthy_param?(data["poll"]["multiple"]) do | |||||
%{"type" => "Question", "anyOf" => poll, "closed" => end_time} | |||||
else | |||||
%{"type" => "Question", "oneOf" => poll, "closed" => end_time} | |||||
end | |||||
{poll, emoji} | |||||
rescue | |||||
e in ArgumentError -> e.message | |||||
end | |||||
end | |||||
def make_poll_data(%{"poll" => poll}) when is_map(poll) do | |||||
"Invalid poll" | |||||
end | |||||
def make_poll_data(_data) do | |||||
{%{}, %{}} | |||||
end | |||||
def make_content_html( | def make_content_html( | ||||
status, | status, | ||||
attachments, | attachments, | ||||
@@ -224,7 +290,8 @@ defmodule Pleroma.Web.CommonAPI.Utils do | |||||
tags, | tags, | ||||
cw \\ nil, | cw \\ nil, | ||||
cc \\ [], | cc \\ [], | ||||
sensitive \\ false | |||||
sensitive \\ false, | |||||
merge \\ %{} | |||||
) do | ) do | ||||
object = %{ | object = %{ | ||||
"type" => "Note", | "type" => "Note", | ||||
@@ -239,12 +306,15 @@ defmodule Pleroma.Web.CommonAPI.Utils do | |||||
"tag" => tags |> Enum.map(fn {_, tag} -> tag end) |> Enum.uniq() | "tag" => tags |> Enum.map(fn {_, tag} -> tag end) |> Enum.uniq() | ||||
} | } | ||||
with false <- is_nil(in_reply_to), | |||||
%Object{} = in_reply_to_object <- Object.normalize(in_reply_to) do | |||||
Map.put(object, "inReplyTo", in_reply_to_object.data["id"]) | |||||
else | |||||
_ -> object | |||||
end | |||||
object = | |||||
with false <- is_nil(in_reply_to), | |||||
%Object{} = in_reply_to_object <- Object.normalize(in_reply_to) do | |||||
Map.put(object, "inReplyTo", in_reply_to_object.data["id"]) | |||||
else | |||||
_ -> object | |||||
end | |||||
Map.merge(object, merge) | |||||
end | end | ||||
def format_naive_asctime(date) do | def format_naive_asctime(date) do | ||||
@@ -421,4 +491,15 @@ defmodule Pleroma.Web.CommonAPI.Utils do | |||||
{:error, "No such conversation"} | {:error, "No such conversation"} | ||||
end | end | ||||
end | end | ||||
def make_answer_data(%User{ap_id: ap_id}, object, name) do | |||||
%{ | |||||
"type" => "Answer", | |||||
"actor" => ap_id, | |||||
"cc" => [object.data["actor"]], | |||||
"to" => [], | |||||
"name" => name, | |||||
"inReplyTo" => object.data["id"] | |||||
} | |||||
end | |||||
end | end |
@@ -197,7 +197,8 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do | |||||
languages: ["en"], | languages: ["en"], | ||||
registrations: Pleroma.Config.get([:instance, :registrations_open]), | registrations: Pleroma.Config.get([:instance, :registrations_open]), | ||||
# Extra (not present in Mastodon): | # Extra (not present in Mastodon): | ||||
max_toot_chars: Keyword.get(instance, :limit) | |||||
max_toot_chars: Keyword.get(instance, :limit), | |||||
poll_limits: Keyword.get(instance, :poll_limits) | |||||
} | } | ||||
json(conn, response) | json(conn, response) | ||||
@@ -409,6 +410,53 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do | |||||
end | end | ||||
end | end | ||||
def get_poll(%{assigns: %{user: user}} = conn, %{"id" => id}) do | |||||
with %Object{} = object <- Object.get_by_id(id), | |||||
%Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]), | |||||
true <- Visibility.visible_for_user?(activity, user) do | |||||
conn | |||||
|> put_view(StatusView) | |||||
|> try_render("poll.json", %{object: object, for: user}) | |||||
else | |||||
nil -> | |||||
conn | |||||
|> put_status(404) | |||||
|> json(%{error: "Record not found"}) | |||||
false -> | |||||
conn | |||||
|> put_status(404) | |||||
|> json(%{error: "Record not found"}) | |||||
end | |||||
end | |||||
def poll_vote(%{assigns: %{user: user}} = conn, %{"id" => id, "choices" => choices}) do | |||||
with %Object{} = object <- Object.get_by_id(id), | |||||
true <- object.data["type"] == "Question", | |||||
%Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]), | |||||
true <- Visibility.visible_for_user?(activity, user), | |||||
{:ok, _activities, object} <- CommonAPI.vote(user, object, choices) do | |||||
conn | |||||
|> put_view(StatusView) | |||||
|> try_render("poll.json", %{object: object, for: user}) | |||||
else | |||||
nil -> | |||||
conn | |||||
|> put_status(404) | |||||
|> json(%{error: "Record not found"}) | |||||
false -> | |||||
conn | |||||
|> put_status(404) | |||||
|> json(%{error: "Record not found"}) | |||||
{:error, message} -> | |||||
conn | |||||
|> put_status(422) | |||||
|> json(%{error: message}) | |||||
end | |||||
end | |||||
def scheduled_statuses(%{assigns: %{user: user}} = conn, params) do | def scheduled_statuses(%{assigns: %{user: user}} = conn, params) do | ||||
with scheduled_activities <- MastodonAPI.get_scheduled_activities(user, params) do | with scheduled_activities <- MastodonAPI.get_scheduled_activities(user, params) do | ||||
conn | conn | ||||
@@ -472,12 +520,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do | |||||
params | params | ||||
|> Map.put("in_reply_to_status_id", params["in_reply_to_id"]) | |> Map.put("in_reply_to_status_id", params["in_reply_to_id"]) | ||||
idempotency_key = | |||||
case get_req_header(conn, "idempotency-key") do | |||||
[key] -> key | |||||
_ -> Ecto.UUID.generate() | |||||
end | |||||
scheduled_at = params["scheduled_at"] | scheduled_at = params["scheduled_at"] | ||||
if scheduled_at && ScheduledActivity.far_enough?(scheduled_at) do | if scheduled_at && ScheduledActivity.far_enough?(scheduled_at) do | ||||
@@ -490,17 +532,40 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do | |||||
else | else | ||||
params = Map.drop(params, ["scheduled_at"]) | params = Map.drop(params, ["scheduled_at"]) | ||||
{:ok, activity} = | |||||
Cachex.fetch!(:idempotency_cache, idempotency_key, fn _ -> | |||||
CommonAPI.post(user, params) | |||||
end) | |||||
conn | |||||
|> put_view(StatusView) | |||||
|> try_render("status.json", %{activity: activity, for: user, as: :activity}) | |||||
case get_cached_status_or_post(conn, params) do | |||||
{:ignore, message} -> | |||||
conn | |||||
|> put_status(422) | |||||
|> json(%{error: message}) | |||||
{:error, message} -> | |||||
conn | |||||
|> put_status(422) | |||||
|> json(%{error: message}) | |||||
{_, activity} -> | |||||
conn | |||||
|> put_view(StatusView) | |||||
|> try_render("status.json", %{activity: activity, for: user, as: :activity}) | |||||
end | |||||
end | end | ||||
end | end | ||||
defp get_cached_status_or_post(%{assigns: %{user: user}} = conn, params) do | |||||
idempotency_key = | |||||
case get_req_header(conn, "idempotency-key") do | |||||
[key] -> key | |||||
_ -> Ecto.UUID.generate() | |||||
end | |||||
Cachex.fetch(:idempotency_cache, idempotency_key, fn _ -> | |||||
case CommonAPI.post(user, params) do | |||||
{:ok, activity} -> activity | |||||
{:error, message} -> {:ignore, message} | |||||
end | |||||
end) | |||||
end | |||||
def delete_status(%{assigns: %{user: user}} = conn, %{"id" => id}) do | def delete_status(%{assigns: %{user: user}} = conn, %{"id" => id}) do | ||||
with {:ok, %Activity{}} <- CommonAPI.delete(id, user) do | with {:ok, %Activity{}} <- CommonAPI.delete(id, user) do | ||||
json(conn, %{}) | json(conn, %{}) | ||||
@@ -1364,6 +1429,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do | |||||
max_toot_chars: limit, | max_toot_chars: limit, | ||||
mascot: User.get_mascot(user)["url"] | mascot: User.get_mascot(user)["url"] | ||||
}, | }, | ||||
poll_limits: Config.get([:instance, :poll_limits]), | |||||
rights: %{ | rights: %{ | ||||
delete_others_notice: present?(user.info.is_moderator), | delete_others_notice: present?(user.info.is_moderator), | ||||
admin: present?(user.info.is_admin) | admin: present?(user.info.is_admin) | ||||
@@ -240,6 +240,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do | |||||
spoiler_text: summary_html, | spoiler_text: summary_html, | ||||
visibility: get_visibility(object), | visibility: get_visibility(object), | ||||
media_attachments: attachments, | media_attachments: attachments, | ||||
poll: render("poll.json", %{object: object, for: opts[:for]}), | |||||
mentions: mentions, | mentions: mentions, | ||||
tags: build_tags(tags), | tags: build_tags(tags), | ||||
application: %{ | application: %{ | ||||
@@ -329,6 +330,64 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do | |||||
} | } | ||||
end | end | ||||
def render("poll.json", %{object: object} = opts) do | |||||
{multiple, options} = | |||||
case object.data do | |||||
%{"anyOf" => options} when is_list(options) -> {true, options} | |||||
%{"oneOf" => options} when is_list(options) -> {false, options} | |||||
_ -> {nil, nil} | |||||
end | |||||
if options do | |||||
end_time = | |||||
(object.data["closed"] || object.data["endTime"]) | |||||
|> NaiveDateTime.from_iso8601!() | |||||
expired = | |||||
end_time | |||||
|> NaiveDateTime.compare(NaiveDateTime.utc_now()) | |||||
|> case do | |||||
:lt -> true | |||||
_ -> false | |||||
end | |||||
voted = | |||||
if opts[:for] do | |||||
existing_votes = | |||||
Pleroma.Web.ActivityPub.Utils.get_existing_votes(opts[:for].ap_id, object) | |||||
existing_votes != [] or opts[:for].ap_id == object.data["actor"] | |||||
else | |||||
false | |||||
end | |||||
{options, votes_count} = | |||||
Enum.map_reduce(options, 0, fn %{"name" => name} = option, count -> | |||||
current_count = option["replies"]["totalItems"] || 0 | |||||
{%{ | |||||
title: HTML.strip_tags(name), | |||||
votes_count: current_count | |||||
}, current_count + count} | |||||
end) | |||||
%{ | |||||
# Mastodon uses separate ids for polls, but an object can't have | |||||
# more than one poll embedded so object id is fine | |||||
id: object.id, | |||||
expires_at: Utils.to_masto_date(end_time), | |||||
expired: expired, | |||||
multiple: multiple, | |||||
votes_count: votes_count, | |||||
options: options, | |||||
voted: voted, | |||||
emojis: build_emojis(object.data["emoji"]) | |||||
} | |||||
else | |||||
nil | |||||
end | |||||
end | |||||
def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do | def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do | ||||
object = Object.normalize(activity) | object = Object.normalize(activity) | ||||
@@ -333,6 +333,8 @@ defmodule Pleroma.Web.Router do | |||||
put("/scheduled_statuses/:id", MastodonAPIController, :update_scheduled_status) | put("/scheduled_statuses/:id", MastodonAPIController, :update_scheduled_status) | ||||
delete("/scheduled_statuses/:id", MastodonAPIController, :delete_scheduled_status) | delete("/scheduled_statuses/:id", MastodonAPIController, :delete_scheduled_status) | ||||
post("/polls/:id/votes", MastodonAPIController, :poll_vote) | |||||
post("/media", MastodonAPIController, :upload) | post("/media", MastodonAPIController, :upload) | ||||
put("/media/:id", MastodonAPIController, :update_media) | put("/media/:id", MastodonAPIController, :update_media) | ||||
@@ -422,6 +424,8 @@ defmodule Pleroma.Web.Router do | |||||
get("/statuses/:id", MastodonAPIController, :get_status) | get("/statuses/:id", MastodonAPIController, :get_status) | ||||
get("/statuses/:id/context", MastodonAPIController, :get_context) | get("/statuses/:id/context", MastodonAPIController, :get_context) | ||||
get("/polls/:id", MastodonAPIController, :get_poll) | |||||
get("/accounts/:id/statuses", MastodonAPIController, :user_statuses) | get("/accounts/:id/statuses", MastodonAPIController, :user_statuses) | ||||
get("/accounts/:id/followers", MastodonAPIController, :followers) | get("/accounts/:id/followers", MastodonAPIController, :followers) | ||||
get("/accounts/:id/following", MastodonAPIController, :following) | get("/accounts/:id/following", MastodonAPIController, :following) | ||||
@@ -0,0 +1,64 @@ | |||||
{ | |||||
"@context": [ | |||||
"https://www.w3.org/ns/activitystreams", | |||||
"https://w3id.org/security/v1", | |||||
{ | |||||
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers", | |||||
"toot": "http://joinmastodon.org/ns#", | |||||
"featured": { | |||||
"@id": "toot:featured", | |||||
"@type": "@id" | |||||
}, | |||||
"alsoKnownAs": { | |||||
"@id": "as:alsoKnownAs", | |||||
"@type": "@id" | |||||
}, | |||||
"movedTo": { | |||||
"@id": "as:movedTo", | |||||
"@type": "@id" | |||||
}, | |||||
"schema": "http://schema.org#", | |||||
"PropertyValue": "schema:PropertyValue", | |||||
"value": "schema:value", | |||||
"Hashtag": "as:Hashtag", | |||||
"Emoji": "toot:Emoji", | |||||
"IdentityProof": "toot:IdentityProof", | |||||
"focalPoint": { | |||||
"@container": "@list", | |||||
"@id": "toot:focalPoint" | |||||
} | |||||
} | |||||
], | |||||
"id": "https://mastodon.sdf.org/users/rinpatch", | |||||
"type": "Person", | |||||
"following": "https://mastodon.sdf.org/users/rinpatch/following", | |||||
"followers": "https://mastodon.sdf.org/users/rinpatch/followers", | |||||
"inbox": "https://mastodon.sdf.org/users/rinpatch/inbox", | |||||
"outbox": "https://mastodon.sdf.org/users/rinpatch/outbox", | |||||
"featured": "https://mastodon.sdf.org/users/rinpatch/collections/featured", | |||||
"preferredUsername": "rinpatch", | |||||
"name": "rinpatch", | |||||
"summary": "<p>umu</p>", | |||||
"url": "https://mastodon.sdf.org/@rinpatch", | |||||
"manuallyApprovesFollowers": false, | |||||
"publicKey": { | |||||
"id": "https://mastodon.sdf.org/users/rinpatch#main-key", | |||||
"owner": "https://mastodon.sdf.org/users/rinpatch", | |||||
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1vbhYKDopb5xzfJB2TZY\n0ZvgxqdAhbSKKkQC5Q2b0ofhvueDy2AuZTnVk1/BbHNlqKlwhJUSpA6LiTZVvtcc\nMn6cmSaJJEg30gRF5GARP8FMcuq8e2jmceiW99NnUX17MQXsddSf2JFUwD0rUE8H\nBsgD7UzE9+zlA/PJOTBO7fvBEz9PTQ3r4sRMTJVFvKz2MU/U+aRNTuexRKMMPnUw\nfp6VWh1F44VWJEQOs4tOEjGiQiMQh5OfBk1w2haT3vrDbQvq23tNpUP1cRomLUtx\nEBcGKi5DMMBzE1RTVT1YUykR/zLWlA+JSmw7P6cWtsHYZovs8dgn8Po3X//6N+ng\nTQIDAQAB\n-----END PUBLIC KEY-----\n" | |||||
}, | |||||
"tag": [], | |||||
"attachment": [], | |||||
"endpoints": { | |||||
"sharedInbox": "https://mastodon.sdf.org/inbox" | |||||
}, | |||||
"icon": { | |||||
"type": "Image", | |||||
"mediaType": "image/jpeg", | |||||
"url": "https://mastodon.sdf.org/system/accounts/avatars/000/067/580/original/bf05521bf711b7a0.jpg?1533238802" | |||||
}, | |||||
"image": { | |||||
"type": "Image", | |||||
"mediaType": "image/gif", | |||||
"url": "https://mastodon.sdf.org/system/accounts/headers/000/067/580/original/a99b987e798f7063.gif?1533278217" | |||||
} | |||||
} |
@@ -0,0 +1,99 @@ | |||||
{ | |||||
"@context": [ | |||||
"https://www.w3.org/ns/activitystreams", | |||||
{ | |||||
"ostatus": "http://ostatus.org#", | |||||
"atomUri": "ostatus:atomUri", | |||||
"inReplyToAtomUri": "ostatus:inReplyToAtomUri", | |||||
"conversation": "ostatus:conversation", | |||||
"sensitive": "as:sensitive", | |||||
"Hashtag": "as:Hashtag", | |||||
"toot": "http://joinmastodon.org/ns#", | |||||
"Emoji": "toot:Emoji", | |||||
"focalPoint": { | |||||
"@container": "@list", | |||||
"@id": "toot:focalPoint" | |||||
} | |||||
} | |||||
], | |||||
"id": "https://mastodon.sdf.org/users/rinpatch/statuses/102070944809637304/activity", | |||||
"type": "Create", | |||||
"actor": "https://mastodon.sdf.org/users/rinpatch", | |||||
"published": "2019-05-10T09:03:36Z", | |||||
"to": [ | |||||
"https://www.w3.org/ns/activitystreams#Public" | |||||
], | |||||
"cc": [ | |||||
"https://mastodon.sdf.org/users/rinpatch/followers" | |||||
], | |||||
"object": { | |||||
"id": "https://mastodon.sdf.org/users/rinpatch/statuses/102070944809637304", | |||||
"type": "Question", | |||||
"summary": null, | |||||
"inReplyTo": null, | |||||
"published": "2019-05-10T09:03:36Z", | |||||
"url": "https://mastodon.sdf.org/@rinpatch/102070944809637304", | |||||
"attributedTo": "https://mastodon.sdf.org/users/rinpatch", | |||||
"to": [ | |||||
"https://www.w3.org/ns/activitystreams#Public" | |||||
], | |||||
"cc": [ | |||||
"https://mastodon.sdf.org/users/rinpatch/followers" | |||||
], | |||||
"sensitive": false, | |||||
"atomUri": "https://mastodon.sdf.org/users/rinpatch/statuses/102070944809637304", | |||||
"inReplyToAtomUri": null, | |||||
"conversation": "tag:mastodon.sdf.org,2019-05-10:objectId=15095122:objectType=Conversation", | |||||
"content": "<p>Why is Tenshi eating a corndog so cute?</p>", | |||||
"contentMap": { | |||||
"en": "<p>Why is Tenshi eating a corndog so cute?</p>" | |||||
}, | |||||
"endTime": "2019-05-11T09:03:36Z", | |||||
"closed": "2019-05-11T09:03:36Z", | |||||
"attachment": [], | |||||
"tag": [], | |||||
"replies": { | |||||
"id": "https://mastodon.sdf.org/users/rinpatch/statuses/102070944809637304/replies", | |||||
"type": "Collection", | |||||
"first": { | |||||
"type": "CollectionPage", | |||||
"partOf": "https://mastodon.sdf.org/users/rinpatch/statuses/102070944809637304/replies", | |||||
"items": [] | |||||
} | |||||
}, | |||||
"oneOf": [ | |||||
{ | |||||
"type": "Note", | |||||
"name": "Dunno", | |||||
"replies": { | |||||
"type": "Collection", | |||||
"totalItems": 0 | |||||
} | |||||
}, | |||||
{ | |||||
"type": "Note", | |||||
"name": "Everyone knows that!", | |||||
"replies": { | |||||
"type": "Collection", | |||||
"totalItems": 1 | |||||
} | |||||
}, | |||||
{ | |||||
"type": "Note", | |||||
"name": "25 char limit is dumb", | |||||
"replies": { | |||||
"type": "Collection", | |||||
"totalItems": 0 | |||||
} | |||||
}, | |||||
{ | |||||
"type": "Note", | |||||
"name": "I can't even fit a funny", | |||||
"replies": { | |||||
"type": "Collection", | |||||
"totalItems": 1 | |||||
} | |||||
} | |||||
] | |||||
} | |||||
} |
@@ -0,0 +1,16 @@ | |||||
{ | |||||
"@context": "https://www.w3.org/ns/activitystreams", | |||||
"actor": "https://mastodon.sdf.org/users/rinpatch", | |||||
"id": "https://mastodon.sdf.org/users/rinpatch#votes/387/activity", | |||||
"nickname": "rin", | |||||
"object": { | |||||
"attributedTo": "https://mastodon.sdf.org/users/rinpatch", | |||||
"id": "https://mastodon.sdf.org/users/rinpatch#votes/387", | |||||
"inReplyTo": "https://testing.uguu.ltd/objects/9d300947-2dcb-445d-8978-9a3b4b84fa14", | |||||
"name": "suya..", | |||||
"to": "https://testing.uguu.ltd/users/rin", | |||||
"type": "Note" | |||||
}, | |||||
"to": "https://testing.uguu.ltd/users/rin", | |||||
"type": "Create" | |||||
} |
@@ -52,6 +52,14 @@ defmodule HttpRequestMock do | |||||
}} | }} | ||||
end | end | ||||
def get("https://mastodon.sdf.org/users/rinpatch", _, _, _) do | |||||
{:ok, | |||||
%Tesla.Env{ | |||||
status: 200, | |||||
body: File.read!("test/fixtures/httpoison_mock/rinpatch.json") | |||||
}} | |||||
end | |||||
def get( | def get( | ||||
"https://mastodon.social/.well-known/webfinger?resource=https://mastodon.social/users/emelie", | "https://mastodon.social/.well-known/webfinger?resource=https://mastodon.social/users/emelie", | ||||
_, | _, | ||||
@@ -113,6 +113,55 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do | |||||
assert Enum.at(object.data["tag"], 2) == "moo" | assert Enum.at(object.data["tag"], 2) == "moo" | ||||
end | end | ||||
test "it works for incoming questions" do | |||||
data = File.read!("test/fixtures/mastodon-question-activity.json") |> Poison.decode!() | |||||
{:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data) | |||||
object = Object.normalize(activity) | |||||
assert Enum.all?(object.data["oneOf"], fn choice -> | |||||
choice["name"] in [ | |||||
"Dunno", | |||||
"Everyone knows that!", | |||||
"25 char limit is dumb", | |||||
"I can't even fit a funny" | |||||
] | |||||
end) | |||||
end | |||||
test "it rewrites Note votes to Answers and increments vote counters on question activities" do | |||||
user = insert(:user) | |||||
{:ok, activity} = | |||||
CommonAPI.post(user, %{ | |||||
"status" => "suya...", | |||||
"poll" => %{"options" => ["suya", "suya.", "suya.."], "expires_in" => 10} | |||||
}) | |||||
object = Object.normalize(activity) | |||||
data = | |||||
File.read!("test/fixtures/mastodon-vote.json") | |||||
|> Poison.decode!() | |||||
|> Kernel.put_in(["to"], user.ap_id) | |||||
|> Kernel.put_in(["object", "inReplyTo"], object.data["id"]) | |||||
|> Kernel.put_in(["object", "to"], user.ap_id) | |||||
{:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data) | |||||
answer_object = Object.normalize(activity) | |||||
assert answer_object.data["type"] == "Answer" | |||||
object = Object.get_by_ap_id(object.data["id"]) | |||||
assert Enum.any?( | |||||
object.data["oneOf"], | |||||
fn | |||||
%{"name" => "suya..", "replies" => %{"totalItems" => 1}} -> true | |||||
_ -> false | |||||
end | |||||
) | |||||
end | |||||
test "it works for incoming notices with contentMap" do | test "it works for incoming notices with contentMap" do | ||||
data = | data = | ||||
File.read!("test/fixtures/mastodon-post-activity-contentmap.json") |> Poison.decode!() | File.read!("test/fixtures/mastodon-post-activity-contentmap.json") |> Poison.decode!() | ||||
@@ -1210,6 +1259,30 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do | |||||
end | end | ||||
end | end | ||||
test "Rewrites Answers to Notes" do | |||||
user = insert(:user) | |||||
{:ok, poll_activity} = | |||||
CommonAPI.post(user, %{ | |||||
"status" => "suya...", | |||||
"poll" => %{"options" => ["suya", "suya.", "suya.."], "expires_in" => 10} | |||||
}) | |||||
poll_object = Object.normalize(poll_activity) | |||||
# TODO: Replace with CommonAPI vote creation when implemented | |||||
data = | |||||
File.read!("test/fixtures/mastodon-vote.json") | |||||
|> Poison.decode!() | |||||
|> Kernel.put_in(["to"], user.ap_id) | |||||
|> Kernel.put_in(["object", "inReplyTo"], poll_object.data["id"]) | |||||
|> Kernel.put_in(["object", "to"], user.ap_id) | |||||
{:ok, %Activity{local: false} = activity} = Transmogrifier.handle_incoming(data) | |||||
{:ok, data} = Transmogrifier.prepare_outgoing(activity.data) | |||||
assert data["object"]["type"] == "Note" | |||||
end | |||||
describe "fix_explicit_addressing" do | describe "fix_explicit_addressing" do | ||||
setup do | setup do | ||||
user = insert(:user) | user = insert(:user) | ||||
@@ -146,6 +146,103 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do | |||||
refute id == third_id | refute id == third_id | ||||
end | end | ||||
describe "posting polls" do | |||||
test "posting a poll", %{conn: conn} do | |||||
user = insert(:user) | |||||
time = NaiveDateTime.utc_now() | |||||
conn = | |||||
conn | |||||
|> assign(:user, user) | |||||
|> post("/api/v1/statuses", %{ | |||||
"status" => "Who is the #bestgrill?", | |||||
"poll" => %{"options" => ["Rei", "Asuka", "Misato"], "expires_in" => 420} | |||||
}) | |||||
response = json_response(conn, 200) | |||||
assert Enum.all?(response["poll"]["options"], fn %{"title" => title} -> | |||||
title in ["Rei", "Asuka", "Misato"] | |||||
end) | |||||
assert NaiveDateTime.diff(NaiveDateTime.from_iso8601!(response["poll"]["expires_at"]), time) in 420..430 | |||||
refute response["poll"]["expred"] | |||||
end | |||||
test "option limit is enforced", %{conn: conn} do | |||||
user = insert(:user) | |||||
limit = Pleroma.Config.get([:instance, :poll_limits, :max_options]) | |||||
conn = | |||||
conn | |||||
|> assign(:user, user) | |||||
|> post("/api/v1/statuses", %{ | |||||
"status" => "desu~", | |||||
"poll" => %{"options" => Enum.map(0..limit, fn _ -> "desu" end), "expires_in" => 1} | |||||
}) | |||||
%{"error" => error} = json_response(conn, 422) | |||||
assert error == "Poll can't contain more than #{limit} options" | |||||
end | |||||
test "option character limit is enforced", %{conn: conn} do | |||||
user = insert(:user) | |||||
limit = Pleroma.Config.get([:instance, :poll_limits, :max_option_chars]) | |||||
conn = | |||||
conn | |||||
|> assign(:user, user) | |||||
|> post("/api/v1/statuses", %{ | |||||
"status" => "...", | |||||
"poll" => %{ | |||||
"options" => [Enum.reduce(0..limit, "", fn _, acc -> acc <> "." end)], | |||||
"expires_in" => 1 | |||||
} | |||||
}) | |||||
%{"error" => error} = json_response(conn, 422) | |||||
assert error == "Poll options cannot be longer than #{limit} characters each" | |||||
end | |||||
test "minimal date limit is enforced", %{conn: conn} do | |||||
user = insert(:user) | |||||
limit = Pleroma.Config.get([:instance, :poll_limits, :min_expiration]) | |||||
conn = | |||||
conn | |||||
|> assign(:user, user) | |||||
|> post("/api/v1/statuses", %{ | |||||
"status" => "imagine arbitrary limits", | |||||
"poll" => %{ | |||||
"options" => ["this post was made by pleroma gang"], | |||||
"expires_in" => limit - 1 | |||||
} | |||||
}) | |||||
%{"error" => error} = json_response(conn, 422) | |||||
assert error == "Expiration date is too soon" | |||||
end | |||||
test "maximum date limit is enforced", %{conn: conn} do | |||||
user = insert(:user) | |||||
limit = Pleroma.Config.get([:instance, :poll_limits, :max_expiration]) | |||||
conn = | |||||
conn | |||||
|> assign(:user, user) | |||||
|> post("/api/v1/statuses", %{ | |||||
"status" => "imagine arbitrary limits", | |||||
"poll" => %{ | |||||
"options" => ["this post was made by pleroma gang"], | |||||
"expires_in" => limit + 1 | |||||
} | |||||
}) | |||||
%{"error" => error} = json_response(conn, 422) | |||||
assert error == "Expiration date is too far in the future" | |||||
end | |||||
end | |||||
test "posting a sensitive status", %{conn: conn} do | test "posting a sensitive status", %{conn: conn} do | ||||
user = insert(:user) | user = insert(:user) | ||||
@@ -2542,7 +2639,8 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do | |||||
"stats" => _, | "stats" => _, | ||||
"thumbnail" => _, | "thumbnail" => _, | ||||
"languages" => _, | "languages" => _, | ||||
"registrations" => _ | |||||
"registrations" => _, | |||||
"poll_limits" => _ | |||||
} = result | } = result | ||||
assert email == from_config_email | assert email == from_config_email | ||||
@@ -3441,4 +3539,124 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do | |||||
assert json_response(conn, 403) == %{"error" => "Rate limit exceeded."} | assert json_response(conn, 403) == %{"error" => "Rate limit exceeded."} | ||||
end | end | ||||
end | end | ||||
describe "GET /api/v1/polls/:id" do | |||||
test "returns poll entity for object id", %{conn: conn} do | |||||
user = insert(:user) | |||||
{:ok, activity} = | |||||
CommonAPI.post(user, %{ | |||||
"status" => "Pleroma does", | |||||
"poll" => %{"options" => ["what Mastodon't", "n't what Mastodoes"], "expires_in" => 20} | |||||
}) | |||||
object = Object.normalize(activity) | |||||
conn = | |||||
conn | |||||
|> assign(:user, user) | |||||
|> get("/api/v1/polls/#{object.id}") | |||||
response = json_response(conn, 200) | |||||
id = object.id | |||||
assert %{"id" => ^id, "expired" => false, "multiple" => false} = response | |||||
end | |||||
test "does not expose polls for private statuses", %{conn: conn} do | |||||
user = insert(:user) | |||||
other_user = insert(:user) | |||||
{:ok, activity} = | |||||
CommonAPI.post(user, %{ | |||||
"status" => "Pleroma does", | |||||
"poll" => %{"options" => ["what Mastodon't", "n't what Mastodoes"], "expires_in" => 20}, | |||||
"visibility" => "private" | |||||
}) | |||||
object = Object.normalize(activity) | |||||
conn = | |||||
conn | |||||
|> assign(:user, other_user) | |||||
|> get("/api/v1/polls/#{object.id}") | |||||
assert json_response(conn, 404) | |||||
end | |||||
end | |||||
describe "POST /api/v1/polls/:id/votes" do | |||||
test "votes are added to the poll", %{conn: conn} do | |||||
user = insert(:user) | |||||
other_user = insert(:user) | |||||
{:ok, activity} = | |||||
CommonAPI.post(user, %{ | |||||
"status" => "A very delicious sandwich", | |||||
"poll" => %{ | |||||
"options" => ["Lettuce", "Grilled Bacon", "Tomato"], | |||||
"expires_in" => 20, | |||||
"multiple" => true | |||||
} | |||||
}) | |||||
object = Object.normalize(activity) | |||||
conn = | |||||
conn | |||||
|> assign(:user, other_user) | |||||
|> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1, 2]}) | |||||
assert json_response(conn, 200) | |||||
object = Object.get_by_id(object.id) | |||||
assert Enum.all?(object.data["anyOf"], fn %{"replies" => %{"totalItems" => total_items}} -> | |||||
total_items == 1 | |||||
end) | |||||
end | |||||
test "author can't vote", %{conn: conn} do | |||||
user = insert(:user) | |||||
{:ok, activity} = | |||||
CommonAPI.post(user, %{ | |||||
"status" => "Am I cute?", | |||||
"poll" => %{"options" => ["Yes", "No"], "expires_in" => 20} | |||||
}) | |||||
object = Object.normalize(activity) | |||||
assert conn | |||||
|> assign(:user, user) | |||||
|> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [1]}) | |||||
|> json_response(422) == %{"error" => "Poll's author can't vote"} | |||||
object = Object.get_by_id(object.id) | |||||
refute Enum.at(object.data["oneOf"], 1)["replies"]["totalItems"] == 1 | |||||
end | |||||
test "does not allow multiple choices on a single-choice question", %{conn: conn} do | |||||
user = insert(:user) | |||||
other_user = insert(:user) | |||||
{:ok, activity} = | |||||
CommonAPI.post(user, %{ | |||||
"status" => "The glass is", | |||||
"poll" => %{"options" => ["half empty", "half full"], "expires_in" => 20} | |||||
}) | |||||
object = Object.normalize(activity) | |||||
assert conn | |||||
|> assign(:user, other_user) | |||||
|> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1]}) | |||||
|> json_response(422) == %{"error" => "Too many choices"} | |||||
object = Object.get_by_id(object.id) | |||||
refute Enum.any?(object.data["oneOf"], fn %{"replies" => %{"totalItems" => total_items}} -> | |||||
total_items == 1 | |||||
end) | |||||
end | |||||
end | |||||
end | end |
@@ -103,6 +103,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do | |||||
muted: false, | muted: false, | ||||
pinned: false, | pinned: false, | ||||
sensitive: false, | sensitive: false, | ||||
poll: nil, | |||||
spoiler_text: HtmlSanitizeEx.basic_html(note.data["object"]["summary"]), | spoiler_text: HtmlSanitizeEx.basic_html(note.data["object"]["summary"]), | ||||
visibility: "public", | visibility: "public", | ||||
media_attachments: [], | media_attachments: [], | ||||
@@ -341,4 +342,106 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do | |||||
StatusView.render("card.json", %{page_url: page_url, rich_media: card}) | StatusView.render("card.json", %{page_url: page_url, rich_media: card}) | ||||
end | end | ||||
end | end | ||||
describe "poll view" do | |||||
test "renders a poll" do | |||||
user = insert(:user) | |||||
{:ok, activity} = | |||||
CommonAPI.post(user, %{ | |||||
"status" => "Is Tenshi eating a corndog cute?", | |||||
"poll" => %{ | |||||
"options" => ["absolutely!", "sure", "yes", "why are you even asking?"], | |||||
"expires_in" => 20 | |||||
} | |||||
}) | |||||
object = Object.normalize(activity) | |||||
expected = %{ | |||||
emojis: [], | |||||
expired: false, | |||||
id: object.id, | |||||
multiple: false, | |||||
options: [ | |||||
%{title: "absolutely!", votes_count: 0}, | |||||
%{title: "sure", votes_count: 0}, | |||||
%{title: "yes", votes_count: 0}, | |||||
%{title: "why are you even asking?", votes_count: 0} | |||||
], | |||||
voted: false, | |||||
votes_count: 0 | |||||
} | |||||
result = StatusView.render("poll.json", %{object: object}) | |||||
expires_at = result.expires_at | |||||
result = Map.delete(result, :expires_at) | |||||
assert result == expected | |||||
expires_at = NaiveDateTime.from_iso8601!(expires_at) | |||||
assert NaiveDateTime.diff(expires_at, NaiveDateTime.utc_now()) in 15..20 | |||||
end | |||||
test "detects if it is multiple choice" do | |||||
user = insert(:user) | |||||
{:ok, activity} = | |||||
CommonAPI.post(user, %{ | |||||
"status" => "Which Mastodon developer is your favourite?", | |||||
"poll" => %{ | |||||
"options" => ["Gargron", "Eugen"], | |||||
"expires_in" => 20, | |||||
"multiple" => true | |||||
} | |||||
}) | |||||
object = Object.normalize(activity) | |||||
assert %{multiple: true} = StatusView.render("poll.json", %{object: object}) | |||||
end | |||||
test "detects emoji" do | |||||
user = insert(:user) | |||||
{:ok, activity} = | |||||
CommonAPI.post(user, %{ | |||||
"status" => "What's with the smug face?", | |||||
"poll" => %{ | |||||
"options" => [":blank: sip", ":blank::blank: sip", ":blank::blank::blank: sip"], | |||||
"expires_in" => 20 | |||||
} | |||||
}) | |||||
object = Object.normalize(activity) | |||||
assert %{emojis: [%{shortcode: "blank"}]} = | |||||
StatusView.render("poll.json", %{object: object}) | |||||
end | |||||
test "detects vote status" do | |||||
user = insert(:user) | |||||
other_user = insert(:user) | |||||
{:ok, activity} = | |||||
CommonAPI.post(user, %{ | |||||
"status" => "Which input devices do you use?", | |||||
"poll" => %{ | |||||
"options" => ["mouse", "trackball", "trackpoint"], | |||||
"multiple" => true, | |||||
"expires_in" => 20 | |||||
} | |||||
}) | |||||
object = Object.normalize(activity) | |||||
{:ok, _, object} = CommonAPI.vote(other_user, object, [1, 2]) | |||||
result = StatusView.render("poll.json", %{object: object, for: other_user}) | |||||
assert result[:voted] == true | |||||
assert Enum.at(result[:options], 1)[:votes_count] == 1 | |||||
assert Enum.at(result[:options], 2)[:votes_count] == 1 | |||||
end | |||||
end | |||||
end | end |