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 update_users_following_followers_counts` | |||
- Mix Tasks: `mix pleroma.user toggle_confirmed` | |||
- Federation: Support for `Question` and `Answer` objects | |||
- Federation: Support for reports | |||
- Configuration: `poll_limits` option | |||
- Configuration: `safe_dm_mentions` option | |||
- Configuration: `link_name` 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: [Reports](https://docs.joinmastodon.org/api/rest/reports/) | |||
- Mastodon API: `POST /api/v1/accounts` (account creation API) | |||
- Mastodon API: [Polls](https://docs.joinmastodon.org/api/rest/polls/) | |||
- ActivityPub C2S: OAuth endpoints | |||
- Metadata: RelMe provider | |||
- OAuth: added support for refresh tokens | |||
@@ -208,6 +208,12 @@ config :pleroma, :instance, | |||
avatar_upload_limit: 2_000_000, | |||
background_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, | |||
federating: true, | |||
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 | |||
* `background_upload_limit`: File size limit of user’s profile backgrounds | |||
* `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. | |||
* `invites_enabled`: Enable user invitations for admins (depends on `registrations_open: false`). | |||
* `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), | |||
"Create" <- activity.data["type"], | |||
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 | |||
{: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) | |||
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 | |||
def create_notifications(_), do: {:ok, []} | |||
@@ -35,6 +35,9 @@ defmodule Pleroma.Object do | |||
|> unique_constraint(:ap_id, name: :objects_unique_apid_index) | |||
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(ap_id) do | |||
@@ -195,4 +198,34 @@ defmodule Pleroma.Object do | |||
_ -> {:error, "Not found"} | |||
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 |
@@ -108,6 +108,15 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do | |||
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 | |||
with nil <- Activity.normalize(map), | |||
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" | |||
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 | |||
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 | |||
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 | |||
@@ -235,6 +246,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do | |||
{:ok, activity} <- insert(create_data, local, fake), | |||
{:fake, false, activity} <- {:fake, fake, activity}, | |||
_ <- 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 | |||
# race conditions on updating user.info | |||
{: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 | |||
from(activity in Activity) | |||
|> maybe_preload_objects(opts) | |||
|> restrict_blocked(opts) | |||
|> restrict_recipients(recipients, opts["user"]) | |||
|> where( | |||
@@ -488,6 +501,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do | |||
^context | |||
) | |||
) | |||
|> exclude_poll_votes(opts) | |||
|> order_by([activity], desc: activity.id) | |||
end | |||
@@ -495,7 +509,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do | |||
def fetch_activities_for_context(context, opts \\ %{}) do | |||
context | |||
|> fetch_activities_for_context_query(opts) | |||
|> Activity.with_preloaded_object() | |||
|> Repo.all() | |||
end | |||
@@ -503,7 +516,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do | |||
Pleroma.FlakeId.t() | nil | |||
def fetch_latest_activity_id_for_context(context, opts \\ %{}) do | |||
context | |||
|> fetch_activities_for_context_query(opts) | |||
|> fetch_activities_for_context_query(Map.merge(%{"skip_preload" => true}, opts)) | |||
|> limit(1) | |||
|> select([a], a.id) | |||
|> Repo.one() | |||
@@ -802,6 +815,18 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do | |||
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, _) do | |||
@@ -863,6 +888,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do | |||
|> restrict_pinned(opts) | |||
|> restrict_muted_reblogs(opts) | |||
|> Activity.restrict_deactivated_users() | |||
|> exclude_poll_votes(opts) | |||
end | |||
def fetch_activities(recipients, opts \\ %{}) do | |||
@@ -35,6 +35,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do | |||
|> fix_likes | |||
|> fix_addressing | |||
|> fix_summary | |||
|> fix_type | |||
end | |||
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_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 | |||
with true <- id =~ "follows", | |||
%User{local: true} = follower <- User.get_cached_by_ap_id(follower_id), | |||
@@ -405,7 +418,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do | |||
# - tags | |||
# - emoji | |||
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) | |||
data = | |||
@@ -738,6 +751,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do | |||
|> set_reply_to_uri | |||
|> strip_internal_fields | |||
|> strip_internal_tags | |||
|> set_type | |||
end | |||
# @doc | |||
@@ -902,6 +916,12 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do | |||
Map.put(object, "sensitive", "nsfw" in tags) | |||
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 | |||
attributed_to = object["attributedTo"] || object["actor"] | |||
@@ -19,7 +19,7 @@ defmodule Pleroma.Web.ActivityPub.Utils do | |||
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) | |||
@valid_visibilities ~w(public unlisted private direct) | |||
@@ -789,4 +789,21 @@ defmodule Pleroma.Web.ActivityPub.Utils do | |||
[to, cc, recipients] | |||
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 |
@@ -119,6 +119,53 @@ defmodule Pleroma.Web.CommonAPI do | |||
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) | |||
when visibility in ~w{public unlisted private direct}, | |||
do: {visibility, get_replied_to_visibility(in_reply_to)} | |||
@@ -154,6 +201,7 @@ defmodule Pleroma.Web.CommonAPI do | |||
data, | |||
visibility | |||
), | |||
{poll, poll_emoji} <- make_poll_data(data), | |||
{to, cc} <- to_for_user_and_mentions(user, mentions, in_reply_to, visibility), | |||
context <- make_context(in_reply_to), | |||
cw <- data["spoiler_text"] || "", | |||
@@ -171,13 +219,14 @@ defmodule Pleroma.Web.CommonAPI do | |||
tags, | |||
cw, | |||
cc, | |||
sensitive | |||
sensitive, | |||
poll | |||
), | |||
object <- | |||
Map.put( | |||
object, | |||
"emoji", | |||
Formatter.get_emoji_map(full_payload) | |||
Map.merge(Formatter.get_emoji_map(full_payload), poll_emoji) | |||
) do | |||
res = | |||
ActivityPub.create( | |||
@@ -102,6 +102,72 @@ defmodule Pleroma.Web.CommonAPI.Utils do | |||
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( | |||
status, | |||
attachments, | |||
@@ -224,7 +290,8 @@ defmodule Pleroma.Web.CommonAPI.Utils do | |||
tags, | |||
cw \\ nil, | |||
cc \\ [], | |||
sensitive \\ false | |||
sensitive \\ false, | |||
merge \\ %{} | |||
) do | |||
object = %{ | |||
"type" => "Note", | |||
@@ -239,12 +306,15 @@ defmodule Pleroma.Web.CommonAPI.Utils do | |||
"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 | |||
def format_naive_asctime(date) do | |||
@@ -421,4 +491,15 @@ defmodule Pleroma.Web.CommonAPI.Utils do | |||
{:error, "No such conversation"} | |||
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 |
@@ -197,7 +197,8 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do | |||
languages: ["en"], | |||
registrations: Pleroma.Config.get([:instance, :registrations_open]), | |||
# 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) | |||
@@ -409,6 +410,53 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do | |||
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 | |||
with scheduled_activities <- MastodonAPI.get_scheduled_activities(user, params) do | |||
conn | |||
@@ -472,12 +520,6 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do | |||
params | |||
|> 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"] | |||
if scheduled_at && ScheduledActivity.far_enough?(scheduled_at) do | |||
@@ -490,17 +532,40 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do | |||
else | |||
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 | |||
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 | |||
with {:ok, %Activity{}} <- CommonAPI.delete(id, user) do | |||
json(conn, %{}) | |||
@@ -1364,6 +1429,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do | |||
max_toot_chars: limit, | |||
mascot: User.get_mascot(user)["url"] | |||
}, | |||
poll_limits: Config.get([:instance, :poll_limits]), | |||
rights: %{ | |||
delete_others_notice: present?(user.info.is_moderator), | |||
admin: present?(user.info.is_admin) | |||
@@ -240,6 +240,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do | |||
spoiler_text: summary_html, | |||
visibility: get_visibility(object), | |||
media_attachments: attachments, | |||
poll: render("poll.json", %{object: object, for: opts[:for]}), | |||
mentions: mentions, | |||
tags: build_tags(tags), | |||
application: %{ | |||
@@ -329,6 +330,64 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do | |||
} | |||
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 | |||
object = Object.normalize(activity) | |||
@@ -333,6 +333,8 @@ defmodule Pleroma.Web.Router do | |||
put("/scheduled_statuses/:id", MastodonAPIController, :update_scheduled_status) | |||
delete("/scheduled_statuses/:id", MastodonAPIController, :delete_scheduled_status) | |||
post("/polls/:id/votes", MastodonAPIController, :poll_vote) | |||
post("/media", MastodonAPIController, :upload) | |||
put("/media/:id", MastodonAPIController, :update_media) | |||
@@ -422,6 +424,8 @@ defmodule Pleroma.Web.Router do | |||
get("/statuses/:id", MastodonAPIController, :get_status) | |||
get("/statuses/:id/context", MastodonAPIController, :get_context) | |||
get("/polls/:id", MastodonAPIController, :get_poll) | |||
get("/accounts/:id/statuses", MastodonAPIController, :user_statuses) | |||
get("/accounts/:id/followers", MastodonAPIController, :followers) | |||
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 | |||
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( | |||
"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" | |||
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 | |||
data = | |||
File.read!("test/fixtures/mastodon-post-activity-contentmap.json") |> Poison.decode!() | |||
@@ -1210,6 +1259,30 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do | |||
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 | |||
setup do | |||
user = insert(:user) | |||
@@ -146,6 +146,103 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do | |||
refute id == third_id | |||
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 | |||
user = insert(:user) | |||
@@ -2542,7 +2639,8 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do | |||
"stats" => _, | |||
"thumbnail" => _, | |||
"languages" => _, | |||
"registrations" => _ | |||
"registrations" => _, | |||
"poll_limits" => _ | |||
} = result | |||
assert email == from_config_email | |||
@@ -3441,4 +3539,124 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do | |||
assert json_response(conn, 403) == %{"error" => "Rate limit exceeded."} | |||
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 |
@@ -103,6 +103,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do | |||
muted: false, | |||
pinned: false, | |||
sensitive: false, | |||
poll: nil, | |||
spoiler_text: HtmlSanitizeEx.basic_html(note.data["object"]["summary"]), | |||
visibility: "public", | |||
media_attachments: [], | |||
@@ -341,4 +342,106 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do | |||
StatusView.render("card.json", %{page_url: page_url, rich_media: card}) | |||
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 |