Post editing #202
66 changed files with 3216 additions and 130 deletions
|
@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
- support for reusing oauth tokens, and not requiring new authorizations
|
- support for reusing oauth tokens, and not requiring new authorizations
|
||||||
- the ability to obfuscate domains in your MRF descriptions
|
- the ability to obfuscate domains in your MRF descriptions
|
||||||
- automatic translation of statuses via DeepL or LibreTranslate
|
- automatic translation of statuses via DeepL or LibreTranslate
|
||||||
|
- ability to edit posts
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- MFM parsing is now done on the backend by a modified version of ilja's parser -> https://akkoma.dev/AkkomaGang/mfm-parser
|
- MFM parsing is now done on the backend by a modified version of ilja's parser -> https://akkoma.dev/AkkomaGang/mfm-parser
|
||||||
|
|
|
@ -40,6 +40,10 @@ Has these additional fields under the `pleroma` object:
|
||||||
- `parent_visible`: If the parent of this post is visible to the user or not.
|
- `parent_visible`: If the parent of this post is visible to the user or not.
|
||||||
- `pinned_at`: a datetime (iso8601) when status was pinned, `null` otherwise.
|
- `pinned_at`: a datetime (iso8601) when status was pinned, `null` otherwise.
|
||||||
|
|
||||||
|
The `GET /api/v1/statuses/:id/source` endpoint additionally has the following attributes:
|
||||||
|
|
||||||
|
- `content_type`: The content type of the status source.
|
||||||
|
|
||||||
## Scheduled statuses
|
## Scheduled statuses
|
||||||
|
|
||||||
Has these additional fields in `params`:
|
Has these additional fields in `params`:
|
||||||
|
|
|
@ -8,6 +8,40 @@ defmodule Pleroma.Activity.HTML do
|
||||||
|
|
||||||
@cachex Pleroma.Config.get([:cachex, :provider], Cachex)
|
@cachex Pleroma.Config.get([:cachex, :provider], Cachex)
|
||||||
|
|
||||||
|
# We store a list of cache keys related to an activity in a
|
||||||
|
# separate cache, scrubber_management_cache. It has the same
|
||||||
|
# size as scrubber_cache (see application.ex). Every time we add
|
||||||
|
# a cache to scrubber_cache, we update scrubber_management_cache.
|
||||||
|
#
|
||||||
|
# The most recent write of a certain key in the management cache
|
||||||
|
# is the same as the most recent write of any record related to that
|
||||||
|
# key in the main cache.
|
||||||
|
# Assuming LRW ( https://hexdocs.pm/cachex/Cachex.Policy.LRW.html ),
|
||||||
|
# this means when the management cache is evicted by cachex, all
|
||||||
|
# related records in the main cache will also have been evicted.
|
||||||
|
|
||||||
|
defp get_cache_keys_for(activity_id) do
|
||||||
|
with {:ok, list} when is_list(list) <- @cachex.get(:scrubber_management_cache, activity_id) do
|
||||||
|
list
|
||||||
|
else
|
||||||
|
_ -> []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp add_cache_key_for(activity_id, additional_key) do
|
||||||
|
current = get_cache_keys_for(activity_id)
|
||||||
|
|
||||||
|
unless additional_key in current do
|
||||||
|
@cachex.put(:scrubber_management_cache, activity_id, [additional_key | current])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def invalidate_cache_for(activity_id) do
|
||||||
|
keys = get_cache_keys_for(activity_id)
|
||||||
|
Enum.map(keys, &@cachex.del(:scrubber_cache, &1))
|
||||||
|
@cachex.del(:scrubber_management_cache, activity_id)
|
||||||
|
end
|
||||||
|
|
||||||
def get_cached_scrubbed_html_for_activity(
|
def get_cached_scrubbed_html_for_activity(
|
||||||
content,
|
content,
|
||||||
scrubbers,
|
scrubbers,
|
||||||
|
@ -19,6 +53,8 @@ def get_cached_scrubbed_html_for_activity(
|
||||||
|
|
||||||
@cachex.fetch!(:scrubber_cache, key, fn _key ->
|
@cachex.fetch!(:scrubber_cache, key, fn _key ->
|
||||||
object = Object.normalize(activity, fetch: false)
|
object = Object.normalize(activity, fetch: false)
|
||||||
|
|
||||||
|
add_cache_key_for(activity.id, key)
|
||||||
HTML.ensure_scrubbed_html(content, scrubbers, object.data["fake"] || false, callback)
|
HTML.ensure_scrubbed_html(content, scrubbers, object.data["fake"] || false, callback)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
|
@ -150,6 +150,7 @@ defp cachex_children do
|
||||||
build_cachex("object", default_ttl: 25_000, ttl_interval: 1000, limit: 2500),
|
build_cachex("object", default_ttl: 25_000, ttl_interval: 1000, limit: 2500),
|
||||||
build_cachex("rich_media", default_ttl: :timer.minutes(120), limit: 5000),
|
build_cachex("rich_media", default_ttl: :timer.minutes(120), limit: 5000),
|
||||||
build_cachex("scrubber", limit: 2500),
|
build_cachex("scrubber", limit: 2500),
|
||||||
|
build_cachex("scrubber_management", limit: 2500),
|
||||||
build_cachex("idempotency", expiration: idempotency_expiration(), limit: 2500),
|
build_cachex("idempotency", expiration: idempotency_expiration(), limit: 2500),
|
||||||
build_cachex("web_resp", limit: 2500),
|
build_cachex("web_resp", limit: 2500),
|
||||||
build_cachex("emoji_packs", expiration: emoji_packs_expiration(), limit: 10),
|
build_cachex("emoji_packs", expiration: emoji_packs_expiration(), limit: 10),
|
||||||
|
|
|
@ -27,4 +27,40 @@ defmodule Pleroma.Constants do
|
||||||
do:
|
do:
|
||||||
~w(index.html robots.txt static static-fe finmoji emoji packs sounds images instance sw.js sw-pleroma.js favicon.png schemas doc embed.js embed.css)
|
~w(index.html robots.txt static static-fe finmoji emoji packs sounds images instance sw.js sw-pleroma.js favicon.png schemas doc embed.js embed.css)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const(status_updatable_fields,
|
||||||
|
do: [
|
||||||
|
"source",
|
||||||
|
"tag",
|
||||||
|
"updated",
|
||||||
|
"emoji",
|
||||||
|
"content",
|
||||||
|
"summary",
|
||||||
|
"sensitive",
|
||||||
|
"attachment",
|
||||||
|
"generator"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const(updatable_object_types,
|
||||||
|
do: [
|
||||||
|
"Note",
|
||||||
|
"Question",
|
||||||
|
"Audio",
|
||||||
|
"Video",
|
||||||
|
"Event",
|
||||||
|
"Article",
|
||||||
|
"Page"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const(actor_types,
|
||||||
|
do: [
|
||||||
|
"Application",
|
||||||
|
"Group",
|
||||||
|
"Organization",
|
||||||
|
"Person",
|
||||||
|
"Service"
|
||||||
|
]
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
|
@ -384,7 +384,7 @@ def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = act
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_notifications(%Activity{data: %{"type" => type}} = activity, options)
|
def create_notifications(%Activity{data: %{"type" => type}} = activity, options)
|
||||||
when type in ["Follow", "Like", "Announce", "Move", "EmojiReact", "Flag"] do
|
when type in ["Follow", "Like", "Announce", "Move", "EmojiReact", "Flag", "Update"] do
|
||||||
do_create_notifications(activity, options)
|
do_create_notifications(activity, options)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -438,6 +438,9 @@ defp type_from_activity(%{data: %{"type" => type}} = activity) do
|
||||||
activity
|
activity
|
||||||
|> type_from_activity_object()
|
|> type_from_activity_object()
|
||||||
|
|
||||||
|
"Update" ->
|
||||||
|
"update"
|
||||||
|
|
||||||
t ->
|
t ->
|
||||||
raise "No notification type for activity type #{t}"
|
raise "No notification type for activity type #{t}"
|
||||||
end
|
end
|
||||||
|
@ -503,7 +506,16 @@ def create_poll_notifications(%Activity{} = activity) do
|
||||||
def get_notified_from_activity(activity, local_only \\ true)
|
def get_notified_from_activity(activity, local_only \\ true)
|
||||||
|
|
||||||
def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, local_only)
|
def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, local_only)
|
||||||
when type in ["Create", "Like", "Announce", "Follow", "Move", "EmojiReact", "Flag"] do
|
when type in [
|
||||||
|
"Create",
|
||||||
|
"Like",
|
||||||
|
"Announce",
|
||||||
|
"Follow",
|
||||||
|
"Move",
|
||||||
|
"EmojiReact",
|
||||||
|
"Flag",
|
||||||
|
"Update"
|
||||||
|
] do
|
||||||
potential_receiver_ap_ids = get_potential_receiver_ap_ids(activity)
|
potential_receiver_ap_ids = get_potential_receiver_ap_ids(activity)
|
||||||
|
|
||||||
potential_receivers =
|
potential_receivers =
|
||||||
|
@ -543,6 +555,21 @@ def get_potential_receiver_ap_ids(%{data: %{"type" => "Flag", "actor" => actor}}
|
||||||
(User.all_superusers() |> Enum.map(fn user -> user.ap_id end)) -- [actor]
|
(User.all_superusers() |> Enum.map(fn user -> user.ap_id end)) -- [actor]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Update activity: notify all who repeated this
|
||||||
|
def get_potential_receiver_ap_ids(%{data: %{"type" => "Update", "actor" => actor}} = activity) do
|
||||||
|
with %Object{data: %{"id" => object_id}} <- Object.normalize(activity, fetch: false) do
|
||||||
|
repeaters =
|
||||||
|
Activity.Queries.by_type("Announce")
|
||||||
|
|> Activity.Queries.by_object_id(object_id)
|
||||||
|
|> Activity.with_joined_user_actor()
|
||||||
|
|> where([a, u], u.local)
|
||||||
|
|> select([a, u], u.ap_id)
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
repeaters -- [actor]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def get_potential_receiver_ap_ids(activity) do
|
def get_potential_receiver_ap_ids(activity) do
|
||||||
[]
|
[]
|
||||||
|> Utils.maybe_notify_to_recipients(activity)
|
|> Utils.maybe_notify_to_recipients(activity)
|
||||||
|
|
|
@ -26,8 +26,42 @@ defp touch_changeset(changeset) do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_reinject_internal_fields(%{data: %{} = old_data}, new_data) do
|
defp maybe_reinject_internal_fields(%{data: %{} = old_data}, new_data) do
|
||||||
|
has_history? = fn
|
||||||
|
%{"formerRepresentations" => %{"orderedItems" => list}} when is_list(list) -> true
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
|
||||||
internal_fields = Map.take(old_data, Pleroma.Constants.object_internal_fields())
|
internal_fields = Map.take(old_data, Pleroma.Constants.object_internal_fields())
|
||||||
|
|
||||||
|
remote_history_exists? = has_history?.(new_data)
|
||||||
|
|
||||||
|
# If the remote history exists, we treat that as the only source of truth.
|
||||||
|
new_data =
|
||||||
|
if has_history?.(old_data) and not remote_history_exists? do
|
||||||
|
Map.put(new_data, "formerRepresentations", old_data["formerRepresentations"])
|
||||||
|
else
|
||||||
|
new_data
|
||||||
|
end
|
||||||
|
|
||||||
|
# If the remote does not have history information, we need to manage it ourselves
|
||||||
|
new_data =
|
||||||
|
if not remote_history_exists? do
|
||||||
|
changed? =
|
||||||
|
Pleroma.Constants.status_updatable_fields()
|
||||||
|
|> Enum.any?(fn field -> Map.get(old_data, field) != Map.get(new_data, field) end)
|
||||||
|
|
||||||
|
%{updated_object: updated_object} =
|
||||||
|
new_data
|
||||||
|
|> Object.Updater.maybe_update_history(old_data,
|
||||||
|
updated: changed?,
|
||||||
|
use_history_in_new_object?: false
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_object
|
||||||
|
else
|
||||||
|
new_data
|
||||||
|
end
|
||||||
|
|
||||||
Map.merge(new_data, internal_fields)
|
Map.merge(new_data, internal_fields)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
240
lib/pleroma/object/updater.ex
Normal file
240
lib/pleroma/object/updater.ex
Normal file
|
@ -0,0 +1,240 @@
|
||||||
|
# Pleroma: A lightweight social networking server
|
||||||
|
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
defmodule Pleroma.Object.Updater do
|
||||||
|
require Pleroma.Constants
|
||||||
|
|
||||||
|
def update_content_fields(orig_object_data, updated_object) do
|
||||||
|
Pleroma.Constants.status_updatable_fields()
|
||||||
|
|> Enum.reduce(
|
||||||
|
%{data: orig_object_data, updated: false},
|
||||||
|
fn field, %{data: data, updated: updated} ->
|
||||||
|
updated =
|
||||||
|
updated or
|
||||||
|
(field != "updated" and
|
||||||
|
Map.get(updated_object, field) != Map.get(orig_object_data, field))
|
||||||
|
|
||||||
|
data =
|
||||||
|
if Map.has_key?(updated_object, field) do
|
||||||
|
Map.put(data, field, updated_object[field])
|
||||||
|
else
|
||||||
|
Map.drop(data, [field])
|
||||||
|
end
|
||||||
|
|
||||||
|
%{data: data, updated: updated}
|
||||||
|
end
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def maybe_history(object) do
|
||||||
|
with history <- Map.get(object, "formerRepresentations"),
|
||||||
|
true <- is_map(history),
|
||||||
|
"OrderedCollection" <- Map.get(history, "type"),
|
||||||
|
true <- is_list(Map.get(history, "orderedItems")),
|
||||||
|
true <- is_integer(Map.get(history, "totalItems")) do
|
||||||
|
history
|
||||||
|
else
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def history_for(object) do
|
||||||
|
with history when not is_nil(history) <- maybe_history(object) do
|
||||||
|
history
|
||||||
|
else
|
||||||
|
_ -> history_skeleton()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp history_skeleton do
|
||||||
|
%{
|
||||||
|
"type" => "OrderedCollection",
|
||||||
|
"totalItems" => 0,
|
||||||
|
"orderedItems" => []
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def maybe_update_history(
|
||||||
|
updated_object,
|
||||||
|
orig_object_data,
|
||||||
|
opts
|
||||||
|
) do
|
||||||
|
updated = opts[:updated]
|
||||||
|
use_history_in_new_object? = opts[:use_history_in_new_object?]
|
||||||
|
|
||||||
|
if not updated do
|
||||||
|
%{updated_object: updated_object, used_history_in_new_object?: false}
|
||||||
|
else
|
||||||
|
# Put edit history
|
||||||
|
# Note that we may have got the edit history by first fetching the object
|
||||||
|
{new_history, used_history_in_new_object?} =
|
||||||
|
with true <- use_history_in_new_object?,
|
||||||
|
updated_history when not is_nil(updated_history) <- maybe_history(opts[:new_data]) do
|
||||||
|
{updated_history, true}
|
||||||
|
else
|
||||||
|
_ ->
|
||||||
|
history = history_for(orig_object_data)
|
||||||
|
|
||||||
|
latest_history_item =
|
||||||
|
orig_object_data
|
||||||
|
|> Map.drop(["id", "formerRepresentations"])
|
||||||
|
|
||||||
|
updated_history =
|
||||||
|
history
|
||||||
|
|> Map.put("orderedItems", [latest_history_item | history["orderedItems"]])
|
||||||
|
|> Map.put("totalItems", history["totalItems"] + 1)
|
||||||
|
|
||||||
|
{updated_history, false}
|
||||||
|
end
|
||||||
|
|
||||||
|
updated_object =
|
||||||
|
updated_object
|
||||||
|
|> Map.put("formerRepresentations", new_history)
|
||||||
|
|
||||||
|
%{updated_object: updated_object, used_history_in_new_object?: used_history_in_new_object?}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_update_poll(to_be_updated, updated_object) do
|
||||||
|
choice_key = fn data ->
|
||||||
|
if Map.has_key?(data, "anyOf"), do: "anyOf", else: "oneOf"
|
||||||
|
end
|
||||||
|
|
||||||
|
with true <- to_be_updated["type"] == "Question",
|
||||||
|
key <- choice_key.(updated_object),
|
||||||
|
true <- key == choice_key.(to_be_updated),
|
||||||
|
orig_choices <- to_be_updated[key] |> Enum.map(&Map.drop(&1, ["replies"])),
|
||||||
|
new_choices <- updated_object[key] |> Enum.map(&Map.drop(&1, ["replies"])),
|
||||||
|
true <- orig_choices == new_choices do
|
||||||
|
# Choices are the same, but counts are different
|
||||||
|
to_be_updated
|
||||||
|
|> Map.put(key, updated_object[key])
|
||||||
|
else
|
||||||
|
# Choices (or vote type) have changed, do not allow this
|
||||||
|
_ -> to_be_updated
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# This calculates the data to be sent as the object of an Update.
|
||||||
|
# new_data's formerRepresentations is not considered.
|
||||||
|
# formerRepresentations is added to the returned data.
|
||||||
|
def make_update_object_data(original_data, new_data, date) do
|
||||||
|
%{data: updated_data, updated: updated} =
|
||||||
|
original_data
|
||||||
|
|> update_content_fields(new_data)
|
||||||
|
|
||||||
|
if not updated do
|
||||||
|
updated_data
|
||||||
|
else
|
||||||
|
%{updated_object: updated_data} =
|
||||||
|
updated_data
|
||||||
|
|> maybe_update_history(original_data, updated: updated, use_history_in_new_object?: false)
|
||||||
|
|
||||||
|
updated_data
|
||||||
|
|> Map.put("updated", date)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# This calculates the data of the new Object from an Update.
|
||||||
|
# new_data's formerRepresentations is considered.
|
||||||
|
def make_new_object_data_from_update_object(original_data, new_data) do
|
||||||
|
update_is_reasonable =
|
||||||
|
with {_, updated} when not is_nil(updated) <- {:cur_updated, new_data["updated"]},
|
||||||
|
{_, {:ok, updated_time, _}} <- {:cur_updated, DateTime.from_iso8601(updated)},
|
||||||
|
{_, last_updated} when not is_nil(last_updated) <-
|
||||||
|
{:last_updated, original_data["updated"] || original_data["published"]},
|
||||||
|
{_, {:ok, last_updated_time, _}} <-
|
||||||
|
{:last_updated, DateTime.from_iso8601(last_updated)},
|
||||||
|
:gt <- DateTime.compare(updated_time, last_updated_time) do
|
||||||
|
:update_everything
|
||||||
|
else
|
||||||
|
# only allow poll updates
|
||||||
|
{:cur_updated, _} -> :no_content_update
|
||||||
|
:eq -> :no_content_update
|
||||||
|
# allow all updates
|
||||||
|
{:last_updated, _} -> :update_everything
|
||||||
|
# allow no updates
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
|
||||||
|
%{
|
||||||
|
updated_object: updated_data,
|
||||||
|
used_history_in_new_object?: used_history_in_new_object?,
|
||||||
|
updated: updated
|
||||||
|
} =
|
||||||
|
if update_is_reasonable == :update_everything do
|
||||||
|
%{data: updated_data, updated: updated} =
|
||||||
|
original_data
|
||||||
|
|> update_content_fields(new_data)
|
||||||
|
|
||||||
|
updated_data
|
||||||
|
|> maybe_update_history(original_data,
|
||||||
|
updated: updated,
|
||||||
|
use_history_in_new_object?: true,
|
||||||
|
new_data: new_data
|
||||||
|
)
|
||||||
|
|> Map.put(:updated, updated)
|
||||||
|
else
|
||||||
|
%{
|
||||||
|
updated_object: original_data,
|
||||||
|
used_history_in_new_object?: false,
|
||||||
|
updated: false
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
updated_data =
|
||||||
|
if update_is_reasonable != false do
|
||||||
|
updated_data
|
||||||
|
|> maybe_update_poll(new_data)
|
||||||
|
else
|
||||||
|
updated_data
|
||||||
|
end
|
||||||
|
|
||||||
|
%{
|
||||||
|
updated_data: updated_data,
|
||||||
|
updated: updated,
|
||||||
|
used_history_in_new_object?: used_history_in_new_object?
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def for_each_history_item(%{"orderedItems" => items} = history, _object, fun) do
|
||||||
|
new_items =
|
||||||
|
Enum.map(items, fun)
|
||||||
|
|> Enum.reduce_while(
|
||||||
|
{:ok, []},
|
||||||
|
fn
|
||||||
|
{:ok, item}, {:ok, acc} -> {:cont, {:ok, acc ++ [item]}}
|
||||||
|
e, _acc -> {:halt, e}
|
||||||
|
end
|
||||||
|
)
|
||||||
|
|
||||||
|
case new_items do
|
||||||
|
{:ok, items} -> {:ok, Map.put(history, "orderedItems", items)}
|
||||||
|
e -> e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def for_each_history_item(history, _, _) do
|
||||||
|
{:ok, history}
|
||||||
|
end
|
||||||
|
|
||||||
|
def do_with_history(object, fun) do
|
||||||
|
with history <- object["formerRepresentations"],
|
||||||
|
object <- Map.drop(object, ["formerRepresentations"]),
|
||||||
|
{_, {:ok, object}} <- {:main_body, fun.(object)},
|
||||||
|
{_, {:ok, history}} <- {:history_items, for_each_history_item(history, object, fun)} do
|
||||||
|
object =
|
||||||
|
if history do
|
||||||
|
Map.put(object, "formerRepresentations", history)
|
||||||
|
else
|
||||||
|
object
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, object}
|
||||||
|
else
|
||||||
|
{:main_body, e} -> e
|
||||||
|
{:history_items, e} -> e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -36,6 +36,7 @@ defmodule Pleroma.Upload do
|
||||||
alias Ecto.UUID
|
alias Ecto.UUID
|
||||||
alias Pleroma.Config
|
alias Pleroma.Config
|
||||||
alias Pleroma.Maps
|
alias Pleroma.Maps
|
||||||
|
alias Pleroma.Web.ActivityPub.Utils
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@type source ::
|
@type source ::
|
||||||
|
@ -88,6 +89,7 @@ def store(upload, opts \\ []) do
|
||||||
{:ok, url_spec} <- Pleroma.Uploaders.Uploader.put_file(opts.uploader, upload) do
|
{:ok, url_spec} <- Pleroma.Uploaders.Uploader.put_file(opts.uploader, upload) do
|
||||||
{:ok,
|
{:ok,
|
||||||
%{
|
%{
|
||||||
|
"id" => Utils.generate_object_id(),
|
||||||
"type" => opts.activity_type,
|
"type" => opts.activity_type,
|
||||||
"mediaType" => upload.content_type,
|
"mediaType" => upload.content_type,
|
||||||
"url" => [
|
"url" => [
|
||||||
|
|
|
@ -194,7 +194,16 @@ defp insert_activity_with_expiration(data, local, recipients) do
|
||||||
def notify_and_stream(activity) do
|
def notify_and_stream(activity) do
|
||||||
Notification.create_notifications(activity)
|
Notification.create_notifications(activity)
|
||||||
|
|
||||||
conversation = create_or_bump_conversation(activity, activity.actor)
|
original_activity =
|
||||||
|
case activity do
|
||||||
|
%{data: %{"type" => "Update"}, object: %{data: %{"id" => id}}} ->
|
||||||
|
Activity.get_create_by_object_ap_id_with_object(id)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
activity
|
||||||
|
end
|
||||||
|
|
||||||
|
conversation = create_or_bump_conversation(original_activity, original_activity.actor)
|
||||||
participations = get_participations(conversation)
|
participations = get_participations(conversation)
|
||||||
stream_out(activity)
|
stream_out(activity)
|
||||||
stream_out_participations(participations)
|
stream_out_participations(participations)
|
||||||
|
@ -260,7 +269,7 @@ def stream_out_participations(_, _), do: :noop
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def stream_out(%Activity{data: %{"type" => data_type}} = activity)
|
def stream_out(%Activity{data: %{"type" => data_type}} = activity)
|
||||||
when data_type in ["Create", "Announce", "Delete"] do
|
when data_type in ["Create", "Announce", "Delete", "Update"] do
|
||||||
activity
|
activity
|
||||||
|> Topics.get_activity_topics()
|
|> Topics.get_activity_topics()
|
||||||
|> Streamer.stream(activity)
|
|> Streamer.stream(activity)
|
||||||
|
|
|
@ -278,10 +278,16 @@ def like(actor, object) do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Retricted to user updates for now, always public
|
|
||||||
@spec update(User.t(), Object.t()) :: {:ok, map(), keyword()}
|
@spec update(User.t(), Object.t()) :: {:ok, map(), keyword()}
|
||||||
def update(actor, object) do
|
def update(actor, object) do
|
||||||
to = [Pleroma.Constants.as_public(), actor.follower_address]
|
{to, cc} =
|
||||||
|
if object["type"] in Pleroma.Constants.actor_types() do
|
||||||
|
# User updates, always public
|
||||||
|
{[Pleroma.Constants.as_public(), actor.follower_address], []}
|
||||||
|
else
|
||||||
|
# Status updates, follow the recipients in the object
|
||||||
|
{object["to"] || [], object["cc"] || []}
|
||||||
|
end
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
%{
|
%{
|
||||||
|
@ -289,7 +295,8 @@ def update(actor, object) do
|
||||||
"type" => "Update",
|
"type" => "Update",
|
||||||
"actor" => actor.ap_id,
|
"actor" => actor.ap_id,
|
||||||
"object" => object,
|
"object" => object,
|
||||||
"to" => to
|
"to" => to,
|
||||||
|
"cc" => cc
|
||||||
}, []}
|
}, []}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -63,10 +63,53 @@ defmodule Pleroma.Web.ActivityPub.MRF do
|
||||||
|
|
||||||
@required_description_keys [:key, :related_policy]
|
@required_description_keys [:key, :related_policy]
|
||||||
|
|
||||||
|
def filter_one(policy, message) do
|
||||||
|
should_plug_history? =
|
||||||
|
if function_exported?(policy, :history_awareness, 0) do
|
||||||
|
policy.history_awareness()
|
||||||
|
else
|
||||||
|
:manual
|
||||||
|
end
|
||||||
|
|> Kernel.==(:auto)
|
||||||
|
|
||||||
|
if not should_plug_history? do
|
||||||
|
policy.filter(message)
|
||||||
|
else
|
||||||
|
main_result = policy.filter(message)
|
||||||
|
|
||||||
|
with {_, {:ok, main_message}} <- {:main, main_result},
|
||||||
|
{_,
|
||||||
|
%{
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"orderedItems" => [_ | _]
|
||||||
|
}
|
||||||
|
}} = {_, object} <- {:object, message["object"]},
|
||||||
|
{_, {:ok, new_history}} <-
|
||||||
|
{:history,
|
||||||
|
Pleroma.Object.Updater.for_each_history_item(
|
||||||
|
object["formerRepresentations"],
|
||||||
|
object,
|
||||||
|
fn item ->
|
||||||
|
with {:ok, filtered} <- policy.filter(Map.put(message, "object", item)) do
|
||||||
|
{:ok, filtered["object"]}
|
||||||
|
else
|
||||||
|
e -> e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
)} do
|
||||||
|
{:ok, put_in(main_message, ["object", "formerRepresentations"], new_history)}
|
||||||
|
else
|
||||||
|
{:main, _} -> main_result
|
||||||
|
{:object, _} -> main_result
|
||||||
|
{:history, e} -> e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def filter(policies, %{} = message) do
|
def filter(policies, %{} = message) do
|
||||||
policies
|
policies
|
||||||
|> Enum.reduce({:ok, message}, fn
|
|> Enum.reduce({:ok, message}, fn
|
||||||
policy, {:ok, message} -> policy.filter(message)
|
policy, {:ok, message} -> filter_one(policy, message)
|
||||||
_, error -> error
|
_, error -> error
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
|
@ -9,6 +9,9 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicy do
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def history_awareness, do: :auto
|
||||||
|
|
||||||
# has the user successfully posted before?
|
# has the user successfully posted before?
|
||||||
defp old_user?(%User{} = u) do
|
defp old_user?(%User{} = u) do
|
||||||
u.note_count > 0 || u.follower_count > 0
|
u.note_count > 0 || u.follower_count > 0
|
||||||
|
|
|
@ -10,6 +10,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.EnsureRePrepended do
|
||||||
|
|
||||||
@reply_prefix Regex.compile!("^re:[[:space:]]*", [:caseless])
|
@reply_prefix Regex.compile!("^re:[[:space:]]*", [:caseless])
|
||||||
|
|
||||||
|
def history_awareness, do: :auto
|
||||||
|
|
||||||
def filter_by_summary(
|
def filter_by_summary(
|
||||||
%{data: %{"summary" => parent_summary}} = _in_reply_to,
|
%{data: %{"summary" => parent_summary}} = _in_reply_to,
|
||||||
%{"summary" => child_summary} = child
|
%{"summary" => child_summary} = child
|
||||||
|
@ -27,8 +29,8 @@ def filter_by_summary(
|
||||||
|
|
||||||
def filter_by_summary(_in_reply_to, child), do: child
|
def filter_by_summary(_in_reply_to, child), do: child
|
||||||
|
|
||||||
def filter(%{"type" => "Create", "object" => child_object} = object)
|
def filter(%{"type" => type, "object" => child_object} = object)
|
||||||
when is_map(child_object) do
|
when type in ["Create", "Update"] and is_map(child_object) do
|
||||||
child =
|
child =
|
||||||
child_object["inReplyTo"]
|
child_object["inReplyTo"]
|
||||||
|> Object.normalize(fetch: false)
|
|> Object.normalize(fetch: false)
|
||||||
|
|
|
@ -16,6 +16,9 @@ defmodule Pleroma.Web.ActivityPub.MRF.HashtagPolicy do
|
||||||
|
|
||||||
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
|
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def history_awareness, do: :manual
|
||||||
|
|
||||||
defp check_reject(message, hashtags) do
|
defp check_reject(message, hashtags) do
|
||||||
if Enum.any?(Config.get([:mrf_hashtag, :reject]), fn match -> match in hashtags end) do
|
if Enum.any?(Config.get([:mrf_hashtag, :reject]), fn match -> match in hashtags end) do
|
||||||
{:reject, "[HashtagPolicy] Matches with rejected keyword"}
|
{:reject, "[HashtagPolicy] Matches with rejected keyword"}
|
||||||
|
@ -47,22 +50,46 @@ defp check_ftl_removal(%{"to" => to} = message, hashtags) do
|
||||||
|
|
||||||
defp check_ftl_removal(message, _hashtags), do: {:ok, message}
|
defp check_ftl_removal(message, _hashtags), do: {:ok, message}
|
||||||
|
|
||||||
defp check_sensitive(message, hashtags) do
|
defp check_sensitive(message) do
|
||||||
if Enum.any?(Config.get([:mrf_hashtag, :sensitive]), fn match -> match in hashtags end) do
|
{:ok, new_object} =
|
||||||
{:ok, Kernel.put_in(message, ["object", "sensitive"], true)}
|
Object.Updater.do_with_history(message["object"], fn object ->
|
||||||
else
|
hashtags = Object.hashtags(%Object{data: object})
|
||||||
{:ok, message}
|
|
||||||
end
|
if Enum.any?(Config.get([:mrf_hashtag, :sensitive]), fn match -> match in hashtags end) do
|
||||||
|
{:ok, Map.put(object, "sensitive", true)}
|
||||||
|
else
|
||||||
|
{:ok, object}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok, Map.put(message, "object", new_object)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def filter(%{"type" => "Create", "object" => object} = message) do
|
def filter(%{"type" => type, "object" => object} = message) when type in ["Create", "Update"] do
|
||||||
hashtags = Object.hashtags(%Object{data: object})
|
history_items =
|
||||||
|
with %{"formerRepresentations" => %{"orderedItems" => items}} <- object do
|
||||||
|
items
|
||||||
|
else
|
||||||
|
_ -> []
|
||||||
|
end
|
||||||
|
|
||||||
|
historical_hashtags =
|
||||||
|
Enum.reduce(history_items, [], fn item, acc ->
|
||||||
|
acc ++ Object.hashtags(%Object{data: item})
|
||||||
|
end)
|
||||||
|
|
||||||
|
hashtags = Object.hashtags(%Object{data: object}) ++ historical_hashtags
|
||||||
|
|
||||||
if hashtags != [] do
|
if hashtags != [] do
|
||||||
with {:ok, message} <- check_reject(message, hashtags),
|
with {:ok, message} <- check_reject(message, hashtags),
|
||||||
{:ok, message} <- check_ftl_removal(message, hashtags),
|
{:ok, message} <-
|
||||||
{:ok, message} <- check_sensitive(message, hashtags) do
|
(if "type" == "Create" do
|
||||||
|
check_ftl_removal(message, hashtags)
|
||||||
|
else
|
||||||
|
{:ok, message}
|
||||||
|
end),
|
||||||
|
{:ok, message} <- check_sensitive(message) do
|
||||||
{:ok, message}
|
{:ok, message}
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
|
|
|
@ -27,24 +27,46 @@ defp object_payload(%{} = object) do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp check_reject(%{"object" => %{} = object} = message) do
|
defp check_reject(%{"object" => %{} = object} = message) do
|
||||||
payload = object_payload(object)
|
with {:ok, _new_object} <-
|
||||||
|
Pleroma.Object.Updater.do_with_history(object, fn object ->
|
||||||
|
payload = object_payload(object)
|
||||||
|
|
||||||
if Enum.any?(Pleroma.Config.get([:mrf_keyword, :reject]), fn pattern ->
|
if Enum.any?(Pleroma.Config.get([:mrf_keyword, :reject]), fn pattern ->
|
||||||
string_matches?(payload, pattern)
|
string_matches?(payload, pattern)
|
||||||
end) do
|
end) do
|
||||||
{:reject, "[KeywordPolicy] Matches with rejected keyword"}
|
{:reject, "[KeywordPolicy] Matches with rejected keyword"}
|
||||||
else
|
else
|
||||||
|
{:ok, message}
|
||||||
|
end
|
||||||
|
end) do
|
||||||
{:ok, message}
|
{:ok, message}
|
||||||
|
else
|
||||||
|
e -> e
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp check_ftl_removal(%{"to" => to, "object" => %{} = object} = message) do
|
defp check_ftl_removal(%{"type" => "Create", "to" => to, "object" => %{} = object} = message) do
|
||||||
payload = object_payload(object)
|
check_keyword = fn object ->
|
||||||
|
payload = object_payload(object)
|
||||||
|
|
||||||
if Pleroma.Constants.as_public() in to and
|
if Enum.any?(Pleroma.Config.get([:mrf_keyword, :federated_timeline_removal]), fn pattern ->
|
||||||
Enum.any?(Pleroma.Config.get([:mrf_keyword, :federated_timeline_removal]), fn pattern ->
|
|
||||||
string_matches?(payload, pattern)
|
string_matches?(payload, pattern)
|
||||||
end) do
|
end) do
|
||||||
|
{:should_delist, nil}
|
||||||
|
else
|
||||||
|
{:ok, %{}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
should_delist? = fn object ->
|
||||||
|
with {:ok, _} <- Pleroma.Object.Updater.do_with_history(object, check_keyword) do
|
||||||
|
false
|
||||||
|
else
|
||||||
|
_ -> true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if Pleroma.Constants.as_public() in to and should_delist?.(object) do
|
||||||
to = List.delete(to, Pleroma.Constants.as_public())
|
to = List.delete(to, Pleroma.Constants.as_public())
|
||||||
cc = [Pleroma.Constants.as_public() | message["cc"] || []]
|
cc = [Pleroma.Constants.as_public() | message["cc"] || []]
|
||||||
|
|
||||||
|
@ -59,8 +81,12 @@ defp check_ftl_removal(%{"to" => to, "object" => %{} = object} = message) do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp check_ftl_removal(message) do
|
||||||
|
{:ok, message}
|
||||||
|
end
|
||||||
|
|
||||||
defp check_replace(%{"object" => %{} = object} = message) do
|
defp check_replace(%{"object" => %{} = object} = message) do
|
||||||
object =
|
replace_kw = fn object ->
|
||||||
["content", "name", "summary"]
|
["content", "name", "summary"]
|
||||||
|> Enum.filter(fn field -> Map.has_key?(object, field) && object[field] end)
|
|> Enum.filter(fn field -> Map.has_key?(object, field) && object[field] end)
|
||||||
|> Enum.reduce(object, fn field, object ->
|
|> Enum.reduce(object, fn field, object ->
|
||||||
|
@ -73,6 +99,10 @@ defp check_replace(%{"object" => %{} = object} = message) do
|
||||||
|
|
||||||
Map.put(object, field, data)
|
Map.put(object, field, data)
|
||||||
end)
|
end)
|
||||||
|
|> (fn object -> {:ok, object} end).()
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, object} = Pleroma.Object.Updater.do_with_history(object, replace_kw)
|
||||||
|
|
||||||
message = Map.put(message, "object", object)
|
message = Map.put(message, "object", object)
|
||||||
|
|
||||||
|
@ -80,7 +110,8 @@ defp check_replace(%{"object" => %{} = object} = message) do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def filter(%{"type" => "Create", "object" => %{"content" => _content}} = message) do
|
def filter(%{"type" => type, "object" => %{"content" => _content}} = message)
|
||||||
|
when type in ["Create", "Update"] do
|
||||||
with {:ok, message} <- check_reject(message),
|
with {:ok, message} <- check_reject(message),
|
||||||
{:ok, message} <- check_ftl_removal(message),
|
{:ok, message} <- check_ftl_removal(message),
|
||||||
{:ok, message} <- check_replace(message) do
|
{:ok, message} <- check_replace(message) do
|
||||||
|
|
|
@ -15,6 +15,9 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do
|
||||||
recv_timeout: 10_000
|
recv_timeout: 10_000
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def history_awareness, do: :auto
|
||||||
|
|
||||||
defp prefetch(url) do
|
defp prefetch(url) do
|
||||||
# Fetching only proxiable resources
|
# Fetching only proxiable resources
|
||||||
if MediaProxy.enabled?() and MediaProxy.url_proxiable?(url) do
|
if MediaProxy.enabled?() and MediaProxy.url_proxiable?(url) do
|
||||||
|
@ -53,10 +56,8 @@ defp preload(%{"object" => %{"attachment" => attachments}} = _message) do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def filter(
|
def filter(%{"type" => type, "object" => %{"attachment" => attachments} = _object} = message)
|
||||||
%{"type" => "Create", "object" => %{"attachment" => attachments} = _object} = message
|
when type in ["Create", "Update"] and is_list(attachments) and length(attachments) > 0 do
|
||||||
)
|
|
||||||
when is_list(attachments) and length(attachments) > 0 do
|
|
||||||
preload(message)
|
preload(message)
|
||||||
|
|
||||||
{:ok, message}
|
{:ok, message}
|
||||||
|
|
|
@ -11,6 +11,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoEmptyPolicy do
|
||||||
@impl true
|
@impl true
|
||||||
def filter(%{"actor" => actor} = object) do
|
def filter(%{"actor" => actor} = object) do
|
||||||
with true <- is_local?(actor),
|
with true <- is_local?(actor),
|
||||||
|
true <- is_eligible_type?(object),
|
||||||
true <- is_note?(object),
|
true <- is_note?(object),
|
||||||
false <- has_attachment?(object),
|
false <- has_attachment?(object),
|
||||||
true <- only_mentions?(object) do
|
true <- only_mentions?(object) do
|
||||||
|
@ -32,7 +33,6 @@ defp is_local?(actor) do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp has_attachment?(%{
|
defp has_attachment?(%{
|
||||||
"type" => "Create",
|
|
||||||
"object" => %{"type" => "Note", "attachment" => attachments}
|
"object" => %{"type" => "Note", "attachment" => attachments}
|
||||||
})
|
})
|
||||||
when length(attachments) > 0,
|
when length(attachments) > 0,
|
||||||
|
@ -40,23 +40,13 @@ defp has_attachment?(%{
|
||||||
|
|
||||||
defp has_attachment?(_), do: false
|
defp has_attachment?(_), do: false
|
||||||
|
|
||||||
defp only_mentions?(%{"type" => "Create", "object" => %{"type" => "Note", "source" => source}})
|
defp only_mentions?(%{"object" => %{"type" => "Note", "source" => source}}) do
|
||||||
when is_binary(source) do
|
source =
|
||||||
non_mentions =
|
case source do
|
||||||
source |> String.split() |> Enum.filter(&(not String.starts_with?(&1, "@"))) |> length
|
%{"content" => text} -> text
|
||||||
|
_ -> source
|
||||||
|
end
|
||||||
|
|
||||||
if non_mentions > 0 do
|
|
||||||
false
|
|
||||||
else
|
|
||||||
true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp only_mentions?(%{
|
|
||||||
"type" => "Create",
|
|
||||||
"object" => %{"type" => "Note", "source" => %{"content" => source}}
|
|
||||||
})
|
|
||||||
when is_binary(source) do
|
|
||||||
non_mentions =
|
non_mentions =
|
||||||
source |> String.split() |> Enum.filter(&(not String.starts_with?(&1, "@"))) |> length
|
source |> String.split() |> Enum.filter(&(not String.starts_with?(&1, "@"))) |> length
|
||||||
|
|
||||||
|
@ -69,9 +59,12 @@ defp only_mentions?(%{
|
||||||
|
|
||||||
defp only_mentions?(_), do: false
|
defp only_mentions?(_), do: false
|
||||||
|
|
||||||
defp is_note?(%{"type" => "Create", "object" => %{"type" => "Note"}}), do: true
|
defp is_note?(%{"object" => %{"type" => "Note"}}), do: true
|
||||||
defp is_note?(_), do: false
|
defp is_note?(_), do: false
|
||||||
|
|
||||||
|
defp is_eligible_type?(%{"type" => type}) when type in ["Create", "Update"], do: true
|
||||||
|
defp is_eligible_type?(_), do: false
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def describe, do: {:ok, %{}}
|
def describe, do: {:ok, %{}}
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,14 +6,17 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicy do
|
||||||
@moduledoc "Ensure no content placeholder is present (such as the dot from mastodon)"
|
@moduledoc "Ensure no content placeholder is present (such as the dot from mastodon)"
|
||||||
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
|
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def history_awareness, do: :auto
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def filter(
|
def filter(
|
||||||
%{
|
%{
|
||||||
"type" => "Create",
|
"type" => type,
|
||||||
"object" => %{"content" => content, "attachment" => _} = _child_object
|
"object" => %{"content" => content, "attachment" => _} = _child_object
|
||||||
} = object
|
} = object
|
||||||
)
|
)
|
||||||
when content in [".", "<p>.</p>"] do
|
when type in ["Create", "Update"] and content in [".", "<p>.</p>"] do
|
||||||
{:ok, put_in(object, ["object", "content"], "")}
|
{:ok, put_in(object, ["object", "content"], "")}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,11 @@ defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkup do
|
||||||
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
|
@behaviour Pleroma.Web.ActivityPub.MRF.Policy
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def filter(%{"type" => "Create", "object" => child_object} = object) do
|
def history_awareness, do: :auto
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def filter(%{"type" => type, "object" => child_object} = object)
|
||||||
|
when type in ["Create", "Update"] do
|
||||||
scrub_policy = Pleroma.Config.get([:mrf_normalize_markup, :scrub_policy])
|
scrub_policy = Pleroma.Config.get([:mrf_normalize_markup, :scrub_policy])
|
||||||
|
|
||||||
content =
|
content =
|
||||||
|
|
|
@ -12,5 +12,6 @@ defmodule Pleroma.Web.ActivityPub.MRF.Policy do
|
||||||
label: String.t(),
|
label: String.t(),
|
||||||
description: String.t()
|
description: String.t()
|
||||||
}
|
}
|
||||||
@optional_callbacks config_description: 0
|
@callback history_awareness() :: :auto | :manual
|
||||||
|
@optional_callbacks config_description: 0, history_awareness: 0
|
||||||
end
|
end
|
||||||
|
|
|
@ -86,8 +86,8 @@ def validate(
|
||||||
meta
|
meta
|
||||||
)
|
)
|
||||||
when objtype in ~w[Question Answer Audio Video Event Article Note Page] do
|
when objtype in ~w[Question Answer Audio Video Event Article Note Page] do
|
||||||
with {:ok, object_data} <- cast_and_apply(object),
|
with {:ok, object_data} <- cast_and_apply_and_stringify_with_history(object),
|
||||||
meta = Keyword.put(meta, :object_data, object_data |> stringify_keys),
|
meta = Keyword.put(meta, :object_data, object_data),
|
||||||
{:ok, create_activity} <-
|
{:ok, create_activity} <-
|
||||||
create_activity
|
create_activity
|
||||||
|> CreateGenericValidator.cast_and_validate(meta)
|
|> CreateGenericValidator.cast_and_validate(meta)
|
||||||
|
@ -111,19 +111,53 @@ def validate(%{"type" => type} = object, meta)
|
||||||
end
|
end
|
||||||
|
|
||||||
with {:ok, object} <-
|
with {:ok, object} <-
|
||||||
object
|
do_separate_with_history(object, fn object ->
|
||||||
|> validator.cast_and_validate()
|
with {:ok, object} <-
|
||||||
|> Ecto.Changeset.apply_action(:insert) do
|
object
|
||||||
object = stringify_keys(object)
|
|> validator.cast_and_validate()
|
||||||
|
|> Ecto.Changeset.apply_action(:insert) do
|
||||||
|
object = stringify_keys(object)
|
||||||
|
|
||||||
# Insert copy of hashtags as strings for the non-hashtag table indexing
|
# Insert copy of hashtags as strings for the non-hashtag table indexing
|
||||||
tag = (object["tag"] || []) ++ Object.hashtags(%Object{data: object})
|
tag = (object["tag"] || []) ++ Object.hashtags(%Object{data: object})
|
||||||
object = Map.put(object, "tag", tag)
|
object = Map.put(object, "tag", tag)
|
||||||
|
|
||||||
|
{:ok, object}
|
||||||
|
end
|
||||||
|
end) do
|
||||||
{:ok, object, meta}
|
{:ok, object, meta}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate(
|
||||||
|
%{"type" => "Update", "object" => %{"type" => objtype} = object} = update_activity,
|
||||||
|
meta
|
||||||
|
)
|
||||||
|
when objtype in ~w[Question Answer Audio Video Event Article Note Page] do
|
||||||
|
with {_, false} <- {:local, Access.get(meta, :local, false)},
|
||||||
|
{_, {:ok, object_data, _}} <- {:object_validation, validate(object, meta)},
|
||||||
|
meta = Keyword.put(meta, :object_data, object_data),
|
||||||
|
{:ok, update_activity} <-
|
||||||
|
update_activity
|
||||||
|
|> UpdateValidator.cast_and_validate()
|
||||||
|
|> Ecto.Changeset.apply_action(:insert) do
|
||||||
|
update_activity = stringify_keys(update_activity)
|
||||||
|
{:ok, update_activity, meta}
|
||||||
|
else
|
||||||
|
{:local, _} ->
|
||||||
|
with {:ok, object} <-
|
||||||
|
update_activity
|
||||||
|
|> UpdateValidator.cast_and_validate()
|
||||||
|
|> Ecto.Changeset.apply_action(:insert) do
|
||||||
|
object = stringify_keys(object)
|
||||||
|
{:ok, object, meta}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:object_validation, e} ->
|
||||||
|
e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def validate(%{"type" => type} = object, meta)
|
def validate(%{"type" => type} = object, meta)
|
||||||
when type in ~w[Accept Reject Follow Update Like EmojiReact Announce
|
when type in ~w[Accept Reject Follow Update Like EmojiReact Announce
|
||||||
Answer] do
|
Answer] do
|
||||||
|
@ -160,6 +194,15 @@ def validate(%{"type" => type} = object, meta) when type in ~w(Add Remove) do
|
||||||
|
|
||||||
def validate(o, m), do: {:error, {:validator_not_set, {o, m}}}
|
def validate(o, m), do: {:error, {:validator_not_set, {o, m}}}
|
||||||
|
|
||||||
|
def cast_and_apply_and_stringify_with_history(object) do
|
||||||
|
do_separate_with_history(object, fn object ->
|
||||||
|
with {:ok, object_data} <- cast_and_apply(object),
|
||||||
|
object_data <- object_data |> stringify_keys() do
|
||||||
|
{:ok, object_data}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
def cast_and_apply(%{"type" => "Question"} = object) do
|
def cast_and_apply(%{"type" => "Question"} = object) do
|
||||||
QuestionValidator.cast_and_apply(object)
|
QuestionValidator.cast_and_apply(object)
|
||||||
end
|
end
|
||||||
|
@ -214,4 +257,54 @@ def fetch_actor_and_object(object) do
|
||||||
Object.normalize(object["object"], fetch: true)
|
Object.normalize(object["object"], fetch: true)
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp for_each_history_item(
|
||||||
|
%{"type" => "OrderedCollection", "orderedItems" => items} = history,
|
||||||
|
object,
|
||||||
|
fun
|
||||||
|
) do
|
||||||
|
processed_items =
|
||||||
|
Enum.map(items, fn item ->
|
||||||
|
with item <- Map.put(item, "id", object["id"]),
|
||||||
|
{:ok, item} <- fun.(item) do
|
||||||
|
item
|
||||||
|
else
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
if Enum.all?(processed_items, &(not is_nil(&1))) do
|
||||||
|
{:ok, Map.put(history, "orderedItems", processed_items)}
|
||||||
|
else
|
||||||
|
{:error, :invalid_history}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp for_each_history_item(nil, _object, _fun) do
|
||||||
|
{:ok, nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp for_each_history_item(_, _object, _fun) do
|
||||||
|
{:error, :invalid_history}
|
||||||
|
end
|
||||||
|
|
||||||
|
# fun is (object -> {:ok, validated_object_with_string_keys})
|
||||||
|
defp do_separate_with_history(object, fun) do
|
||||||
|
with history <- object["formerRepresentations"],
|
||||||
|
object <- Map.drop(object, ["formerRepresentations"]),
|
||||||
|
{_, {:ok, object}} <- {:main_body, fun.(object)},
|
||||||
|
{_, {:ok, history}} <- {:history_items, for_each_history_item(history, object, fun)} do
|
||||||
|
object =
|
||||||
|
if history do
|
||||||
|
Map.put(object, "formerRepresentations", history)
|
||||||
|
else
|
||||||
|
object
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, object}
|
||||||
|
else
|
||||||
|
{:main_body, e} -> e
|
||||||
|
{:history_items, e} -> e
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -53,7 +53,10 @@ defp fix_url(%{"url" => url} = data) when is_bitstring(url), do: data
|
||||||
defp fix_url(%{"url" => url} = data) when is_map(url), do: Map.put(data, "url", url["href"])
|
defp fix_url(%{"url" => url} = data) when is_map(url), do: Map.put(data, "url", url["href"])
|
||||||
defp fix_url(data), do: data
|
defp fix_url(data), do: data
|
||||||
|
|
||||||
defp fix_tag(%{"tag" => tag} = data) when is_list(tag), do: data
|
defp fix_tag(%{"tag" => tag} = data) when is_list(tag) do
|
||||||
|
Map.put(data, "tag", Enum.filter(tag, &is_map/1))
|
||||||
|
end
|
||||||
|
|
||||||
defp fix_tag(%{"tag" => tag} = data) when is_map(tag), do: Map.put(data, "tag", [tag])
|
defp fix_tag(%{"tag" => tag} = data) when is_map(tag), do: Map.put(data, "tag", [tag])
|
||||||
defp fix_tag(data), do: Map.drop(data, ["tag"])
|
defp fix_tag(data), do: Map.drop(data, ["tag"])
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do
|
||||||
|
|
||||||
@primary_key false
|
@primary_key false
|
||||||
embedded_schema do
|
embedded_schema do
|
||||||
|
field(:id, :string)
|
||||||
field(:type, :string)
|
field(:type, :string)
|
||||||
field(:mediaType, :string, default: "application/octet-stream")
|
field(:mediaType, :string, default: "application/octet-stream")
|
||||||
field(:name, :string)
|
field(:name, :string)
|
||||||
|
@ -43,7 +44,7 @@ def changeset(struct, data) do
|
||||||
|> fix_url()
|
|> fix_url()
|
||||||
|
|
||||||
struct
|
struct
|
||||||
|> cast(data, [:type, :mediaType, :name, :blurhash])
|
|> cast(data, [:id, :type, :mediaType, :name, :blurhash])
|
||||||
|> cast_embed(:url, with: &url_changeset/2, required: true)
|
|> cast_embed(:url, with: &url_changeset/2, required: true)
|
||||||
|> validate_inclusion(:type, ~w[Link Document Audio Image Video])
|
|> validate_inclusion(:type, ~w[Link Document Audio Image Video])
|
||||||
|> validate_required([:type, :mediaType])
|
|> validate_required([:type, :mediaType])
|
||||||
|
|
|
@ -33,6 +33,7 @@ defmacro object_fields do
|
||||||
field(:content, :string)
|
field(:content, :string)
|
||||||
|
|
||||||
field(:published, ObjectValidators.DateTime)
|
field(:published, ObjectValidators.DateTime)
|
||||||
|
field(:updated, ObjectValidators.DateTime)
|
||||||
field(:emoji, ObjectValidators.Emoji, default: %{})
|
field(:emoji, ObjectValidators.Emoji, default: %{})
|
||||||
embeds_many(:attachment, AttachmentValidator)
|
embeds_many(:attachment, AttachmentValidator)
|
||||||
end
|
end
|
||||||
|
|
|
@ -51,7 +51,9 @@ def validate_updating_rights(cng) do
|
||||||
with actor = get_field(cng, :actor),
|
with actor = get_field(cng, :actor),
|
||||||
object = get_field(cng, :object),
|
object = get_field(cng, :object),
|
||||||
{:ok, object_id} <- ObjectValidators.ObjectID.cast(object),
|
{:ok, object_id} <- ObjectValidators.ObjectID.cast(object),
|
||||||
true <- actor == object_id do
|
actor_uri <- URI.parse(actor),
|
||||||
|
object_uri <- URI.parse(object_id),
|
||||||
|
true <- actor_uri.host == object_uri.host do
|
||||||
cng
|
cng
|
||||||
else
|
else
|
||||||
_e ->
|
_e ->
|
||||||
|
|
|
@ -23,6 +23,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
|
||||||
alias Pleroma.Web.Streamer
|
alias Pleroma.Web.Streamer
|
||||||
alias Pleroma.Workers.PollWorker
|
alias Pleroma.Workers.PollWorker
|
||||||
|
|
||||||
|
require Pleroma.Constants
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
@logger Pleroma.Config.get([:side_effects, :logger], Logger)
|
@logger Pleroma.Config.get([:side_effects, :logger], Logger)
|
||||||
|
@ -150,23 +151,26 @@ def handle(
|
||||||
|
|
||||||
# Tasks this handles:
|
# Tasks this handles:
|
||||||
# - Update the user
|
# - Update the user
|
||||||
|
# - Update a non-user object (Note, Question, etc.)
|
||||||
#
|
#
|
||||||
# For a local user, we also get a changeset with the full information, so we
|
# For a local user, we also get a changeset with the full information, so we
|
||||||
# can update non-federating, non-activitypub settings as well.
|
# can update non-federating, non-activitypub settings as well.
|
||||||
@impl true
|
@impl true
|
||||||
def handle(%{data: %{"type" => "Update", "object" => updated_object}} = object, meta) do
|
def handle(%{data: %{"type" => "Update", "object" => updated_object}} = object, meta) do
|
||||||
if changeset = Keyword.get(meta, :user_update_changeset) do
|
updated_object_id = updated_object["id"]
|
||||||
changeset
|
|
||||||
|> User.update_and_set_cache()
|
with {_, true} <- {:has_id, is_binary(updated_object_id)},
|
||||||
|
%{"type" => type} <- updated_object,
|
||||||
|
{_, is_user} <- {:is_user, type in Pleroma.Constants.actor_types()} do
|
||||||
|
if is_user do
|
||||||
|
handle_update_user(object, meta)
|
||||||
|
else
|
||||||
|
handle_update_object(object, meta)
|
||||||
|
end
|
||||||
else
|
else
|
||||||
{:ok, new_user_data} = ActivityPub.user_data_from_user_object(updated_object)
|
_ ->
|
||||||
|
{:ok, object, meta}
|
||||||
User.get_by_ap_id(updated_object["id"])
|
|
||||||
|> User.remote_user_changeset(new_user_data)
|
|
||||||
|> User.update_and_set_cache()
|
|
||||||
end
|
end
|
||||||
|
|
||||||
{:ok, object, meta}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Tasks this handles:
|
# Tasks this handles:
|
||||||
|
@ -395,6 +399,79 @@ def handle(object, meta) do
|
||||||
{:ok, object, meta}
|
{:ok, object, meta}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp handle_update_user(
|
||||||
|
%{data: %{"type" => "Update", "object" => updated_object}} = object,
|
||||||
|
meta
|
||||||
|
) do
|
||||||
|
if changeset = Keyword.get(meta, :user_update_changeset) do
|
||||||
|
changeset
|
||||||
|
|> User.update_and_set_cache()
|
||||||
|
else
|
||||||
|
{:ok, new_user_data} = ActivityPub.user_data_from_user_object(updated_object)
|
||||||
|
|
||||||
|
User.get_by_ap_id(updated_object["id"])
|
||||||
|
|> User.remote_user_changeset(new_user_data)
|
||||||
|
|> User.update_and_set_cache()
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, object, meta}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_update_object(
|
||||||
|
%{data: %{"type" => "Update", "object" => updated_object}} = object,
|
||||||
|
meta
|
||||||
|
) do
|
||||||
|
orig_object_ap_id = updated_object["id"]
|
||||||
|
orig_object = Object.get_by_ap_id(orig_object_ap_id)
|
||||||
|
orig_object_data = orig_object.data
|
||||||
|
|
||||||
|
updated_object =
|
||||||
|
if meta[:local] do
|
||||||
|
# If this is a local Update, we don't process it by transmogrifier,
|
||||||
|
# so we use the embedded object as-is.
|
||||||
|
updated_object
|
||||||
|
else
|
||||||
|
meta[:object_data]
|
||||||
|
end
|
||||||
|
|
||||||
|
if orig_object_data["type"] in Pleroma.Constants.updatable_object_types() do
|
||||||
|
%{
|
||||||
|
updated_data: updated_object_data,
|
||||||
|
updated: updated,
|
||||||
|
used_history_in_new_object?: used_history_in_new_object?
|
||||||
|
} = Object.Updater.make_new_object_data_from_update_object(orig_object_data, updated_object)
|
||||||
|
|
||||||
|
changeset =
|
||||||
|
orig_object
|
||||||
|
|> Repo.preload(:hashtags)
|
||||||
|
|> Object.change(%{data: updated_object_data})
|
||||||
|
|
||||||
|
with {:ok, new_object} <- Repo.update(changeset),
|
||||||
|
{:ok, _} <- Object.invalid_object_cache(new_object),
|
||||||
|
{:ok, _} <- Object.set_cache(new_object),
|
||||||
|
# The metadata/utils.ex uses the object id for the cache.
|
||||||
|
{:ok, _} <- Pleroma.Activity.HTML.invalidate_cache_for(new_object.id) do
|
||||||
|
if used_history_in_new_object? do
|
||||||
|
with create_activity when not is_nil(create_activity) <-
|
||||||
|
Pleroma.Activity.get_create_by_object_ap_id(orig_object_ap_id),
|
||||||
|
{:ok, _} <- Pleroma.Activity.HTML.invalidate_cache_for(create_activity.id) do
|
||||||
|
nil
|
||||||
|
else
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if updated do
|
||||||
|
object
|
||||||
|
|> Activity.normalize()
|
||||||
|
|> ActivityPub.notify_and_stream()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, object, meta}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_object_creation(%{"type" => "Question"} = object, activity, meta) do
|
def handle_object_creation(%{"type" => "Question"} = object, activity, meta) do
|
||||||
with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do
|
with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do
|
||||||
PollWorker.schedule_poll_end(activity)
|
PollWorker.schedule_poll_end(activity)
|
||||||
|
|
|
@ -699,6 +699,24 @@ def prepare_object(object) do
|
||||||
|> strip_internal_fields
|
|> strip_internal_fields
|
||||||
|> strip_internal_tags
|
|> strip_internal_tags
|
||||||
|> set_type
|
|> set_type
|
||||||
|
|> maybe_process_history
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_process_history(%{"formerRepresentations" => %{"orderedItems" => history}} = object) do
|
||||||
|
processed_history =
|
||||||
|
Enum.map(
|
||||||
|
history,
|
||||||
|
fn
|
||||||
|
item when is_map(item) -> prepare_object(item)
|
||||||
|
item -> item
|
||||||
|
end
|
||||||
|
)
|
||||||
|
|
||||||
|
put_in(object, ["formerRepresentations", "orderedItems"], processed_history)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_process_history(object) do
|
||||||
|
object
|
||||||
end
|
end
|
||||||
|
|
||||||
# @doc
|
# @doc
|
||||||
|
@ -723,6 +741,21 @@ def prepare_outgoing(%{"type" => activity_type, "object" => object_id} = data)
|
||||||
{:ok, data}
|
{:ok, data}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def prepare_outgoing(%{"type" => "Update", "object" => %{"type" => objtype} = object} = data)
|
||||||
|
when objtype in Pleroma.Constants.updatable_object_types() do
|
||||||
|
object =
|
||||||
|
object
|
||||||
|
|> prepare_object
|
||||||
|
|
||||||
|
data =
|
||||||
|
data
|
||||||
|
|> Map.put("object", object)
|
||||||
|
|> Map.merge(Utils.make_json_ld_header())
|
||||||
|
|> Map.delete("bcc")
|
||||||
|
|
||||||
|
{:ok, data}
|
||||||
|
end
|
||||||
|
|
||||||
def prepare_outgoing(%{"type" => "Announce", "actor" => ap_id, "object" => object_id} = data) do
|
def prepare_outgoing(%{"type" => "Announce", "actor" => ap_id, "object" => object_id} = data) do
|
||||||
object =
|
object =
|
||||||
object_id
|
object_id
|
||||||
|
|
|
@ -6,9 +6,13 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do
|
||||||
alias OpenApiSpex.Operation
|
alias OpenApiSpex.Operation
|
||||||
alias OpenApiSpex.Schema
|
alias OpenApiSpex.Schema
|
||||||
alias Pleroma.Web.ApiSpec.AccountOperation
|
alias Pleroma.Web.ApiSpec.AccountOperation
|
||||||
|
alias Pleroma.Web.ApiSpec.Schemas.Account
|
||||||
alias Pleroma.Web.ApiSpec.Schemas.ApiError
|
alias Pleroma.Web.ApiSpec.Schemas.ApiError
|
||||||
|
alias Pleroma.Web.ApiSpec.Schemas.Attachment
|
||||||
alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
|
alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
|
||||||
|
alias Pleroma.Web.ApiSpec.Schemas.Emoji
|
||||||
alias Pleroma.Web.ApiSpec.Schemas.FlakeID
|
alias Pleroma.Web.ApiSpec.Schemas.FlakeID
|
||||||
|
alias Pleroma.Web.ApiSpec.Schemas.Poll
|
||||||
alias Pleroma.Web.ApiSpec.Schemas.ScheduledStatus
|
alias Pleroma.Web.ApiSpec.Schemas.ScheduledStatus
|
||||||
alias Pleroma.Web.ApiSpec.Schemas.Status
|
alias Pleroma.Web.ApiSpec.Schemas.Status
|
||||||
alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
|
alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
|
||||||
|
@ -422,6 +426,59 @@ def translate_operation do
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def show_history_operation do
|
||||||
|
%Operation{
|
||||||
|
tags: ["Retrieve status history"],
|
||||||
|
summary: "Status history",
|
||||||
|
description: "View history of a status",
|
||||||
|
operationId: "StatusController.show_history",
|
||||||
|
security: [%{"oAuth" => ["read:statuses"]}],
|
||||||
|
parameters: [
|
||||||
|
id_param()
|
||||||
|
],
|
||||||
|
responses: %{
|
||||||
|
200 => status_history_response(),
|
||||||
|
404 => Operation.response("Not Found", "application/json", ApiError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def show_source_operation do
|
||||||
|
%Operation{
|
||||||
|
tags: ["Retrieve status source"],
|
||||||
|
summary: "Status source",
|
||||||
|
description: "View source of a status",
|
||||||
|
operationId: "StatusController.show_source",
|
||||||
|
security: [%{"oAuth" => ["read:statuses"]}],
|
||||||
|
parameters: [
|
||||||
|
id_param()
|
||||||
|
],
|
||||||
|
responses: %{
|
||||||
|
200 => status_source_response(),
|
||||||
|
404 => Operation.response("Not Found", "application/json", ApiError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_operation do
|
||||||
|
%Operation{
|
||||||
|
tags: ["Update status"],
|
||||||
|
summary: "Update status",
|
||||||
|
description: "Change the content of a status",
|
||||||
|
operationId: "StatusController.update",
|
||||||
|
security: [%{"oAuth" => ["write:statuses"]}],
|
||||||
|
parameters: [
|
||||||
|
id_param()
|
||||||
|
],
|
||||||
|
requestBody: request_body("Parameters", update_request(), required: true),
|
||||||
|
responses: %{
|
||||||
|
200 => status_response(),
|
||||||
|
403 => Operation.response("Forbidden", "application/json", ApiError),
|
||||||
|
404 => Operation.response("Not Found", "application/json", ApiError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def array_of_statuses do
|
def array_of_statuses do
|
||||||
%Schema{type: :array, items: Status, example: [Status.schema().example]}
|
%Schema{type: :array, items: Status, example: [Status.schema().example]}
|
||||||
end
|
end
|
||||||
|
@ -530,6 +587,60 @@ defp create_request do
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp update_request do
|
||||||
|
%Schema{
|
||||||
|
title: "StatusUpdateRequest",
|
||||||
|
type: :object,
|
||||||
|
properties: %{
|
||||||
|
status: %Schema{
|
||||||
|
type: :string,
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
"Text content of the status. If `media_ids` is provided, this becomes optional. Attaching a `poll` is optional while `status` is provided."
|
||||||
|
},
|
||||||
|
media_ids: %Schema{
|
||||||
|
nullable: true,
|
||||||
|
type: :array,
|
||||||
|
items: %Schema{type: :string},
|
||||||
|
description: "Array of Attachment ids to be attached as media."
|
||||||
|
},
|
||||||
|
poll: poll_params(),
|
||||||
|
sensitive: %Schema{
|
||||||
|
allOf: [BooleanLike],
|
||||||
|
nullable: true,
|
||||||
|
description: "Mark status and attached media as sensitive?"
|
||||||
|
},
|
||||||
|
spoiler_text: %Schema{
|
||||||
|
type: :string,
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
"Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field."
|
||||||
|
},
|
||||||
|
content_type: %Schema{
|
||||||
|
type: :string,
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
"The MIME type of the status, it is transformed into HTML by the backend. You can get the list of the supported MIME types with the nodeinfo endpoint."
|
||||||
|
},
|
||||||
|
to: %Schema{
|
||||||
|
type: :array,
|
||||||
|
nullable: true,
|
||||||
|
items: %Schema{type: :string},
|
||||||
|
description:
|
||||||
|
"A list of nicknames (like `lain@soykaf.club` or `lain` on the local server) that will be used to determine who is going to be addressed by this post. Using this will disable the implicit addressing by mentioned names in the `status` body, only the people in the `to` list will be addressed. The normal rules for for post visibility are not affected by this and will still apply"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
example: %{
|
||||||
|
"status" => "What time is it?",
|
||||||
|
"sensitive" => "false",
|
||||||
|
"poll" => %{
|
||||||
|
"options" => ["Cofe", "Adventure"],
|
||||||
|
"expires_in" => 420
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def poll_params do
|
def poll_params do
|
||||||
%Schema{
|
%Schema{
|
||||||
nullable: true,
|
nullable: true,
|
||||||
|
@ -580,6 +691,87 @@ defp status_response do
|
||||||
Operation.response("Status", "application/json", Status)
|
Operation.response("Status", "application/json", Status)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp status_history_response do
|
||||||
|
Operation.response(
|
||||||
|
"Status History",
|
||||||
|
"application/json",
|
||||||
|
%Schema{
|
||||||
|
title: "Status history",
|
||||||
|
description: "Response schema for history of a status",
|
||||||
|
type: :array,
|
||||||
|
items: %Schema{
|
||||||
|
type: :object,
|
||||||
|
properties: %{
|
||||||
|
account: %Schema{
|
||||||
|
allOf: [Account],
|
||||||
|
description: "The account that authored this status"
|
||||||
|
},
|
||||||
|
content: %Schema{
|
||||||
|
type: :string,
|
||||||
|
format: :html,
|
||||||
|
description: "HTML-encoded status content"
|
||||||
|
},
|
||||||
|
sensitive: %Schema{
|
||||||
|
type: :boolean,
|
||||||
|
description: "Is this status marked as sensitive content?"
|
||||||
|
},
|
||||||
|
spoiler_text: %Schema{
|
||||||
|
type: :string,
|
||||||
|
description:
|
||||||
|
"Subject or summary line, below which status content is collapsed until expanded"
|
||||||
|
},
|
||||||
|
created_at: %Schema{
|
||||||
|
type: :string,
|
||||||
|
format: "date-time",
|
||||||
|
description: "The date when this status was created"
|
||||||
|
},
|
||||||
|
media_attachments: %Schema{
|
||||||
|
type: :array,
|
||||||
|
items: Attachment,
|
||||||
|
description: "Media that is attached to this status"
|
||||||
|
},
|
||||||
|
emojis: %Schema{
|
||||||
|
type: :array,
|
||||||
|
items: Emoji,
|
||||||
|
description: "Custom emoji to be used when rendering status content"
|
||||||
|
},
|
||||||
|
poll: %Schema{
|
||||||
|
allOf: [Poll],
|
||||||
|
nullable: true,
|
||||||
|
description: "The poll attached to the status"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp status_source_response do
|
||||||
|
Operation.response(
|
||||||
|
"Status Source",
|
||||||
|
"application/json",
|
||||||
|
%Schema{
|
||||||
|
type: :object,
|
||||||
|
properties: %{
|
||||||
|
id: FlakeID,
|
||||||
|
text: %Schema{
|
||||||
|
type: :string,
|
||||||
|
description: "Raw source of status content"
|
||||||
|
},
|
||||||
|
spoiler_text: %Schema{
|
||||||
|
type: :string,
|
||||||
|
description:
|
||||||
|
"Subject or summary line, below which status content is collapsed until expanded"
|
||||||
|
},
|
||||||
|
content_type: %Schema{
|
||||||
|
type: :string,
|
||||||
|
description: "The content type of the source"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
defp context do
|
defp context do
|
||||||
%Schema{
|
%Schema{
|
||||||
title: "StatusContext",
|
title: "StatusContext",
|
||||||
|
|
|
@ -73,6 +73,12 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do
|
||||||
format: "date-time",
|
format: "date-time",
|
||||||
description: "The date when this status was created"
|
description: "The date when this status was created"
|
||||||
},
|
},
|
||||||
|
edited_at: %Schema{
|
||||||
|
type: :string,
|
||||||
|
format: "date-time",
|
||||||
|
nullable: true,
|
||||||
|
description: "The date when this status was last edited"
|
||||||
|
},
|
||||||
emojis: %Schema{
|
emojis: %Schema{
|
||||||
type: :array,
|
type: :array,
|
||||||
items: Emoji,
|
items: Emoji,
|
||||||
|
|
|
@ -347,6 +347,41 @@ def post(user, %{status: _} = data) do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def update(user, orig_activity, changes) do
|
||||||
|
with orig_object <- Object.normalize(orig_activity),
|
||||||
|
{:ok, new_object} <- make_update_data(user, orig_object, changes),
|
||||||
|
{:ok, update_data, _} <- Builder.update(user, new_object),
|
||||||
|
{:ok, update, _} <- Pipeline.common_pipeline(update_data, local: true) do
|
||||||
|
{:ok, update}
|
||||||
|
else
|
||||||
|
_ -> {:error, nil}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp make_update_data(user, orig_object, changes) do
|
||||||
|
kept_params = %{
|
||||||
|
visibility: Visibility.get_visibility(orig_object),
|
||||||
|
in_reply_to_id:
|
||||||
|
with replied_id when is_binary(replied_id) <- orig_object.data["inReplyTo"],
|
||||||
|
%Activity{id: activity_id} <- Activity.get_create_by_object_ap_id(replied_id) do
|
||||||
|
activity_id
|
||||||
|
else
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
}
|
||||||
|
|
||||||
|
params = Map.merge(changes, kept_params)
|
||||||
|
|
||||||
|
with {:ok, draft} <- ActivityDraft.create(user, params) do
|
||||||
|
change =
|
||||||
|
Object.Updater.make_update_object_data(orig_object.data, draft.object, Utils.make_date())
|
||||||
|
|
||||||
|
{:ok, change}
|
||||||
|
else
|
||||||
|
_ -> {:error, nil}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@spec pin(String.t(), User.t()) :: {:ok, Activity.t()} | {:error, term()}
|
@spec pin(String.t(), User.t()) :: {:ok, Activity.t()} | {:error, term()}
|
||||||
def pin(id, %User{} = user) do
|
def pin(id, %User{} = user) do
|
||||||
with %Activity{} = activity <- create_activity_by_id(id),
|
with %Activity{} = activity <- create_activity_by_id(id),
|
||||||
|
|
|
@ -221,7 +221,7 @@ defp object(draft) do
|
||||||
|> Map.put("emoji", emoji)
|
|> Map.put("emoji", emoji)
|
||||||
|> Map.put("source", %{
|
|> Map.put("source", %{
|
||||||
"content" => draft.status,
|
"content" => draft.status,
|
||||||
"mediaType" => draft.params[:content_type]
|
"mediaType" => Utils.get_content_type(draft.params[:content_type])
|
||||||
})
|
})
|
||||||
|> Map.put("generator", draft.params[:generator])
|
|> Map.put("generator", draft.params[:generator])
|
||||||
|
|
||||||
|
|
|
@ -37,7 +37,7 @@ def attachments_from_ids_no_descs([]), do: []
|
||||||
|
|
||||||
def attachments_from_ids_no_descs(ids) do
|
def attachments_from_ids_no_descs(ids) do
|
||||||
Enum.map(ids, fn media_id ->
|
Enum.map(ids, fn media_id ->
|
||||||
case Repo.get(Object, media_id) do
|
case get_attachment(media_id) do
|
||||||
%Object{data: data} -> data
|
%Object{data: data} -> data
|
||||||
_ -> nil
|
_ -> nil
|
||||||
end
|
end
|
||||||
|
@ -51,13 +51,17 @@ def attachments_from_ids_descs(ids, descs_str) do
|
||||||
{_, descs} = Jason.decode(descs_str)
|
{_, descs} = Jason.decode(descs_str)
|
||||||
|
|
||||||
Enum.map(ids, fn media_id ->
|
Enum.map(ids, fn media_id ->
|
||||||
with %Object{data: data} <- Repo.get(Object, media_id) do
|
with %Object{data: data} <- get_attachment(media_id) do
|
||||||
Map.put(data, "name", descs[media_id])
|
Map.put(data, "name", descs[media_id])
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|> Enum.reject(&is_nil/1)
|
|> Enum.reject(&is_nil/1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp get_attachment(media_id) do
|
||||||
|
Repo.get(Object, media_id)
|
||||||
|
end
|
||||||
|
|
||||||
@spec get_to_and_cc(ActivityDraft.t()) :: {list(String.t()), list(String.t())}
|
@spec get_to_and_cc(ActivityDraft.t()) :: {list(String.t()), list(String.t())}
|
||||||
|
|
||||||
def get_to_and_cc(%{in_reply_to_conversation: %Participation{} = participation}) do
|
def get_to_and_cc(%{in_reply_to_conversation: %Participation{} = participation}) do
|
||||||
|
@ -219,7 +223,7 @@ def make_content_html(%ActivityDraft{} = draft) do
|
||||||
|> maybe_add_attachments(draft.attachments, attachment_links)
|
|> maybe_add_attachments(draft.attachments, attachment_links)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_content_type(content_type) do
|
def get_content_type(content_type) do
|
||||||
if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do
|
if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do
|
||||||
content_type
|
content_type
|
||||||
else
|
else
|
||||||
|
|
|
@ -51,6 +51,7 @@ def index(conn, %{account_id: account_id} = params) do
|
||||||
move
|
move
|
||||||
pleroma:emoji_reaction
|
pleroma:emoji_reaction
|
||||||
poll
|
poll
|
||||||
|
update
|
||||||
}
|
}
|
||||||
def index(%{assigns: %{user: user}} = conn, params) do
|
def index(%{assigns: %{user: user}} = conn, params) do
|
||||||
params =
|
params =
|
||||||
|
|
|
@ -40,7 +40,9 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
|
||||||
:index,
|
:index,
|
||||||
:show,
|
:show,
|
||||||
:context,
|
:context,
|
||||||
:translate
|
:translate,
|
||||||
|
:show_history,
|
||||||
|
:show_source
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -51,7 +53,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
|
||||||
:create,
|
:create,
|
||||||
:delete,
|
:delete,
|
||||||
:reblog,
|
:reblog,
|
||||||
:unreblog
|
:unreblog,
|
||||||
|
:update
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -193,6 +196,59 @@ def create(%{assigns: %{user: _user}, body_params: %{media_ids: _} = params} = c
|
||||||
create(%Plug.Conn{conn | body_params: params}, %{})
|
create(%Plug.Conn{conn | body_params: params}, %{})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc "GET /api/v1/statuses/:id/history"
|
||||||
|
def show_history(%{assigns: assigns} = conn, %{id: id} = params) do
|
||||||
|
with user = assigns[:user],
|
||||||
|
%Activity{} = activity <- Activity.get_by_id_with_object(id),
|
||||||
|
true <- Visibility.visible_for_user?(activity, user) do
|
||||||
|
try_render(conn, "history.json",
|
||||||
|
activity: activity,
|
||||||
|
for: user,
|
||||||
|
with_direct_conversation_id: true,
|
||||||
|
with_muted: Map.get(params, :with_muted, false)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
_ -> {:error, :not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "GET /api/v1/statuses/:id/source"
|
||||||
|
def show_source(%{assigns: assigns} = conn, %{id: id} = _params) do
|
||||||
|
with user = assigns[:user],
|
||||||
|
%Activity{} = activity <- Activity.get_by_id_with_object(id),
|
||||||
|
true <- Visibility.visible_for_user?(activity, user) do
|
||||||
|
try_render(conn, "source.json",
|
||||||
|
activity: activity,
|
||||||
|
for: user
|
||||||
|
)
|
||||||
|
else
|
||||||
|
_ -> {:error, :not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "PUT /api/v1/statuses/:id"
|
||||||
|
def update(%{assigns: %{user: user}, body_params: body_params} = conn, %{id: id} = params) do
|
||||||
|
with {_, %Activity{}} = {_, activity} <- {:activity, Activity.get_by_id_with_object(id)},
|
||||||
|
{_, true} <- {:visible, Visibility.visible_for_user?(activity, user)},
|
||||||
|
{_, true} <- {:is_create, activity.data["type"] == "Create"},
|
||||||
|
actor <- Activity.user_actor(activity),
|
||||||
|
{_, true} <- {:own_status, actor.id == user.id},
|
||||||
|
changes <- body_params |> put_application(conn),
|
||||||
|
{_, {:ok, _update_activity}} <- {:pipeline, CommonAPI.update(user, activity, changes)},
|
||||||
|
{_, %Activity{}} = {_, activity} <- {:refetched, Activity.get_by_id_with_object(id)} do
|
||||||
|
try_render(conn, "show.json",
|
||||||
|
activity: activity,
|
||||||
|
for: user,
|
||||||
|
with_direct_conversation_id: true,
|
||||||
|
with_muted: Map.get(params, :with_muted, false)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
{:own_status, _} -> {:error, :forbidden}
|
||||||
|
{:pipeline, _} -> {:error, :internal_server_error}
|
||||||
|
_ -> {:error, :not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@doc "GET /api/v1/statuses/:id"
|
@doc "GET /api/v1/statuses/:id"
|
||||||
def show(%{assigns: %{user: user}} = conn, %{id: id} = params) do
|
def show(%{assigns: %{user: user}} = conn, %{id: id} = params) do
|
||||||
with %Activity{} = activity <- Activity.get_by_id_with_object(id),
|
with %Activity{} = activity <- Activity.get_by_id_with_object(id),
|
||||||
|
|
|
@ -65,6 +65,7 @@ def features do
|
||||||
"shareable_emoji_packs",
|
"shareable_emoji_packs",
|
||||||
"multifetch",
|
"multifetch",
|
||||||
"pleroma:api/v1/notifications:include_types_filter",
|
"pleroma:api/v1/notifications:include_types_filter",
|
||||||
|
"editing",
|
||||||
if Config.get([:media_proxy, :enabled]) do
|
if Config.get([:media_proxy, :enabled]) do
|
||||||
"media_proxy"
|
"media_proxy"
|
||||||
end,
|
end,
|
||||||
|
|
|
@ -17,7 +17,11 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
|
||||||
alias Pleroma.Web.MastodonAPI.NotificationView
|
alias Pleroma.Web.MastodonAPI.NotificationView
|
||||||
alias Pleroma.Web.MastodonAPI.StatusView
|
alias Pleroma.Web.MastodonAPI.StatusView
|
||||||
|
|
||||||
@parent_types ~w{Like Announce EmojiReact}
|
defp object_id_for(%{data: %{"object" => %{"id" => id}}}) when is_binary(id), do: id
|
||||||
|
|
||||||
|
defp object_id_for(%{data: %{"object" => id}}) when is_binary(id), do: id
|
||||||
|
|
||||||
|
@parent_types ~w{Like Announce EmojiReact Update}
|
||||||
|
|
||||||
def render("index.json", %{notifications: notifications, for: reading_user} = opts) do
|
def render("index.json", %{notifications: notifications, for: reading_user} = opts) do
|
||||||
activities = Enum.map(notifications, & &1.activity)
|
activities = Enum.map(notifications, & &1.activity)
|
||||||
|
@ -28,7 +32,7 @@ def render("index.json", %{notifications: notifications, for: reading_user} = op
|
||||||
%{data: %{"type" => type}} ->
|
%{data: %{"type" => type}} ->
|
||||||
type in @parent_types
|
type in @parent_types
|
||||||
end)
|
end)
|
||||||
|> Enum.map(& &1.data["object"])
|
|> Enum.map(&object_id_for/1)
|
||||||
|> Activity.create_by_object_ap_id()
|
|> Activity.create_by_object_ap_id()
|
||||||
|> Activity.with_preloaded_object(:left)
|
|> Activity.with_preloaded_object(:left)
|
||||||
|> Pleroma.Repo.all()
|
|> Pleroma.Repo.all()
|
||||||
|
@ -76,9 +80,9 @@ def render(
|
||||||
|
|
||||||
parent_activity_fn = fn ->
|
parent_activity_fn = fn ->
|
||||||
if opts[:parent_activities] do
|
if opts[:parent_activities] do
|
||||||
Activity.Queries.find_by_object_ap_id(opts[:parent_activities], activity.data["object"])
|
Activity.Queries.find_by_object_ap_id(opts[:parent_activities], object_id_for(activity))
|
||||||
else
|
else
|
||||||
Activity.get_create_by_object_ap_id(activity.data["object"])
|
Activity.get_create_by_object_ap_id(object_id_for(activity))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -107,6 +111,9 @@ def render(
|
||||||
"reblog" ->
|
"reblog" ->
|
||||||
put_status(response, parent_activity_fn.(), reading_user, status_render_opts)
|
put_status(response, parent_activity_fn.(), reading_user, status_render_opts)
|
||||||
|
|
||||||
|
"update" ->
|
||||||
|
put_status(response, parent_activity_fn.(), reading_user, status_render_opts)
|
||||||
|
|
||||||
"move" ->
|
"move" ->
|
||||||
put_target(response, activity, reading_user, %{})
|
put_target(response, activity, reading_user, %{})
|
||||||
|
|
||||||
|
|
|
@ -265,10 +265,30 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity}
|
||||||
|
|
||||||
created_at = Utils.to_masto_date(object.data["published"])
|
created_at = Utils.to_masto_date(object.data["published"])
|
||||||
|
|
||||||
|
edited_at =
|
||||||
|
with %{"updated" => updated} <- object.data,
|
||||||
|
date <- Utils.to_masto_date(updated),
|
||||||
|
true <- date != "" do
|
||||||
|
date
|
||||||
|
else
|
||||||
|
_ ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
reply_to = get_reply_to(activity, opts)
|
reply_to = get_reply_to(activity, opts)
|
||||||
|
|
||||||
reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
|
reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
|
||||||
|
|
||||||
|
history_len =
|
||||||
|
1 +
|
||||||
|
(Object.Updater.history_for(object.data)
|
||||||
|
|> Map.get("orderedItems")
|
||||||
|
|> length())
|
||||||
|
|
||||||
|
# See render("history.json", ...) for more details
|
||||||
|
# Here the implicit index of the current content is 0
|
||||||
|
chrono_order = history_len - 1
|
||||||
|
|
||||||
content =
|
content =
|
||||||
object
|
object
|
||||||
|> render_content()
|
|> render_content()
|
||||||
|
@ -278,14 +298,14 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity}
|
||||||
|> Activity.HTML.get_cached_scrubbed_html_for_activity(
|
|> Activity.HTML.get_cached_scrubbed_html_for_activity(
|
||||||
User.html_filter_policy(opts[:for]),
|
User.html_filter_policy(opts[:for]),
|
||||||
activity,
|
activity,
|
||||||
"mastoapi:content"
|
"mastoapi:content:#{chrono_order}"
|
||||||
)
|
)
|
||||||
|
|
||||||
content_plaintext =
|
content_plaintext =
|
||||||
content
|
content
|
||||||
|> Activity.HTML.get_cached_stripped_html_for_activity(
|
|> Activity.HTML.get_cached_stripped_html_for_activity(
|
||||||
activity,
|
activity,
|
||||||
"mastoapi:content"
|
"mastoapi:content:#{chrono_order}"
|
||||||
)
|
)
|
||||||
|
|
||||||
summary = object.data["summary"] || ""
|
summary = object.data["summary"] || ""
|
||||||
|
@ -353,8 +373,9 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity}
|
||||||
reblog: nil,
|
reblog: nil,
|
||||||
card: card,
|
card: card,
|
||||||
content: content_html,
|
content: content_html,
|
||||||
text: opts[:with_source] && object.data["source"],
|
text: opts[:with_source] && get_source_text(object.data["source"]),
|
||||||
created_at: created_at,
|
created_at: created_at,
|
||||||
|
edited_at: edited_at,
|
||||||
reblogs_count: announcement_count,
|
reblogs_count: announcement_count,
|
||||||
replies_count: object.data["repliesCount"] || 0,
|
replies_count: object.data["repliesCount"] || 0,
|
||||||
favourites_count: like_count,
|
favourites_count: like_count,
|
||||||
|
@ -400,6 +421,100 @@ def render("show.json", _) do
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def render("history.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
|
||||||
|
object = Object.normalize(activity, fetch: false)
|
||||||
|
|
||||||
|
hashtags = Object.hashtags(object)
|
||||||
|
|
||||||
|
user = CommonAPI.get_user(activity.data["actor"])
|
||||||
|
|
||||||
|
past_history =
|
||||||
|
Object.Updater.history_for(object.data)
|
||||||
|
|> Map.get("orderedItems")
|
||||||
|
|> Enum.map(&Map.put(&1, "id", object.data["id"]))
|
||||||
|
|> Enum.map(&%Object{data: &1, id: object.id})
|
||||||
|
|
||||||
|
history =
|
||||||
|
[object | past_history]
|
||||||
|
# Mastodon expects the original to be at the first
|
||||||
|
|> Enum.reverse()
|
||||||
|
|> Enum.with_index()
|
||||||
|
|> Enum.map(fn {object, chrono_order} ->
|
||||||
|
%{
|
||||||
|
# The history is prepended every time there is a new edit.
|
||||||
|
# In chrono_order, the oldest item is always at 0, and so on.
|
||||||
|
# The chrono_order is an invariant kept between edits.
|
||||||
|
chrono_order: chrono_order,
|
||||||
|
object: object
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
individual_opts =
|
||||||
|
opts
|
||||||
|
|> Map.put(:as, :item)
|
||||||
|
|> Map.put(:user, user)
|
||||||
|
|> Map.put(:hashtags, hashtags)
|
||||||
|
|
||||||
|
render_many(history, StatusView, "history_item.json", individual_opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
def render(
|
||||||
|
"history_item.json",
|
||||||
|
%{
|
||||||
|
activity: activity,
|
||||||
|
user: user,
|
||||||
|
item: %{object: object, chrono_order: chrono_order},
|
||||||
|
hashtags: hashtags
|
||||||
|
} = opts
|
||||||
|
) do
|
||||||
|
sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
|
||||||
|
|
||||||
|
attachment_data = object.data["attachment"] || []
|
||||||
|
attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
|
||||||
|
|
||||||
|
created_at = Utils.to_masto_date(object.data["updated"] || object.data["published"])
|
||||||
|
|
||||||
|
content =
|
||||||
|
object
|
||||||
|
|> render_content()
|
||||||
|
|
||||||
|
content_html =
|
||||||
|
content
|
||||||
|
|> Activity.HTML.get_cached_scrubbed_html_for_activity(
|
||||||
|
User.html_filter_policy(opts[:for]),
|
||||||
|
activity,
|
||||||
|
"mastoapi:content:#{chrono_order}"
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = object.data["summary"] || ""
|
||||||
|
|
||||||
|
%{
|
||||||
|
account:
|
||||||
|
AccountView.render("show.json", %{
|
||||||
|
user: user,
|
||||||
|
for: opts[:for]
|
||||||
|
}),
|
||||||
|
content: content_html,
|
||||||
|
sensitive: sensitive,
|
||||||
|
spoiler_text: summary,
|
||||||
|
created_at: created_at,
|
||||||
|
media_attachments: attachments,
|
||||||
|
emojis: build_emojis(object.data["emoji"]),
|
||||||
|
poll: render(PollView, "show.json", object: object, for: opts[:for])
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render("source.json", %{activity: %{data: %{"object" => _object}} = activity} = _opts) do
|
||||||
|
object = Object.normalize(activity, fetch: false)
|
||||||
|
|
||||||
|
%{
|
||||||
|
id: activity.id,
|
||||||
|
text: get_source_text(Map.get(object.data, "source", "")),
|
||||||
|
spoiler_text: Map.get(object.data, "summary", ""),
|
||||||
|
content_type: get_source_content_type(object.data["source"])
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
|
def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
|
||||||
page_url_data = URI.parse(page_url)
|
page_url_data = URI.parse(page_url)
|
||||||
|
|
||||||
|
@ -452,10 +567,19 @@ def render("attachment.json", %{attachment: attachment}) do
|
||||||
true -> "unknown"
|
true -> "unknown"
|
||||||
end
|
end
|
||||||
|
|
||||||
<<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
|
attachment_id =
|
||||||
|
with {_, ap_id} when is_binary(ap_id) <- {:ap_id, attachment["id"]},
|
||||||
|
{_, %Object{data: _object_data, id: object_id}} <-
|
||||||
|
{:object, Object.get_by_ap_id(ap_id)} do
|
||||||
|
to_string(object_id)
|
||||||
|
else
|
||||||
|
_ ->
|
||||||
|
<<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
|
||||||
|
to_string(attachment["id"] || hash_id)
|
||||||
|
end
|
||||||
|
|
||||||
%{
|
%{
|
||||||
id: to_string(attachment["id"] || hash_id),
|
id: attachment_id,
|
||||||
url: href,
|
url: href,
|
||||||
remote_url: href,
|
remote_url: href,
|
||||||
preview_url: href_preview,
|
preview_url: href_preview,
|
||||||
|
@ -638,4 +762,24 @@ defp maybe_render_quote(quote, opts) do
|
||||||
_ -> nil
|
_ -> nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp get_source_text(%{"content" => content} = _source) do
|
||||||
|
content
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_source_text(source) when is_binary(source) do
|
||||||
|
source
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_source_text(_) do
|
||||||
|
""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_source_content_type(%{"mediaType" => type} = _source) do
|
||||||
|
type
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_source_content_type(_source) do
|
||||||
|
Utils.get_content_type(nil)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -547,6 +547,7 @@ defmodule Pleroma.Web.Router do
|
||||||
get("/bookmarks", StatusController, :bookmarks)
|
get("/bookmarks", StatusController, :bookmarks)
|
||||||
|
|
||||||
post("/statuses", StatusController, :create)
|
post("/statuses", StatusController, :create)
|
||||||
|
put("/statuses/:id", StatusController, :update)
|
||||||
delete("/statuses/:id", StatusController, :delete)
|
delete("/statuses/:id", StatusController, :delete)
|
||||||
post("/statuses/:id/reblog", StatusController, :reblog)
|
post("/statuses/:id/reblog", StatusController, :reblog)
|
||||||
post("/statuses/:id/unreblog", StatusController, :unreblog)
|
post("/statuses/:id/unreblog", StatusController, :unreblog)
|
||||||
|
@ -612,6 +613,8 @@ defmodule Pleroma.Web.Router do
|
||||||
get("/statuses/:id/context", StatusController, :context)
|
get("/statuses/:id/context", StatusController, :context)
|
||||||
get("/statuses/:id/favourited_by", StatusController, :favourited_by)
|
get("/statuses/:id/favourited_by", StatusController, :favourited_by)
|
||||||
get("/statuses/:id/reblogged_by", StatusController, :reblogged_by)
|
get("/statuses/:id/reblogged_by", StatusController, :reblogged_by)
|
||||||
|
get("/statuses/:id/history", StatusController, :show_history)
|
||||||
|
get("/statuses/:id/source", StatusController, :show_source)
|
||||||
|
|
||||||
get("/custom_emojis", CustomEmojiController, :index)
|
get("/custom_emojis", CustomEmojiController, :index)
|
||||||
|
|
||||||
|
|
|
@ -287,6 +287,27 @@ defp push_to_socket(topic, %Activity{
|
||||||
|
|
||||||
defp push_to_socket(_topic, %Activity{data: %{"type" => "Delete"}}), do: :noop
|
defp push_to_socket(_topic, %Activity{data: %{"type" => "Delete"}}), do: :noop
|
||||||
|
|
||||||
|
defp push_to_socket(topic, %Activity{data: %{"type" => "Update"}} = item) do
|
||||||
|
create_activity =
|
||||||
|
Pleroma.Activity.get_create_by_object_ap_id(item.object.data["id"])
|
||||||
|
|> Map.put(:object, item.object)
|
||||||
|
|
||||||
|
anon_render = StreamerView.render("status_update.json", create_activity, topic)
|
||||||
|
|
||||||
|
Registry.dispatch(@registry, topic, fn list ->
|
||||||
|
Enum.each(list, fn {pid, auth?} ->
|
||||||
|
if auth? do
|
||||||
|
send(
|
||||||
|
pid,
|
||||||
|
{:render_with_user, StreamerView, "status_update.json", create_activity, topic}
|
||||||
|
)
|
||||||
|
else
|
||||||
|
send(pid, {:text, anon_render})
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
defp push_to_socket(topic, item) do
|
defp push_to_socket(topic, item) do
|
||||||
anon_render = StreamerView.render("update.json", item, topic)
|
anon_render = StreamerView.render("update.json", item, topic)
|
||||||
|
|
||||||
|
|
|
@ -26,6 +26,23 @@ def render("update.json", %Activity{} = activity, %User{} = user, topic) do
|
||||||
|> Jason.encode!()
|
|> Jason.encode!()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def render("status_update.json", %Activity{} = activity, %User{} = user, topic) do
|
||||||
|
activity = Activity.get_create_by_object_ap_id_with_object(activity.object.data["id"])
|
||||||
|
|
||||||
|
%{
|
||||||
|
stream: [topic],
|
||||||
|
event: "status.update",
|
||||||
|
payload:
|
||||||
|
Pleroma.Web.MastodonAPI.StatusView.render(
|
||||||
|
"show.json",
|
||||||
|
activity: activity,
|
||||||
|
for: user
|
||||||
|
)
|
||||||
|
|> Jason.encode!()
|
||||||
|
}
|
||||||
|
|> Jason.encode!()
|
||||||
|
end
|
||||||
|
|
||||||
def render("notification.json", %Notification{} = notify, %User{} = user, topic) do
|
def render("notification.json", %Notification{} = notify, %User{} = user, topic) do
|
||||||
%{
|
%{
|
||||||
stream: [topic],
|
stream: [topic],
|
||||||
|
@ -54,6 +71,22 @@ def render("update.json", %Activity{} = activity, topic) do
|
||||||
|> Jason.encode!()
|
|> Jason.encode!()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def render("status_update.json", %Activity{} = activity, topic) do
|
||||||
|
activity = Activity.get_create_by_object_ap_id_with_object(activity.object.data["id"])
|
||||||
|
|
||||||
|
%{
|
||||||
|
stream: [topic],
|
||||||
|
event: "status.update",
|
||||||
|
payload:
|
||||||
|
Pleroma.Web.MastodonAPI.StatusView.render(
|
||||||
|
"show.json",
|
||||||
|
activity: activity
|
||||||
|
)
|
||||||
|
|> Jason.encode!()
|
||||||
|
}
|
||||||
|
|> Jason.encode!()
|
||||||
|
end
|
||||||
|
|
||||||
def render("follow_relationships_update.json", item, topic) do
|
def render("follow_relationships_update.json", item, topic) do
|
||||||
%{
|
%{
|
||||||
stream: [topic],
|
stream: [topic],
|
||||||
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
defmodule Pleroma.Repo.Migrations.AddUpdateToNotificationsEnum do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
@disable_ddl_transaction true
|
||||||
|
|
||||||
|
def up do
|
||||||
|
"""
|
||||||
|
alter type notification_type add value 'update'
|
||||||
|
"""
|
||||||
|
|> execute()
|
||||||
|
end
|
||||||
|
|
||||||
|
# 20210717000000_add_poll_to_notifications_enum.exs
|
||||||
|
def down do
|
||||||
|
alter table(:notifications) do
|
||||||
|
modify(:type, :string)
|
||||||
|
end
|
||||||
|
|
||||||
|
"""
|
||||||
|
delete from notifications where type = 'update'
|
||||||
|
"""
|
||||||
|
|> execute()
|
||||||
|
|
||||||
|
"""
|
||||||
|
drop type if exists notification_type
|
||||||
|
"""
|
||||||
|
|> execute()
|
||||||
|
|
||||||
|
"""
|
||||||
|
create type notification_type as enum (
|
||||||
|
'follow',
|
||||||
|
'follow_request',
|
||||||
|
'mention',
|
||||||
|
'move',
|
||||||
|
'pleroma:emoji_reaction',
|
||||||
|
'pleroma:chat_mention',
|
||||||
|
'reblog',
|
||||||
|
'favourite',
|
||||||
|
'pleroma:report',
|
||||||
|
'poll'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|> execute()
|
||||||
|
|
||||||
|
"""
|
||||||
|
alter table notifications
|
||||||
|
alter column type type notification_type using (type::notification_type)
|
||||||
|
"""
|
||||||
|
|> execute()
|
||||||
|
end
|
||||||
|
end
|
|
@ -39,7 +39,9 @@
|
||||||
"alsoKnownAs": {
|
"alsoKnownAs": {
|
||||||
"@id": "as:alsoKnownAs",
|
"@id": "as:alsoKnownAs",
|
||||||
"@type": "@id"
|
"@type": "@id"
|
||||||
}
|
},
|
||||||
|
"vcard": "http://www.w3.org/2006/vcard/ns#",
|
||||||
|
"formerRepresentations": "litepub:formerRepresentations"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -127,6 +127,28 @@ test "does not create a notification for subscribed users if status is a reply"
|
||||||
subscriber_notifications = Notification.for_user(subscriber)
|
subscriber_notifications = Notification.for_user(subscriber)
|
||||||
assert Enum.empty?(subscriber_notifications)
|
assert Enum.empty?(subscriber_notifications)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "it sends edited notifications to those who repeated a status" do
|
||||||
|
user = insert(:user)
|
||||||
|
repeated_user = insert(:user)
|
||||||
|
other_user = insert(:user)
|
||||||
|
|
||||||
|
{:ok, activity_one} =
|
||||||
|
CommonAPI.post(user, %{
|
||||||
|
status: "hey @#{other_user.nickname}!"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, _activity_two} = CommonAPI.repeat(activity_one.id, repeated_user)
|
||||||
|
|
||||||
|
{:ok, _edit_activity} =
|
||||||
|
CommonAPI.update(user, activity_one, %{
|
||||||
|
status: "hey @#{other_user.nickname}! mew mew"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert [%{type: "reblog"}] = Notification.for_user(user)
|
||||||
|
assert [%{type: "update"}] = Notification.for_user(repeated_user)
|
||||||
|
assert [%{type: "mention"}] = Notification.for_user(other_user)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "create_poll_notifications/1" do
|
test "create_poll_notifications/1" do
|
||||||
|
@ -838,6 +860,30 @@ test "it returns following domain-blocking recipient in enabled recipients list"
|
||||||
assert [other_user] == enabled_receivers
|
assert [other_user] == enabled_receivers
|
||||||
assert [] == disabled_receivers
|
assert [] == disabled_receivers
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "it sends edited notifications to those who repeated a status" do
|
||||||
|
user = insert(:user)
|
||||||
|
repeated_user = insert(:user)
|
||||||
|
other_user = insert(:user)
|
||||||
|
|
||||||
|
{:ok, activity_one} =
|
||||||
|
CommonAPI.post(user, %{
|
||||||
|
status: "hey @#{other_user.nickname}!"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, _activity_two} = CommonAPI.repeat(activity_one.id, repeated_user)
|
||||||
|
|
||||||
|
{:ok, edit_activity} =
|
||||||
|
CommonAPI.update(user, activity_one, %{
|
||||||
|
status: "hey @#{other_user.nickname}! mew mew"
|
||||||
|
})
|
||||||
|
|
||||||
|
{enabled_receivers, _disabled_receivers} =
|
||||||
|
Notification.get_notified_from_activity(edit_activity)
|
||||||
|
|
||||||
|
assert repeated_user in enabled_receivers
|
||||||
|
assert other_user not in enabled_receivers
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "notification lifecycle" do
|
describe "notification lifecycle" do
|
||||||
|
|
|
@ -269,4 +269,271 @@ test "it can refetch pruned objects" do
|
||||||
refute called(Pleroma.Signature.sign(:_, :_))
|
refute called(Pleroma.Signature.sign(:_, :_))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "refetching" do
|
||||||
|
setup do
|
||||||
|
object1 = %{
|
||||||
|
"id" => "https://mastodon.social/1",
|
||||||
|
"actor" => "https://mastodon.social/users/emelie",
|
||||||
|
"attributedTo" => "https://mastodon.social/users/emelie",
|
||||||
|
"type" => "Note",
|
||||||
|
"content" => "test 1",
|
||||||
|
"bcc" => [],
|
||||||
|
"bto" => [],
|
||||||
|
"cc" => [],
|
||||||
|
"to" => [],
|
||||||
|
"summary" => ""
|
||||||
|
}
|
||||||
|
|
||||||
|
object2 = %{
|
||||||
|
"id" => "https://mastodon.social/2",
|
||||||
|
"actor" => "https://mastodon.social/users/emelie",
|
||||||
|
"attributedTo" => "https://mastodon.social/users/emelie",
|
||||||
|
"type" => "Note",
|
||||||
|
"content" => "test 2",
|
||||||
|
"bcc" => [],
|
||||||
|
"bto" => [],
|
||||||
|
"cc" => [],
|
||||||
|
"to" => [],
|
||||||
|
"summary" => "",
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"type" => "OrderedCollection",
|
||||||
|
"orderedItems" => [
|
||||||
|
%{
|
||||||
|
"type" => "Note",
|
||||||
|
"content" => "orig 2",
|
||||||
|
"actor" => "https://mastodon.social/users/emelie",
|
||||||
|
"attributedTo" => "https://mastodon.social/users/emelie",
|
||||||
|
"bcc" => [],
|
||||||
|
"bto" => [],
|
||||||
|
"cc" => [],
|
||||||
|
"to" => [],
|
||||||
|
"summary" => ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalItems" => 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mock(fn
|
||||||
|
%{
|
||||||
|
method: :get,
|
||||||
|
url: "https://mastodon.social/1"
|
||||||
|
} ->
|
||||||
|
%Tesla.Env{
|
||||||
|
status: 200,
|
||||||
|
headers: [{"content-type", "application/activity+json"}],
|
||||||
|
body: Jason.encode!(object1)
|
||||||
|
}
|
||||||
|
|
||||||
|
%{
|
||||||
|
method: :get,
|
||||||
|
url: "https://mastodon.social/2"
|
||||||
|
} ->
|
||||||
|
%Tesla.Env{
|
||||||
|
status: 200,
|
||||||
|
headers: [{"content-type", "application/activity+json"}],
|
||||||
|
body: Jason.encode!(object2)
|
||||||
|
}
|
||||||
|
|
||||||
|
%{
|
||||||
|
method: :get,
|
||||||
|
url: "https://mastodon.social/users/emelie/collections/featured"
|
||||||
|
} ->
|
||||||
|
%Tesla.Env{
|
||||||
|
status: 200,
|
||||||
|
headers: [{"content-type", "application/activity+json"}],
|
||||||
|
body:
|
||||||
|
Jason.encode!(%{
|
||||||
|
"id" => "https://mastodon.social/users/emelie/collections/featured",
|
||||||
|
"type" => "OrderedCollection",
|
||||||
|
"actor" => "https://mastodon.social/users/emelie",
|
||||||
|
"attributedTo" => "https://mastodon.social/users/emelie",
|
||||||
|
"orderedItems" => [],
|
||||||
|
"totalItems" => 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
env ->
|
||||||
|
apply(HttpRequestMock, :request, [env])
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{object1: object1, object2: object2}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it keeps formerRepresentations if remote does not have this attr", %{object1: object1} do
|
||||||
|
full_object1 =
|
||||||
|
object1
|
||||||
|
|> Map.merge(%{
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"type" => "OrderedCollection",
|
||||||
|
"orderedItems" => [
|
||||||
|
%{
|
||||||
|
"type" => "Note",
|
||||||
|
"content" => "orig 2",
|
||||||
|
"actor" => "https://mastodon.social/users/emelie",
|
||||||
|
"attributedTo" => "https://mastodon.social/users/emelie",
|
||||||
|
"bcc" => [],
|
||||||
|
"bto" => [],
|
||||||
|
"cc" => [],
|
||||||
|
"to" => [],
|
||||||
|
"summary" => ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalItems" => 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, o} = Object.create(full_object1)
|
||||||
|
|
||||||
|
assert {:ok, refetched} = Fetcher.refetch_object(o)
|
||||||
|
|
||||||
|
assert %{"formerRepresentations" => %{"orderedItems" => [%{"content" => "orig 2"}]}} =
|
||||||
|
refetched.data
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it uses formerRepresentations from remote if possible", %{object2: object2} do
|
||||||
|
{:ok, o} = Object.create(object2)
|
||||||
|
|
||||||
|
assert {:ok, refetched} = Fetcher.refetch_object(o)
|
||||||
|
|
||||||
|
assert %{"formerRepresentations" => %{"orderedItems" => [%{"content" => "orig 2"}]}} =
|
||||||
|
refetched.data
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it replaces formerRepresentations with the one from remote", %{object2: object2} do
|
||||||
|
full_object2 =
|
||||||
|
object2
|
||||||
|
|> Map.merge(%{
|
||||||
|
"content" => "mew mew #def",
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"type" => "OrderedCollection",
|
||||||
|
"orderedItems" => [
|
||||||
|
%{"type" => "Note", "content" => "mew mew 2"}
|
||||||
|
],
|
||||||
|
"totalItems" => 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, o} = Object.create(full_object2)
|
||||||
|
|
||||||
|
assert {:ok, refetched} = Fetcher.refetch_object(o)
|
||||||
|
|
||||||
|
assert %{
|
||||||
|
"content" => "test 2",
|
||||||
|
"formerRepresentations" => %{"orderedItems" => [%{"content" => "orig 2"}]}
|
||||||
|
} = refetched.data
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it adds to formerRepresentations if the remote does not have one and the object has changed",
|
||||||
|
%{object1: object1} do
|
||||||
|
full_object1 =
|
||||||
|
object1
|
||||||
|
|> Map.merge(%{
|
||||||
|
"content" => "mew mew #def",
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"type" => "OrderedCollection",
|
||||||
|
"orderedItems" => [
|
||||||
|
%{"type" => "Note", "content" => "mew mew 1"}
|
||||||
|
],
|
||||||
|
"totalItems" => 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, o} = Object.create(full_object1)
|
||||||
|
|
||||||
|
assert {:ok, refetched} = Fetcher.refetch_object(o)
|
||||||
|
|
||||||
|
assert %{
|
||||||
|
"content" => "test 1",
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"orderedItems" => [
|
||||||
|
%{"content" => "mew mew #def"},
|
||||||
|
%{"content" => "mew mew 1"}
|
||||||
|
],
|
||||||
|
"totalItems" => 2
|
||||||
|
}
|
||||||
|
} = refetched.data
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "fetch with history" do
|
||||||
|
setup do
|
||||||
|
object2 = %{
|
||||||
|
"id" => "https://mastodon.social/2",
|
||||||
|
"actor" => "https://mastodon.social/users/emelie",
|
||||||
|
"attributedTo" => "https://mastodon.social/users/emelie",
|
||||||
|
"type" => "Note",
|
||||||
|
"content" => "test 2",
|
||||||
|
"bcc" => [],
|
||||||
|
"bto" => [],
|
||||||
|
"cc" => ["https://mastodon.social/users/emelie/followers"],
|
||||||
|
"to" => [],
|
||||||
|
"summary" => "",
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"type" => "OrderedCollection",
|
||||||
|
"orderedItems" => [
|
||||||
|
%{
|
||||||
|
"type" => "Note",
|
||||||
|
"content" => "orig 2",
|
||||||
|
"actor" => "https://mastodon.social/users/emelie",
|
||||||
|
"attributedTo" => "https://mastodon.social/users/emelie",
|
||||||
|
"bcc" => [],
|
||||||
|
"bto" => [],
|
||||||
|
"cc" => ["https://mastodon.social/users/emelie/followers"],
|
||||||
|
"to" => [],
|
||||||
|
"summary" => ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalItems" => 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mock(fn
|
||||||
|
%{
|
||||||
|
method: :get,
|
||||||
|
url: "https://mastodon.social/2"
|
||||||
|
} ->
|
||||||
|
%Tesla.Env{
|
||||||
|
status: 200,
|
||||||
|
headers: [{"content-type", "application/activity+json"}],
|
||||||
|
body: Jason.encode!(object2)
|
||||||
|
}
|
||||||
|
|
||||||
|
%{
|
||||||
|
method: :get,
|
||||||
|
url: "https://mastodon.social/users/emelie/collections/featured"
|
||||||
|
} ->
|
||||||
|
%Tesla.Env{
|
||||||
|
status: 200,
|
||||||
|
headers: [{"content-type", "application/activity+json"}],
|
||||||
|
body:
|
||||||
|
Jason.encode!(%{
|
||||||
|
"id" => "https://mastodon.social/users/emelie/collections/featured",
|
||||||
|
"type" => "OrderedCollection",
|
||||||
|
"actor" => "https://mastodon.social/users/emelie",
|
||||||
|
"attributedTo" => "https://mastodon.social/users/emelie",
|
||||||
|
"orderedItems" => [],
|
||||||
|
"totalItems" => 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
env ->
|
||||||
|
apply(HttpRequestMock, :request, [env])
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{object2: object2}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it gets history", %{object2: object2} do
|
||||||
|
{:ok, object} = Fetcher.fetch_object_from_id(object2["id"])
|
||||||
|
|
||||||
|
assert %{
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"type" => "OrderedCollection",
|
||||||
|
"orderedItems" => [%{}]
|
||||||
|
}
|
||||||
|
} = object.data
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
76
test/pleroma/object/updater_test.exs
Normal file
76
test/pleroma/object/updater_test.exs
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
# Pleroma: A lightweight social networking server
|
||||||
|
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
defmodule Pleroma.Object.UpdaterTest do
|
||||||
|
use Pleroma.DataCase
|
||||||
|
use Oban.Testing, repo: Pleroma.Repo
|
||||||
|
|
||||||
|
import Pleroma.Factory
|
||||||
|
|
||||||
|
alias Pleroma.Object.Updater
|
||||||
|
|
||||||
|
describe "make_update_object_data/3" do
|
||||||
|
setup do
|
||||||
|
note = insert(:note)
|
||||||
|
%{original_data: note.data}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it makes an updated field", %{original_data: original_data} do
|
||||||
|
new_data = Map.put(original_data, "content", "new content")
|
||||||
|
|
||||||
|
date = Pleroma.Web.ActivityPub.Utils.make_date()
|
||||||
|
update_object_data = Updater.make_update_object_data(original_data, new_data, date)
|
||||||
|
assert %{"updated" => ^date} = update_object_data
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it creates formerRepresentations", %{original_data: original_data} do
|
||||||
|
new_data = Map.put(original_data, "content", "new content")
|
||||||
|
|
||||||
|
date = Pleroma.Web.ActivityPub.Utils.make_date()
|
||||||
|
update_object_data = Updater.make_update_object_data(original_data, new_data, date)
|
||||||
|
|
||||||
|
history_item = original_data |> Map.drop(["id", "formerRepresentations"])
|
||||||
|
|
||||||
|
assert %{
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"totalItems" => 1,
|
||||||
|
"orderedItems" => [^history_item]
|
||||||
|
}
|
||||||
|
} = update_object_data
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "make_new_object_data_from_update_object/2" do
|
||||||
|
test "it reuses formerRepresentations if it exists" do
|
||||||
|
%{data: original_data} = insert(:note)
|
||||||
|
|
||||||
|
new_data =
|
||||||
|
original_data
|
||||||
|
|> Map.put("content", "edited")
|
||||||
|
|
||||||
|
date = Pleroma.Web.ActivityPub.Utils.make_date()
|
||||||
|
update_object_data = Updater.make_update_object_data(original_data, new_data, date)
|
||||||
|
|
||||||
|
history = update_object_data["formerRepresentations"]["orderedItems"]
|
||||||
|
|
||||||
|
update_object_data =
|
||||||
|
update_object_data
|
||||||
|
|> put_in(
|
||||||
|
["formerRepresentations", "orderedItems"],
|
||||||
|
history ++ [Map.put(original_data, "summary", "additional summary")]
|
||||||
|
)
|
||||||
|
|> put_in(["formerRepresentations", "totalItems"], length(history) + 1)
|
||||||
|
|
||||||
|
%{
|
||||||
|
updated_data: updated_data,
|
||||||
|
updated: updated,
|
||||||
|
used_history_in_new_object?: used_history_in_new_object?
|
||||||
|
} = Updater.make_new_object_data_from_update_object(original_data, update_object_data)
|
||||||
|
|
||||||
|
assert updated
|
||||||
|
assert used_history_in_new_object?
|
||||||
|
assert updated_data["formerRepresentations"] == update_object_data["formerRepresentations"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -49,20 +49,22 @@ def put_file(upload), do: TestUploaderBase.put_file(upload, __MODULE__)
|
||||||
test "it returns file" do
|
test "it returns file" do
|
||||||
File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg")
|
File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg")
|
||||||
|
|
||||||
assert Upload.store(@upload_file) ==
|
assert {:ok, result} = Upload.store(@upload_file)
|
||||||
{:ok,
|
|
||||||
%{
|
assert result ==
|
||||||
"name" => "image.jpg",
|
%{
|
||||||
"type" => "Document",
|
"id" => result["id"],
|
||||||
"mediaType" => "image/jpeg",
|
"name" => "image.jpg",
|
||||||
"url" => [
|
"type" => "Document",
|
||||||
%{
|
"mediaType" => "image/jpeg",
|
||||||
"href" => "http://localhost:4001/media/post-process-file.jpg",
|
"url" => [
|
||||||
"mediaType" => "image/jpeg",
|
%{
|
||||||
"type" => "Link"
|
"href" => "http://localhost:4001/media/post-process-file.jpg",
|
||||||
}
|
"mediaType" => "image/jpeg",
|
||||||
]
|
"type" => "Link"
|
||||||
}}
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
Task.await(Agent.get(TestUploaderSuccess, fn task_pid -> task_pid end))
|
Task.await(Agent.get(TestUploaderSuccess, fn task_pid -> task_pid end))
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicyTest do
|
||||||
import Pleroma.Factory
|
import Pleroma.Factory
|
||||||
import ExUnit.CaptureLog
|
import ExUnit.CaptureLog
|
||||||
|
|
||||||
|
alias Pleroma.Web.ActivityPub.MRF
|
||||||
alias Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicy
|
alias Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicy
|
||||||
|
|
||||||
@linkless_message %{
|
@linkless_message %{
|
||||||
|
@ -49,11 +50,23 @@ test "it disallows posts with links" do
|
||||||
|
|
||||||
assert user.note_count == 0
|
assert user.note_count == 0
|
||||||
|
|
||||||
message =
|
message = %{
|
||||||
@linkful_message
|
"type" => "Create",
|
||||||
|> Map.put("actor", user.ap_id)
|
"actor" => user.ap_id,
|
||||||
|
"object" => %{
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"type" => "OrderedCollection",
|
||||||
|
"orderedItems" => [
|
||||||
|
%{
|
||||||
|
"content" => "<a href='https://example.com'>hi world!</a>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"content" => "mew"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
{:reject, _} = AntiLinkSpamPolicy.filter(message)
|
{:reject, _} = MRF.filter_one(AntiLinkSpamPolicy, message)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it allows posts with links for local users" do
|
test "it allows posts with links for local users" do
|
||||||
|
@ -67,6 +80,18 @@ test "it allows posts with links for local users" do
|
||||||
|
|
||||||
{:ok, _message} = AntiLinkSpamPolicy.filter(message)
|
{:ok, _message} = AntiLinkSpamPolicy.filter(message)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "it disallows posts with links in history" do
|
||||||
|
user = insert(:user, local: false)
|
||||||
|
|
||||||
|
assert user.note_count == 0
|
||||||
|
|
||||||
|
message =
|
||||||
|
@linkful_message
|
||||||
|
|> Map.put("actor", user.ap_id)
|
||||||
|
|
||||||
|
{:reject, _} = AntiLinkSpamPolicy.filter(message)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "with old user" do
|
describe "with old user" do
|
||||||
|
|
|
@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.EnsureRePrependedTest do
|
||||||
|
|
||||||
alias Pleroma.Activity
|
alias Pleroma.Activity
|
||||||
alias Pleroma.Object
|
alias Pleroma.Object
|
||||||
|
alias Pleroma.Web.ActivityPub.MRF
|
||||||
alias Pleroma.Web.ActivityPub.MRF.EnsureRePrepended
|
alias Pleroma.Web.ActivityPub.MRF.EnsureRePrepended
|
||||||
|
|
||||||
describe "rewrites summary" do
|
describe "rewrites summary" do
|
||||||
|
@ -35,10 +36,58 @@ test "it adds `re:` to summary object when child summary containts re-subject of
|
||||||
assert {:ok, res} = EnsureRePrepended.filter(message)
|
assert {:ok, res} = EnsureRePrepended.filter(message)
|
||||||
assert res["object"]["summary"] == "re: object-summary"
|
assert res["object"]["summary"] == "re: object-summary"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "it adds `re:` to history" do
|
||||||
|
message = %{
|
||||||
|
"type" => "Create",
|
||||||
|
"object" => %{
|
||||||
|
"summary" => "object-summary",
|
||||||
|
"inReplyTo" => %Activity{object: %Object{data: %{"summary" => "object-summary"}}},
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"orderedItems" => [
|
||||||
|
%{
|
||||||
|
"summary" => "object-summary",
|
||||||
|
"inReplyTo" => %Activity{object: %Object{data: %{"summary" => "object-summary"}}}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, res} = MRF.filter_one(EnsureRePrepended, message)
|
||||||
|
assert res["object"]["summary"] == "re: object-summary"
|
||||||
|
|
||||||
|
assert Enum.at(res["object"]["formerRepresentations"]["orderedItems"], 0)["summary"] ==
|
||||||
|
"re: object-summary"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it accepts Updates" do
|
||||||
|
message = %{
|
||||||
|
"type" => "Update",
|
||||||
|
"object" => %{
|
||||||
|
"summary" => "object-summary",
|
||||||
|
"inReplyTo" => %Activity{object: %Object{data: %{"summary" => "object-summary"}}},
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"orderedItems" => [
|
||||||
|
%{
|
||||||
|
"summary" => "object-summary",
|
||||||
|
"inReplyTo" => %Activity{object: %Object{data: %{"summary" => "object-summary"}}}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, res} = MRF.filter_one(EnsureRePrepended, message)
|
||||||
|
assert res["object"]["summary"] == "re: object-summary"
|
||||||
|
|
||||||
|
assert Enum.at(res["object"]["formerRepresentations"]["orderedItems"], 0)["summary"] ==
|
||||||
|
"re: object-summary"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "skip filter" do
|
describe "skip filter" do
|
||||||
test "it skip if type isn't 'Create'" do
|
test "it skip if type isn't 'Create' or 'Update'" do
|
||||||
message = %{
|
message = %{
|
||||||
"type" => "Annotation",
|
"type" => "Annotation",
|
||||||
"object" => %{"summary" => "object-summary"}
|
"object" => %{"summary" => "object-summary"}
|
||||||
|
|
|
@ -20,6 +20,76 @@ test "it sets the sensitive property with relevant hashtags" do
|
||||||
assert modified["object"]["sensitive"]
|
assert modified["object"]["sensitive"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "it is history-aware" do
|
||||||
|
activity = %{
|
||||||
|
"type" => "Create",
|
||||||
|
"object" => %{
|
||||||
|
"content" => "hey",
|
||||||
|
"tag" => []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activity_data =
|
||||||
|
activity
|
||||||
|
|> put_in(
|
||||||
|
["object", "formerRepresentations"],
|
||||||
|
%{
|
||||||
|
"type" => "OrderedCollection",
|
||||||
|
"orderedItems" => [
|
||||||
|
Map.put(
|
||||||
|
activity["object"],
|
||||||
|
"tag",
|
||||||
|
[%{"type" => "Hashtag", "name" => "#nsfw"}]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, modified} =
|
||||||
|
Pleroma.Web.ActivityPub.MRF.filter_one(
|
||||||
|
Pleroma.Web.ActivityPub.MRF.HashtagPolicy,
|
||||||
|
activity_data
|
||||||
|
)
|
||||||
|
|
||||||
|
refute modified["object"]["sensitive"]
|
||||||
|
assert Enum.at(modified["object"]["formerRepresentations"]["orderedItems"], 0)["sensitive"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it works with Update" do
|
||||||
|
activity = %{
|
||||||
|
"type" => "Update",
|
||||||
|
"object" => %{
|
||||||
|
"content" => "hey",
|
||||||
|
"tag" => []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activity_data =
|
||||||
|
activity
|
||||||
|
|> put_in(
|
||||||
|
["object", "formerRepresentations"],
|
||||||
|
%{
|
||||||
|
"type" => "OrderedCollection",
|
||||||
|
"orderedItems" => [
|
||||||
|
Map.put(
|
||||||
|
activity["object"],
|
||||||
|
"tag",
|
||||||
|
[%{"type" => "Hashtag", "name" => "#nsfw"}]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, modified} =
|
||||||
|
Pleroma.Web.ActivityPub.MRF.filter_one(
|
||||||
|
Pleroma.Web.ActivityPub.MRF.HashtagPolicy,
|
||||||
|
activity_data
|
||||||
|
)
|
||||||
|
|
||||||
|
refute modified["object"]["sensitive"]
|
||||||
|
assert Enum.at(modified["object"]["formerRepresentations"]["orderedItems"], 0)["sensitive"]
|
||||||
|
end
|
||||||
|
|
||||||
test "it doesn't sets the sensitive property with irrelevant hashtags" do
|
test "it doesn't sets the sensitive property with irrelevant hashtags" do
|
||||||
user = insert(:user)
|
user = insert(:user)
|
||||||
|
|
||||||
|
|
|
@ -79,6 +79,54 @@ test "rejects if regex matches in summary" do
|
||||||
KeywordPolicy.filter(message)
|
KeywordPolicy.filter(message)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "rejects if string matches in history" do
|
||||||
|
clear_config([:mrf_keyword, :reject], ["pun"])
|
||||||
|
|
||||||
|
message = %{
|
||||||
|
"type" => "Create",
|
||||||
|
"object" => %{
|
||||||
|
"content" => "just a daily reminder that compLAINer is a good",
|
||||||
|
"summary" => "",
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"type" => "OrderedCollection",
|
||||||
|
"orderedItems" => [
|
||||||
|
%{
|
||||||
|
"content" => "just a daily reminder that compLAINer is a good pun",
|
||||||
|
"summary" => ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:reject, "[KeywordPolicy] Matches with rejected keyword"} =
|
||||||
|
KeywordPolicy.filter(message)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects Updates" do
|
||||||
|
clear_config([:mrf_keyword, :reject], ["pun"])
|
||||||
|
|
||||||
|
message = %{
|
||||||
|
"type" => "Update",
|
||||||
|
"object" => %{
|
||||||
|
"content" => "just a daily reminder that compLAINer is a good",
|
||||||
|
"summary" => "",
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"type" => "OrderedCollection",
|
||||||
|
"orderedItems" => [
|
||||||
|
%{
|
||||||
|
"content" => "just a daily reminder that compLAINer is a good pun",
|
||||||
|
"summary" => ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:reject, "[KeywordPolicy] Matches with rejected keyword"} =
|
||||||
|
KeywordPolicy.filter(message)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "delisting from ftl based on keywords" do
|
describe "delisting from ftl based on keywords" do
|
||||||
|
@ -157,6 +205,31 @@ test "delists if regex matches in summary" do
|
||||||
not (["https://www.w3.org/ns/activitystreams#Public"] == result["to"])
|
not (["https://www.w3.org/ns/activitystreams#Public"] == result["to"])
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "delists if string matches in history" do
|
||||||
|
clear_config([:mrf_keyword, :federated_timeline_removal], ["pun"])
|
||||||
|
|
||||||
|
message = %{
|
||||||
|
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
|
||||||
|
"type" => "Create",
|
||||||
|
"object" => %{
|
||||||
|
"content" => "just a daily reminder that compLAINer is a good",
|
||||||
|
"summary" => "",
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"orderedItems" => [
|
||||||
|
%{
|
||||||
|
"content" => "just a daily reminder that compLAINer is a good pun",
|
||||||
|
"summary" => ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, result} = KeywordPolicy.filter(message)
|
||||||
|
assert ["https://www.w3.org/ns/activitystreams#Public"] == result["cc"]
|
||||||
|
refute ["https://www.w3.org/ns/activitystreams#Public"] == result["to"]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "replacing keywords" do
|
describe "replacing keywords" do
|
||||||
|
@ -221,5 +294,63 @@ test "replaces keyword if regex matches in summary" do
|
||||||
result == "ZFS is free software"
|
result == "ZFS is free software"
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "replaces keyword if string matches in history" do
|
||||||
|
clear_config([:mrf_keyword, :replace], [{"opensource", "free software"}])
|
||||||
|
|
||||||
|
message = %{
|
||||||
|
"type" => "Create",
|
||||||
|
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
|
||||||
|
"object" => %{
|
||||||
|
"content" => "ZFS is opensource",
|
||||||
|
"summary" => "",
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"type" => "OrderedCollection",
|
||||||
|
"orderedItems" => [
|
||||||
|
%{"content" => "ZFS is opensource mew mew", "summary" => ""}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
"object" => %{
|
||||||
|
"content" => "ZFS is free software",
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"orderedItems" => [%{"content" => "ZFS is free software mew mew"}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}} = KeywordPolicy.filter(message)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "replaces keyword in Updates" do
|
||||||
|
clear_config([:mrf_keyword, :replace], [{"opensource", "free software"}])
|
||||||
|
|
||||||
|
message = %{
|
||||||
|
"type" => "Update",
|
||||||
|
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
|
||||||
|
"object" => %{
|
||||||
|
"content" => "ZFS is opensource",
|
||||||
|
"summary" => "",
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"type" => "OrderedCollection",
|
||||||
|
"orderedItems" => [
|
||||||
|
%{"content" => "ZFS is opensource mew mew", "summary" => ""}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
"object" => %{
|
||||||
|
"content" => "ZFS is free software",
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"orderedItems" => [%{"content" => "ZFS is free software mew mew"}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}} = KeywordPolicy.filter(message)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do
|
||||||
use Pleroma.Tests.Helpers
|
use Pleroma.Tests.Helpers
|
||||||
|
|
||||||
alias Pleroma.HTTP
|
alias Pleroma.HTTP
|
||||||
|
alias Pleroma.Web.ActivityPub.MRF
|
||||||
alias Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy
|
alias Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy
|
||||||
|
|
||||||
import Mock
|
import Mock
|
||||||
|
@ -22,6 +23,25 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicyTest do
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@message_with_history %{
|
||||||
|
"type" => "Create",
|
||||||
|
"object" => %{
|
||||||
|
"type" => "Note",
|
||||||
|
"content" => "content",
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"orderedItems" => [
|
||||||
|
%{
|
||||||
|
"type" => "Note",
|
||||||
|
"content" => "content",
|
||||||
|
"attachment" => [
|
||||||
|
%{"url" => [%{"href" => "http://example.com/image.jpg"}]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setup do: clear_config([:media_proxy, :enabled], true)
|
setup do: clear_config([:media_proxy, :enabled], true)
|
||||||
|
|
||||||
test "it prefetches media proxy URIs" do
|
test "it prefetches media proxy URIs" do
|
||||||
|
@ -50,4 +70,28 @@ test "it does nothing when no attachments are present" do
|
||||||
refute called(HTTP.get(:_, :_, :_))
|
refute called(HTTP.get(:_, :_, :_))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "history-aware" do
|
||||||
|
Tesla.Mock.mock(fn %{method: :get, url: "http://example.com/image.jpg"} ->
|
||||||
|
{:ok, %Tesla.Env{status: 200, body: ""}}
|
||||||
|
end)
|
||||||
|
|
||||||
|
with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do
|
||||||
|
MRF.filter_one(MediaProxyWarmingPolicy, @message_with_history)
|
||||||
|
|
||||||
|
assert called(HTTP.get(:_, :_, :_))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "works with Updates" do
|
||||||
|
Tesla.Mock.mock(fn %{method: :get, url: "http://example.com/image.jpg"} ->
|
||||||
|
{:ok, %Tesla.Env{status: 200, body: ""}}
|
||||||
|
end)
|
||||||
|
|
||||||
|
with_mock HTTP, get: fn _, _, _ -> {:ok, []} end do
|
||||||
|
MRF.filter_one(MediaProxyWarmingPolicy, @message_with_history |> Map.put("type", "Update"))
|
||||||
|
|
||||||
|
assert called(HTTP.get(:_, :_, :_))
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -153,4 +153,27 @@ test "Notes with no content are denied" do
|
||||||
|
|
||||||
assert NoEmptyPolicy.filter(message) == {:reject, "[NoEmptyPolicy]"}
|
assert NoEmptyPolicy.filter(message) == {:reject, "[NoEmptyPolicy]"}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "works with Update" do
|
||||||
|
message = %{
|
||||||
|
"actor" => "http://localhost:4001/users/testuser",
|
||||||
|
"cc" => ["http://localhost:4001/users/testuser/followers"],
|
||||||
|
"object" => %{
|
||||||
|
"actor" => "http://localhost:4001/users/testuser",
|
||||||
|
"attachment" => [],
|
||||||
|
"cc" => ["http://localhost:4001/users/testuser/followers"],
|
||||||
|
"source" => "",
|
||||||
|
"to" => [
|
||||||
|
"https://www.w3.org/ns/activitystreams#Public"
|
||||||
|
],
|
||||||
|
"type" => "Note"
|
||||||
|
},
|
||||||
|
"to" => [
|
||||||
|
"https://www.w3.org/ns/activitystreams#Public"
|
||||||
|
],
|
||||||
|
"type" => "Update"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert NoEmptyPolicy.filter(message) == {:reject, "[NoEmptyPolicy]"}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
defmodule Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicyTest do
|
defmodule Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicyTest do
|
||||||
use Pleroma.DataCase, async: true
|
use Pleroma.DataCase, async: true
|
||||||
|
alias Pleroma.Web.ActivityPub.MRF
|
||||||
alias Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicy
|
alias Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicy
|
||||||
|
|
||||||
test "it clears content object" do
|
test "it clears content object" do
|
||||||
|
@ -20,6 +21,46 @@ test "it clears content object" do
|
||||||
assert res["object"]["content"] == ""
|
assert res["object"]["content"] == ""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "history-aware" do
|
||||||
|
message = %{
|
||||||
|
"type" => "Create",
|
||||||
|
"object" => %{
|
||||||
|
"content" => ".",
|
||||||
|
"attachment" => "image",
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"orderedItems" => [%{"content" => ".", "attachment" => "image"}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, res} = MRF.filter_one(NoPlaceholderTextPolicy, message)
|
||||||
|
|
||||||
|
assert %{
|
||||||
|
"content" => "",
|
||||||
|
"formerRepresentations" => %{"orderedItems" => [%{"content" => ""}]}
|
||||||
|
} = res["object"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "works with Updates" do
|
||||||
|
message = %{
|
||||||
|
"type" => "Update",
|
||||||
|
"object" => %{
|
||||||
|
"content" => ".",
|
||||||
|
"attachment" => "image",
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"orderedItems" => [%{"content" => ".", "attachment" => "image"}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, res} = MRF.filter_one(NoPlaceholderTextPolicy, message)
|
||||||
|
|
||||||
|
assert %{
|
||||||
|
"content" => "",
|
||||||
|
"formerRepresentations" => %{"orderedItems" => [%{"content" => ""}]}
|
||||||
|
} = res["object"]
|
||||||
|
end
|
||||||
|
|
||||||
@messages [
|
@messages [
|
||||||
%{
|
%{
|
||||||
"type" => "Create",
|
"type" => "Create",
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkupTest do
|
defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkupTest do
|
||||||
use Pleroma.DataCase, async: true
|
use Pleroma.DataCase, async: true
|
||||||
|
alias Pleroma.Web.ActivityPub.MRF
|
||||||
alias Pleroma.Web.ActivityPub.MRF.NormalizeMarkup
|
alias Pleroma.Web.ActivityPub.MRF.NormalizeMarkup
|
||||||
|
|
||||||
@html_sample """
|
@html_sample """
|
||||||
|
@ -16,24 +17,58 @@ defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkupTest do
|
||||||
<script>alert('hacked')</script>
|
<script>alert('hacked')</script>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
test "it filter html tags" do
|
@expected """
|
||||||
expected = """
|
<b>this is in bold</b>
|
||||||
<b>this is in bold</b>
|
<p>this is a paragraph</p>
|
||||||
<p>this is a paragraph</p>
|
this is a linebreak<br/>
|
||||||
this is a linebreak<br/>
|
this is a link with allowed "rel" attribute: <a href="http://example.com/" rel="tag">example.com</a>
|
||||||
this is a link with allowed "rel" attribute: <a href="http://example.com/" rel="tag">example.com</a>
|
this is a link with not allowed "rel" attribute: <a href="http://example.com/">example.com</a>
|
||||||
this is a link with not allowed "rel" attribute: <a href="http://example.com/">example.com</a>
|
this is an image: <img src="http://example.com/image.jpg"/><br/>
|
||||||
this is an image: <img src="http://example.com/image.jpg"/><br/>
|
alert('hacked')
|
||||||
alert('hacked')
|
"""
|
||||||
"""
|
|
||||||
|
|
||||||
|
test "it filter html tags" do
|
||||||
message = %{"type" => "Create", "object" => %{"content" => @html_sample}}
|
message = %{"type" => "Create", "object" => %{"content" => @html_sample}}
|
||||||
|
|
||||||
assert {:ok, res} = NormalizeMarkup.filter(message)
|
assert {:ok, res} = NormalizeMarkup.filter(message)
|
||||||
assert res["object"]["content"] == expected
|
assert res["object"]["content"] == @expected
|
||||||
end
|
end
|
||||||
|
|
||||||
test "it skips filter if type isn't `Create`" do
|
test "history-aware" do
|
||||||
|
message = %{
|
||||||
|
"type" => "Create",
|
||||||
|
"object" => %{
|
||||||
|
"content" => @html_sample,
|
||||||
|
"formerRepresentations" => %{"orderedItems" => [%{"content" => @html_sample}]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, res} = MRF.filter_one(NormalizeMarkup, message)
|
||||||
|
|
||||||
|
assert %{
|
||||||
|
"content" => @expected,
|
||||||
|
"formerRepresentations" => %{"orderedItems" => [%{"content" => @expected}]}
|
||||||
|
} = res["object"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "works with Updates" do
|
||||||
|
message = %{
|
||||||
|
"type" => "Update",
|
||||||
|
"object" => %{
|
||||||
|
"content" => @html_sample,
|
||||||
|
"formerRepresentations" => %{"orderedItems" => [%{"content" => @html_sample}]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, res} = MRF.filter_one(NormalizeMarkup, message)
|
||||||
|
|
||||||
|
assert %{
|
||||||
|
"content" => @expected,
|
||||||
|
"formerRepresentations" => %{"orderedItems" => [%{"content" => @expected}]}
|
||||||
|
} = res["object"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it skips filter if type isn't `Create` or `Update`" do
|
||||||
message = %{"type" => "Note", "object" => %{}}
|
message = %{"type" => "Note", "object" => %{}}
|
||||||
|
|
||||||
assert {:ok, res} = NormalizeMarkup.filter(message)
|
assert {:ok, res} = NormalizeMarkup.filter(message)
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidatorTest do
|
defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidatorTest do
|
||||||
use Pleroma.DataCase, async: true
|
use Pleroma.DataCase, async: true
|
||||||
|
|
||||||
|
alias Pleroma.Web.ActivityPub.ObjectValidator
|
||||||
alias Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator
|
alias Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator
|
||||||
alias Pleroma.Web.ActivityPub.Utils
|
alias Pleroma.Web.ActivityPub.Utils
|
||||||
|
|
||||||
|
@ -38,6 +39,11 @@ test "a basic note validates", %{note: note} do
|
||||||
%{valid?: true} = ArticleNotePageValidator.cast_and_validate(note)
|
%{valid?: true} = ArticleNotePageValidator.cast_and_validate(note)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "a note from factory validates" do
|
||||||
|
note = insert(:note)
|
||||||
|
%{valid?: true} = ArticleNotePageValidator.cast_and_validate(note.data)
|
||||||
|
end
|
||||||
|
|
||||||
test "a note with a remote replies collection should validate", _ do
|
test "a note with a remote replies collection should validate", _ do
|
||||||
insert(:user, %{ap_id: "https://bookwyrm.com/user/TestUser"})
|
insert(:user, %{ap_id: "https://bookwyrm.com/user/TestUser"})
|
||||||
collection = File.read!("test/fixtures/bookwyrm-replies-collection.json")
|
collection = File.read!("test/fixtures/bookwyrm-replies-collection.json")
|
||||||
|
@ -159,4 +165,47 @@ test "a Note without replies/first/items validates" do
|
||||||
|
|
||||||
%{valid?: true} = ArticleNotePageValidator.cast_and_validate(note)
|
%{valid?: true} = ArticleNotePageValidator.cast_and_validate(note)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "Note with history" do
|
||||||
|
setup do
|
||||||
|
user = insert(:user)
|
||||||
|
{:ok, activity} = Pleroma.Web.CommonAPI.post(user, %{status: "mew mew :dinosaur:"})
|
||||||
|
{:ok, edit} = Pleroma.Web.CommonAPI.update(user, activity, %{status: "edited :blank:"})
|
||||||
|
|
||||||
|
{:ok, %{"object" => external_rep}} =
|
||||||
|
Pleroma.Web.ActivityPub.Transmogrifier.prepare_outgoing(edit.data)
|
||||||
|
|
||||||
|
%{external_rep: external_rep}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "edited note", %{external_rep: external_rep} do
|
||||||
|
assert %{"formerRepresentations" => %{"orderedItems" => [%{"tag" => [_]}]}} = external_rep
|
||||||
|
|
||||||
|
{:ok, validate_res, []} = ObjectValidator.validate(external_rep, [])
|
||||||
|
|
||||||
|
assert %{"formerRepresentations" => %{"orderedItems" => [%{"emoji" => %{"dinosaur" => _}}]}} =
|
||||||
|
validate_res
|
||||||
|
end
|
||||||
|
|
||||||
|
test "edited note, badly-formed formerRepresentations", %{external_rep: external_rep} do
|
||||||
|
external_rep = Map.put(external_rep, "formerRepresentations", %{})
|
||||||
|
|
||||||
|
assert {:error, _} = ObjectValidator.validate(external_rep, [])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "edited note, badly-formed history item", %{external_rep: external_rep} do
|
||||||
|
history_item =
|
||||||
|
Enum.at(external_rep["formerRepresentations"]["orderedItems"], 0)
|
||||||
|
|> Map.put("type", "Foo")
|
||||||
|
|
||||||
|
external_rep =
|
||||||
|
put_in(
|
||||||
|
external_rep,
|
||||||
|
["formerRepresentations", "orderedItems"],
|
||||||
|
[history_item]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert {:error, _} = ObjectValidator.validate(external_rep, [])
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -32,7 +32,7 @@ test "validates a basic object", %{valid_update: valid_update} do
|
||||||
test "returns an error if the object can't be updated by the actor", %{
|
test "returns an error if the object can't be updated by the actor", %{
|
||||||
valid_update: valid_update
|
valid_update: valid_update
|
||||||
} do
|
} do
|
||||||
other_user = insert(:user)
|
other_user = insert(:user, local: false)
|
||||||
|
|
||||||
update =
|
update =
|
||||||
valid_update
|
valid_update
|
||||||
|
@ -40,5 +40,129 @@ test "returns an error if the object can't be updated by the actor", %{
|
||||||
|
|
||||||
assert {:error, _cng} = ObjectValidator.validate(update, [])
|
assert {:error, _cng} = ObjectValidator.validate(update, [])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "validates as long as the object is same-origin with the actor", %{
|
||||||
|
valid_update: valid_update
|
||||||
|
} do
|
||||||
|
other_user = insert(:user)
|
||||||
|
|
||||||
|
update =
|
||||||
|
valid_update
|
||||||
|
|> Map.put("actor", other_user.ap_id)
|
||||||
|
|
||||||
|
assert {:ok, _update, []} = ObjectValidator.validate(update, [])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validates if the object is not of an Actor type" do
|
||||||
|
note = insert(:note)
|
||||||
|
updated_note = note.data |> Map.put("content", "edited content")
|
||||||
|
other_user = insert(:user)
|
||||||
|
|
||||||
|
{:ok, update, _} = Builder.update(other_user, updated_note)
|
||||||
|
|
||||||
|
assert {:ok, _update, _} = ObjectValidator.validate(update, [])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "update note" do
|
||||||
|
test "converts object into Pleroma's format" do
|
||||||
|
mastodon_tags = [
|
||||||
|
%{
|
||||||
|
"icon" => %{
|
||||||
|
"mediaType" => "image/png",
|
||||||
|
"type" => "Image",
|
||||||
|
"url" => "https://somewhere.org/emoji/url/1.png"
|
||||||
|
},
|
||||||
|
"id" => "https://somewhere.org/emoji/1",
|
||||||
|
"name" => ":some_emoji:",
|
||||||
|
"type" => "Emoji",
|
||||||
|
"updated" => "2021-04-07T11:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
user = insert(:user)
|
||||||
|
note = insert(:note, user: user)
|
||||||
|
|
||||||
|
updated_note =
|
||||||
|
note.data
|
||||||
|
|> Map.put("content", "edited content")
|
||||||
|
|> Map.put("tag", mastodon_tags)
|
||||||
|
|
||||||
|
{:ok, update, _} = Builder.update(user, updated_note)
|
||||||
|
|
||||||
|
assert {:ok, _update, meta} = ObjectValidator.validate(update, [])
|
||||||
|
|
||||||
|
assert %{"emoji" => %{"some_emoji" => "https://somewhere.org/emoji/url/1.png"}} =
|
||||||
|
meta[:object_data]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns no object_data in meta for a local Update" do
|
||||||
|
user = insert(:user)
|
||||||
|
note = insert(:note, user: user)
|
||||||
|
|
||||||
|
updated_note =
|
||||||
|
note.data
|
||||||
|
|> Map.put("content", "edited content")
|
||||||
|
|
||||||
|
{:ok, update, _} = Builder.update(user, updated_note)
|
||||||
|
|
||||||
|
assert {:ok, _update, meta} = ObjectValidator.validate(update, local: true)
|
||||||
|
assert is_nil(meta[:object_data])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns object_data in meta for a remote Update" do
|
||||||
|
user = insert(:user)
|
||||||
|
note = insert(:note, user: user)
|
||||||
|
|
||||||
|
updated_note =
|
||||||
|
note.data
|
||||||
|
|> Map.put("content", "edited content")
|
||||||
|
|
||||||
|
{:ok, update, _} = Builder.update(user, updated_note)
|
||||||
|
|
||||||
|
assert {:ok, _update, meta} = ObjectValidator.validate(update, local: false)
|
||||||
|
assert meta[:object_data]
|
||||||
|
|
||||||
|
assert {:ok, _update, meta} = ObjectValidator.validate(update, [])
|
||||||
|
assert meta[:object_data]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "update with history" do
|
||||||
|
setup do
|
||||||
|
user = insert(:user)
|
||||||
|
{:ok, activity} = Pleroma.Web.CommonAPI.post(user, %{status: "mew mew :dinosaur:"})
|
||||||
|
{:ok, edit} = Pleroma.Web.CommonAPI.update(user, activity, %{status: "edited :blank:"})
|
||||||
|
{:ok, external_rep} = Pleroma.Web.ActivityPub.Transmogrifier.prepare_outgoing(edit.data)
|
||||||
|
%{external_rep: external_rep}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "edited note", %{external_rep: external_rep} do
|
||||||
|
{:ok, _validate_res, meta} = ObjectValidator.validate(external_rep, [])
|
||||||
|
|
||||||
|
assert %{"formerRepresentations" => %{"orderedItems" => [%{"emoji" => %{"dinosaur" => _}}]}} =
|
||||||
|
meta[:object_data]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "edited note, badly-formed formerRepresentations", %{external_rep: external_rep} do
|
||||||
|
external_rep = put_in(external_rep, ["object", "formerRepresentations"], %{})
|
||||||
|
|
||||||
|
assert {:error, _} = ObjectValidator.validate(external_rep, [])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "edited note, badly-formed history item", %{external_rep: external_rep} do
|
||||||
|
history_item =
|
||||||
|
Enum.at(external_rep["object"]["formerRepresentations"]["orderedItems"], 0)
|
||||||
|
|> Map.put("type", "Foo")
|
||||||
|
|
||||||
|
external_rep =
|
||||||
|
put_in(
|
||||||
|
external_rep,
|
||||||
|
["object", "formerRepresentations", "orderedItems"],
|
||||||
|
[history_item]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert {:error, _} = ObjectValidator.validate(external_rep, [])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -123,7 +123,10 @@ test "it blocks but does not unfollow if the relevant setting is set", %{
|
||||||
describe "update users" do
|
describe "update users" do
|
||||||
setup do
|
setup do
|
||||||
user = insert(:user, local: false)
|
user = insert(:user, local: false)
|
||||||
{:ok, update_data, []} = Builder.update(user, %{"id" => user.ap_id, "name" => "new name!"})
|
|
||||||
|
{:ok, update_data, []} =
|
||||||
|
Builder.update(user, %{"id" => user.ap_id, "type" => "Person", "name" => "new name!"})
|
||||||
|
|
||||||
{:ok, update, _meta} = ActivityPub.persist(update_data, local: true)
|
{:ok, update, _meta} = ActivityPub.persist(update_data, local: true)
|
||||||
|
|
||||||
%{user: user, update_data: update_data, update: update}
|
%{user: user, update_data: update_data, update: update}
|
||||||
|
@ -145,6 +148,298 @@ test "it uses a given changeset to update", %{user: user, update: update} do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "update notes" do
|
||||||
|
setup do
|
||||||
|
make_time = fn ->
|
||||||
|
Pleroma.Web.ActivityPub.Utils.make_date()
|
||||||
|
end
|
||||||
|
|
||||||
|
user = insert(:user)
|
||||||
|
note = insert(:note, user: user, data: %{"published" => make_time.()})
|
||||||
|
_note_activity = insert(:note_activity, note: note)
|
||||||
|
|
||||||
|
updated_note =
|
||||||
|
note.data
|
||||||
|
|> Map.put("summary", "edited summary")
|
||||||
|
|> Map.put("content", "edited content")
|
||||||
|
|> Map.put("updated", make_time.())
|
||||||
|
|
||||||
|
{:ok, update_data, []} = Builder.update(user, updated_note)
|
||||||
|
{:ok, update, _meta} = ActivityPub.persist(update_data, local: true)
|
||||||
|
|
||||||
|
%{
|
||||||
|
user: user,
|
||||||
|
note: note,
|
||||||
|
object_id: note.id,
|
||||||
|
update_data: update_data,
|
||||||
|
update: update,
|
||||||
|
updated_note: updated_note
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it updates the note", %{
|
||||||
|
object_id: object_id,
|
||||||
|
update: update,
|
||||||
|
updated_note: updated_note
|
||||||
|
} do
|
||||||
|
{:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
|
||||||
|
updated_time = updated_note["updated"]
|
||||||
|
|
||||||
|
new_note = Pleroma.Object.get_by_id(object_id)
|
||||||
|
|
||||||
|
assert %{
|
||||||
|
"summary" => "edited summary",
|
||||||
|
"content" => "edited content",
|
||||||
|
"updated" => ^updated_time
|
||||||
|
} = new_note.data
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it rejects updates with no updated attribute in object", %{
|
||||||
|
object_id: object_id,
|
||||||
|
update: update,
|
||||||
|
updated_note: updated_note
|
||||||
|
} do
|
||||||
|
old_note = Pleroma.Object.get_by_id(object_id)
|
||||||
|
updated_note = Map.drop(updated_note, ["updated"])
|
||||||
|
{:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
|
||||||
|
new_note = Pleroma.Object.get_by_id(object_id)
|
||||||
|
assert old_note.data == new_note.data
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it rejects updates with updated attribute older than what we have in the original object",
|
||||||
|
%{
|
||||||
|
object_id: object_id,
|
||||||
|
update: update,
|
||||||
|
updated_note: updated_note
|
||||||
|
} do
|
||||||
|
old_note = Pleroma.Object.get_by_id(object_id)
|
||||||
|
{:ok, creation_time, _} = DateTime.from_iso8601(old_note.data["published"])
|
||||||
|
|
||||||
|
updated_note =
|
||||||
|
Map.put(updated_note, "updated", DateTime.to_iso8601(DateTime.add(creation_time, -10)))
|
||||||
|
|
||||||
|
{:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
|
||||||
|
new_note = Pleroma.Object.get_by_id(object_id)
|
||||||
|
assert old_note.data == new_note.data
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it rejects updates with updated attribute older than the last Update", %{
|
||||||
|
object_id: object_id,
|
||||||
|
update: update,
|
||||||
|
updated_note: updated_note
|
||||||
|
} do
|
||||||
|
old_note = Pleroma.Object.get_by_id(object_id)
|
||||||
|
{:ok, creation_time, _} = DateTime.from_iso8601(old_note.data["published"])
|
||||||
|
|
||||||
|
updated_note =
|
||||||
|
Map.put(updated_note, "updated", DateTime.to_iso8601(DateTime.add(creation_time, +10)))
|
||||||
|
|
||||||
|
{:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
|
||||||
|
|
||||||
|
old_note = Pleroma.Object.get_by_id(object_id)
|
||||||
|
{:ok, update_time, _} = DateTime.from_iso8601(old_note.data["updated"])
|
||||||
|
|
||||||
|
updated_note =
|
||||||
|
Map.put(updated_note, "updated", DateTime.to_iso8601(DateTime.add(update_time, -5)))
|
||||||
|
|
||||||
|
{:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
|
||||||
|
|
||||||
|
new_note = Pleroma.Object.get_by_id(object_id)
|
||||||
|
assert old_note.data == new_note.data
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it updates using object_data", %{
|
||||||
|
object_id: object_id,
|
||||||
|
update: update,
|
||||||
|
updated_note: updated_note
|
||||||
|
} do
|
||||||
|
updated_note = Map.put(updated_note, "summary", "mew mew")
|
||||||
|
{:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
|
||||||
|
new_note = Pleroma.Object.get_by_id(object_id)
|
||||||
|
assert %{"summary" => "mew mew", "content" => "edited content"} = new_note.data
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it records the original note in formerRepresentations", %{
|
||||||
|
note: note,
|
||||||
|
object_id: object_id,
|
||||||
|
update: update,
|
||||||
|
updated_note: updated_note
|
||||||
|
} do
|
||||||
|
{:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
|
||||||
|
%{data: new_note} = Pleroma.Object.get_by_id(object_id)
|
||||||
|
assert %{"summary" => "edited summary", "content" => "edited content"} = new_note
|
||||||
|
|
||||||
|
assert [Map.drop(note.data, ["id", "formerRepresentations"])] ==
|
||||||
|
new_note["formerRepresentations"]["orderedItems"]
|
||||||
|
|
||||||
|
assert new_note["formerRepresentations"]["totalItems"] == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it puts the original note at the front of formerRepresentations", %{
|
||||||
|
user: user,
|
||||||
|
note: note,
|
||||||
|
object_id: object_id,
|
||||||
|
update: update,
|
||||||
|
updated_note: updated_note
|
||||||
|
} do
|
||||||
|
{:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
|
||||||
|
%{data: first_edit} = Pleroma.Object.get_by_id(object_id)
|
||||||
|
|
||||||
|
second_updated_note =
|
||||||
|
note.data
|
||||||
|
|> Map.put("summary", "edited summary 2")
|
||||||
|
|> Map.put("content", "edited content 2")
|
||||||
|
|> Map.put(
|
||||||
|
"updated",
|
||||||
|
first_edit["updated"]
|
||||||
|
|> DateTime.from_iso8601()
|
||||||
|
|> elem(1)
|
||||||
|
|> DateTime.add(10)
|
||||||
|
|> DateTime.to_iso8601()
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, second_update_data, []} = Builder.update(user, second_updated_note)
|
||||||
|
{:ok, update, _meta} = ActivityPub.persist(second_update_data, local: true)
|
||||||
|
{:ok, _, _} = SideEffects.handle(update, object_data: second_updated_note)
|
||||||
|
%{data: new_note} = Pleroma.Object.get_by_id(object_id)
|
||||||
|
assert %{"summary" => "edited summary 2", "content" => "edited content 2"} = new_note
|
||||||
|
|
||||||
|
original_version = Map.drop(note.data, ["id", "formerRepresentations"])
|
||||||
|
first_edit = Map.drop(first_edit, ["id", "formerRepresentations"])
|
||||||
|
|
||||||
|
assert [first_edit, original_version] ==
|
||||||
|
new_note["formerRepresentations"]["orderedItems"]
|
||||||
|
|
||||||
|
assert new_note["formerRepresentations"]["totalItems"] == 2
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it does not prepend to formerRepresentations if no actual changes are made", %{
|
||||||
|
note: note,
|
||||||
|
object_id: object_id,
|
||||||
|
update: update,
|
||||||
|
updated_note: updated_note
|
||||||
|
} do
|
||||||
|
{:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
|
||||||
|
%{data: first_edit} = Pleroma.Object.get_by_id(object_id)
|
||||||
|
|
||||||
|
updated_note =
|
||||||
|
updated_note
|
||||||
|
|> Map.put(
|
||||||
|
"updated",
|
||||||
|
first_edit["updated"]
|
||||||
|
|> DateTime.from_iso8601()
|
||||||
|
|> elem(1)
|
||||||
|
|> DateTime.add(10)
|
||||||
|
|> DateTime.to_iso8601()
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, _, _} = SideEffects.handle(update, object_data: updated_note)
|
||||||
|
%{data: new_note} = Pleroma.Object.get_by_id(object_id)
|
||||||
|
assert %{"summary" => "edited summary", "content" => "edited content"} = new_note
|
||||||
|
|
||||||
|
original_version = Map.drop(note.data, ["id", "formerRepresentations"])
|
||||||
|
|
||||||
|
assert [original_version] ==
|
||||||
|
new_note["formerRepresentations"]["orderedItems"]
|
||||||
|
|
||||||
|
assert new_note["formerRepresentations"]["totalItems"] == 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "update questions" do
|
||||||
|
setup do
|
||||||
|
user = insert(:user)
|
||||||
|
|
||||||
|
question =
|
||||||
|
insert(:question,
|
||||||
|
user: user,
|
||||||
|
data: %{"published" => Pleroma.Web.ActivityPub.Utils.make_date()}
|
||||||
|
)
|
||||||
|
|
||||||
|
%{user: user, data: question.data, id: question.id}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "allows updating choice count without generating edit history", %{
|
||||||
|
user: user,
|
||||||
|
data: data,
|
||||||
|
id: id
|
||||||
|
} do
|
||||||
|
new_choices =
|
||||||
|
data["oneOf"]
|
||||||
|
|> Enum.map(fn choice -> put_in(choice, ["replies", "totalItems"], 5) end)
|
||||||
|
|
||||||
|
updated_question =
|
||||||
|
data
|
||||||
|
|> Map.put("oneOf", new_choices)
|
||||||
|
|> Map.put("updated", Pleroma.Web.ActivityPub.Utils.make_date())
|
||||||
|
|
||||||
|
{:ok, update_data, []} = Builder.update(user, updated_question)
|
||||||
|
{:ok, update, _meta} = ActivityPub.persist(update_data, local: true)
|
||||||
|
|
||||||
|
{:ok, _, _} = SideEffects.handle(update, object_data: updated_question)
|
||||||
|
|
||||||
|
%{data: new_question} = Pleroma.Object.get_by_id(id)
|
||||||
|
|
||||||
|
assert [%{"replies" => %{"totalItems" => 5}}, %{"replies" => %{"totalItems" => 5}}] =
|
||||||
|
new_question["oneOf"]
|
||||||
|
|
||||||
|
refute Map.has_key?(new_question, "formerRepresentations")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "allows updating choice count without updated field", %{
|
||||||
|
user: user,
|
||||||
|
data: data,
|
||||||
|
id: id
|
||||||
|
} do
|
||||||
|
new_choices =
|
||||||
|
data["oneOf"]
|
||||||
|
|> Enum.map(fn choice -> put_in(choice, ["replies", "totalItems"], 5) end)
|
||||||
|
|
||||||
|
updated_question =
|
||||||
|
data
|
||||||
|
|> Map.put("oneOf", new_choices)
|
||||||
|
|
||||||
|
{:ok, update_data, []} = Builder.update(user, updated_question)
|
||||||
|
{:ok, update, _meta} = ActivityPub.persist(update_data, local: true)
|
||||||
|
|
||||||
|
{:ok, _, _} = SideEffects.handle(update, object_data: updated_question)
|
||||||
|
|
||||||
|
%{data: new_question} = Pleroma.Object.get_by_id(id)
|
||||||
|
|
||||||
|
assert [%{"replies" => %{"totalItems" => 5}}, %{"replies" => %{"totalItems" => 5}}] =
|
||||||
|
new_question["oneOf"]
|
||||||
|
|
||||||
|
refute Map.has_key?(new_question, "formerRepresentations")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "allows updating choice count with updated field same as the creation date", %{
|
||||||
|
user: user,
|
||||||
|
data: data,
|
||||||
|
id: id
|
||||||
|
} do
|
||||||
|
new_choices =
|
||||||
|
data["oneOf"]
|
||||||
|
|> Enum.map(fn choice -> put_in(choice, ["replies", "totalItems"], 5) end)
|
||||||
|
|
||||||
|
updated_question =
|
||||||
|
data
|
||||||
|
|> Map.put("oneOf", new_choices)
|
||||||
|
|> Map.put("updated", data["published"])
|
||||||
|
|
||||||
|
{:ok, update_data, []} = Builder.update(user, updated_question)
|
||||||
|
{:ok, update, _meta} = ActivityPub.persist(update_data, local: true)
|
||||||
|
|
||||||
|
{:ok, _, _} = SideEffects.handle(update, object_data: updated_question)
|
||||||
|
|
||||||
|
%{data: new_question} = Pleroma.Object.get_by_id(id)
|
||||||
|
|
||||||
|
assert [%{"replies" => %{"totalItems" => 5}}, %{"replies" => %{"totalItems" => 5}}] =
|
||||||
|
new_question["oneOf"]
|
||||||
|
|
||||||
|
refute Map.has_key?(new_question, "formerRepresentations")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "EmojiReact objects" do
|
describe "EmojiReact objects" do
|
||||||
setup do
|
setup do
|
||||||
poster = insert(:user)
|
poster = insert(:user)
|
||||||
|
|
|
@ -301,6 +301,28 @@ test "custom emoji urls are URI encoded" do
|
||||||
|
|
||||||
assert url == "http://localhost:4001/emoji/dino%20walking.gif"
|
assert url == "http://localhost:4001/emoji/dino%20walking.gif"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "Updates of Notes are handled" do
|
||||||
|
user = insert(:user)
|
||||||
|
|
||||||
|
{:ok, activity} = CommonAPI.post(user, %{status: "everybody do the dinosaur :dinosaur:"})
|
||||||
|
{:ok, update} = CommonAPI.update(user, activity, %{status: "mew mew :blank:"})
|
||||||
|
|
||||||
|
{:ok, prepared} = Transmogrifier.prepare_outgoing(update.data)
|
||||||
|
|
||||||
|
assert %{
|
||||||
|
"content" => "mew mew :blank:",
|
||||||
|
"tag" => [%{"name" => ":blank:", "type" => "Emoji"}],
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"orderedItems" => [
|
||||||
|
%{
|
||||||
|
"content" => "everybody do the dinosaur :dinosaur:",
|
||||||
|
"tag" => [%{"name" => ":dinosaur:", "type" => "Emoji"}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
} = prepared["object"]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "user upgrade" do
|
describe "user upgrade" do
|
||||||
|
@ -564,4 +586,43 @@ test "puts dimensions into attachment url field" do
|
||||||
assert Transmogrifier.fix_attachments(object) == expected
|
assert Transmogrifier.fix_attachments(object) == expected
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "prepare_object/1" do
|
||||||
|
test "it processes history" do
|
||||||
|
original = %{
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"orderedItems" => [
|
||||||
|
%{
|
||||||
|
"generator" => %{},
|
||||||
|
"emoji" => %{"blobcat" => "http://localhost:4001/emoji/blobcat.png"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processed = Transmogrifier.prepare_object(original)
|
||||||
|
|
||||||
|
history_item = Enum.at(processed["formerRepresentations"]["orderedItems"], 0)
|
||||||
|
|
||||||
|
refute Map.has_key?(history_item, "generator")
|
||||||
|
|
||||||
|
assert [%{"name" => ":blobcat:"}] = history_item["tag"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it works when there is no or bad history" do
|
||||||
|
original = %{
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"items" => [
|
||||||
|
%{
|
||||||
|
"generator" => %{},
|
||||||
|
"emoji" => %{"blobcat" => "http://localhost:4001/emoji/blobcat.png"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processed = Transmogrifier.prepare_object(original)
|
||||||
|
assert processed["formerRepresentations"] == original["formerRepresentations"]
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1313,4 +1313,128 @@ test "unreact_with_emoji" do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "update/3" do
|
||||||
|
test "updates a post" do
|
||||||
|
user = insert(:user)
|
||||||
|
{:ok, activity} = CommonAPI.post(user, %{status: "foo1", spoiler_text: "title 1"})
|
||||||
|
|
||||||
|
{:ok, updated} = CommonAPI.update(user, activity, %{status: "updated 2"})
|
||||||
|
|
||||||
|
updated_object = Object.normalize(updated)
|
||||||
|
assert updated_object.data["content"] == "updated 2"
|
||||||
|
assert Map.get(updated_object.data, "summary", "") == ""
|
||||||
|
assert Map.has_key?(updated_object.data, "updated")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not change visibility" do
|
||||||
|
user = insert(:user)
|
||||||
|
|
||||||
|
{:ok, activity} =
|
||||||
|
CommonAPI.post(user, %{status: "foo1", spoiler_text: "title 1", visibility: "private"})
|
||||||
|
|
||||||
|
{:ok, updated} = CommonAPI.update(user, activity, %{status: "updated 2"})
|
||||||
|
|
||||||
|
updated_object = Object.normalize(updated)
|
||||||
|
assert updated_object.data["content"] == "updated 2"
|
||||||
|
assert Map.get(updated_object.data, "summary", "") == ""
|
||||||
|
assert Visibility.get_visibility(updated_object) == "private"
|
||||||
|
assert Visibility.get_visibility(updated) == "private"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "updates a post with emoji" do
|
||||||
|
[{emoji1, _}, {emoji2, _} | _] = Pleroma.Emoji.get_all()
|
||||||
|
|
||||||
|
user = insert(:user)
|
||||||
|
|
||||||
|
{:ok, activity} =
|
||||||
|
CommonAPI.post(user, %{status: "foo1", spoiler_text: "title 1 :#{emoji1}:"})
|
||||||
|
|
||||||
|
{:ok, updated} = CommonAPI.update(user, activity, %{status: "updated 2 :#{emoji2}:"})
|
||||||
|
|
||||||
|
updated_object = Object.normalize(updated)
|
||||||
|
assert updated_object.data["content"] == "updated 2 :#{emoji2}:"
|
||||||
|
assert %{^emoji2 => _} = updated_object.data["emoji"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "updates a post with emoji and federate properly" do
|
||||||
|
[{emoji1, _}, {emoji2, _} | _] = Pleroma.Emoji.get_all()
|
||||||
|
|
||||||
|
user = insert(:user)
|
||||||
|
|
||||||
|
{:ok, activity} =
|
||||||
|
CommonAPI.post(user, %{status: "foo1", spoiler_text: "title 1 :#{emoji1}:"})
|
||||||
|
|
||||||
|
clear_config([:instance, :federating], true)
|
||||||
|
|
||||||
|
with_mock Pleroma.Web.Federator,
|
||||||
|
publish: fn _p -> nil end do
|
||||||
|
{:ok, updated} = CommonAPI.update(user, activity, %{status: "updated 2 :#{emoji2}:"})
|
||||||
|
|
||||||
|
assert updated.data["object"]["content"] == "updated 2 :#{emoji2}:"
|
||||||
|
assert %{^emoji2 => _} = updated.data["object"]["emoji"]
|
||||||
|
|
||||||
|
assert called(Pleroma.Web.Federator.publish(updated))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "editing a post that copied a remote title with remote emoji should keep that emoji" do
|
||||||
|
remote_emoji_uri = "https://remote.org/emoji.png"
|
||||||
|
|
||||||
|
note =
|
||||||
|
insert(
|
||||||
|
:note,
|
||||||
|
data: %{
|
||||||
|
"summary" => ":remoteemoji:",
|
||||||
|
"emoji" => %{
|
||||||
|
"remoteemoji" => remote_emoji_uri
|
||||||
|
},
|
||||||
|
"tag" => [
|
||||||
|
%{
|
||||||
|
"type" => "Emoji",
|
||||||
|
"name" => "remoteemoji",
|
||||||
|
"icon" => %{"url" => remote_emoji_uri}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
note_activity = insert(:note_activity, note: note)
|
||||||
|
|
||||||
|
user = insert(:user)
|
||||||
|
|
||||||
|
{:ok, reply} =
|
||||||
|
CommonAPI.post(user, %{
|
||||||
|
status: "reply",
|
||||||
|
spoiler_text: ":remoteemoji:",
|
||||||
|
in_reply_to_id: note_activity.id
|
||||||
|
})
|
||||||
|
|
||||||
|
assert reply.object.data["emoji"]["remoteemoji"] == remote_emoji_uri
|
||||||
|
|
||||||
|
{:ok, edit} =
|
||||||
|
CommonAPI.update(user, reply, %{status: "reply mew mew", spoiler_text: ":remoteemoji:"})
|
||||||
|
|
||||||
|
edited_note = Pleroma.Object.normalize(edit)
|
||||||
|
|
||||||
|
assert edited_note.data["emoji"]["remoteemoji"] == remote_emoji_uri
|
||||||
|
end
|
||||||
|
|
||||||
|
test "respects MRF" do
|
||||||
|
user = insert(:user)
|
||||||
|
|
||||||
|
clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.KeywordPolicy])
|
||||||
|
clear_config([:mrf_keyword, :replace], [{"updated", "mewmew"}])
|
||||||
|
|
||||||
|
{:ok, activity} = CommonAPI.post(user, %{status: "foo1", spoiler_text: "updated 1"})
|
||||||
|
assert Object.normalize(activity).data["summary"] == "mewmew 1"
|
||||||
|
|
||||||
|
{:ok, updated} = CommonAPI.update(user, activity, %{status: "updated 2"})
|
||||||
|
|
||||||
|
updated_object = Object.normalize(updated)
|
||||||
|
assert updated_object.data["content"] == "mewmew 2"
|
||||||
|
assert Map.get(updated_object.data, "summary", "") == ""
|
||||||
|
assert Map.has_key?(updated_object.data, "updated")
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -2072,6 +2072,52 @@ test "posting a quote of a status that doesn't exist", %{conn: conn} do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "get status history" do
|
||||||
|
setup do
|
||||||
|
%{conn: build_conn()}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "unedited post", %{conn: conn} do
|
||||||
|
activity = insert(:note_activity)
|
||||||
|
|
||||||
|
conn = get(conn, "/api/v1/statuses/#{activity.id}/history")
|
||||||
|
|
||||||
|
assert [_] = json_response_and_validate_schema(conn, 200)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "edited post", %{conn: conn} do
|
||||||
|
note =
|
||||||
|
insert(
|
||||||
|
:note,
|
||||||
|
data: %{
|
||||||
|
"formerRepresentations" => %{
|
||||||
|
"type" => "OrderedCollection",
|
||||||
|
"orderedItems" => [
|
||||||
|
%{
|
||||||
|
"type" => "Note",
|
||||||
|
"content" => "mew mew 2",
|
||||||
|
"summary" => "title 2"
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
"type" => "Note",
|
||||||
|
"content" => "mew mew 1",
|
||||||
|
"summary" => "title 1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totalItems" => 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
activity = insert(:note_activity, note: note)
|
||||||
|
|
||||||
|
conn = get(conn, "/api/v1/statuses/#{activity.id}/history")
|
||||||
|
|
||||||
|
assert [%{"spoiler_text" => "title 1"}, %{"spoiler_text" => "title 2"}, _] =
|
||||||
|
json_response_and_validate_schema(conn, 200)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "translating statuses" do
|
describe "translating statuses" do
|
||||||
setup do
|
setup do
|
||||||
clear_config([:translator, :enabled], true)
|
clear_config([:translator, :enabled], true)
|
||||||
|
@ -2177,4 +2223,132 @@ test "should not allow translating of statuses you cannot see", %{conn: conn} do
|
||||||
json_response_and_validate_schema(conn, 404)
|
json_response_and_validate_schema(conn, 404)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "get status source" do
|
||||||
|
setup do
|
||||||
|
%{conn: build_conn()}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it returns the source", %{conn: conn} do
|
||||||
|
user = insert(:user)
|
||||||
|
|
||||||
|
{:ok, activity} = CommonAPI.post(user, %{status: "mew mew #abc", spoiler_text: "#def"})
|
||||||
|
|
||||||
|
conn = get(conn, "/api/v1/statuses/#{activity.id}/source")
|
||||||
|
|
||||||
|
id = activity.id
|
||||||
|
|
||||||
|
assert %{"id" => ^id, "text" => "mew mew #abc", "spoiler_text" => "#def"} =
|
||||||
|
json_response_and_validate_schema(conn, 200)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "update status" do
|
||||||
|
setup do
|
||||||
|
oauth_access(["write:statuses"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it updates the status" do
|
||||||
|
%{conn: conn, user: user} = oauth_access(["write:statuses", "read:statuses"])
|
||||||
|
|
||||||
|
{:ok, activity} = CommonAPI.post(user, %{status: "mew mew #abc", spoiler_text: "#def"})
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> get("/api/v1/statuses/#{activity.id}")
|
||||||
|
|> json_response_and_validate_schema(200)
|
||||||
|
|
||||||
|
response =
|
||||||
|
conn
|
||||||
|
|> put_req_header("content-type", "application/json")
|
||||||
|
|> put("/api/v1/statuses/#{activity.id}", %{
|
||||||
|
"status" => "edited",
|
||||||
|
"spoiler_text" => "lol"
|
||||||
|
})
|
||||||
|
|> json_response_and_validate_schema(200)
|
||||||
|
|
||||||
|
assert response["content"] == "edited"
|
||||||
|
assert response["spoiler_text"] == "lol"
|
||||||
|
|
||||||
|
response =
|
||||||
|
conn
|
||||||
|
|> get("/api/v1/statuses/#{activity.id}")
|
||||||
|
|> json_response_and_validate_schema(200)
|
||||||
|
|
||||||
|
assert response["content"] == "edited"
|
||||||
|
assert response["spoiler_text"] == "lol"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it updates the attachments", %{conn: conn, user: user} do
|
||||||
|
attachment = insert(:attachment, user: user)
|
||||||
|
attachment_id = to_string(attachment.id)
|
||||||
|
|
||||||
|
{:ok, activity} = CommonAPI.post(user, %{status: "mew mew #abc", spoiler_text: "#def"})
|
||||||
|
|
||||||
|
response =
|
||||||
|
conn
|
||||||
|
|> put_req_header("content-type", "application/json")
|
||||||
|
|> put("/api/v1/statuses/#{activity.id}", %{
|
||||||
|
"status" => "mew mew #abc",
|
||||||
|
"spoiler_text" => "#def",
|
||||||
|
"media_ids" => [attachment_id]
|
||||||
|
})
|
||||||
|
|> json_response_and_validate_schema(200)
|
||||||
|
|
||||||
|
assert [%{"id" => ^attachment_id}] = response["media_attachments"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it does not update visibility", %{conn: conn, user: user} do
|
||||||
|
{:ok, activity} =
|
||||||
|
CommonAPI.post(user, %{
|
||||||
|
status: "mew mew #abc",
|
||||||
|
spoiler_text: "#def",
|
||||||
|
visibility: "private"
|
||||||
|
})
|
||||||
|
|
||||||
|
response =
|
||||||
|
conn
|
||||||
|
|> put_req_header("content-type", "application/json")
|
||||||
|
|> put("/api/v1/statuses/#{activity.id}", %{
|
||||||
|
"status" => "edited",
|
||||||
|
"spoiler_text" => "lol"
|
||||||
|
})
|
||||||
|
|> json_response_and_validate_schema(200)
|
||||||
|
|
||||||
|
assert response["visibility"] == "private"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it refuses to update when original post is not by the user", %{conn: conn} do
|
||||||
|
another_user = insert(:user)
|
||||||
|
|
||||||
|
{:ok, activity} =
|
||||||
|
CommonAPI.post(another_user, %{status: "mew mew #abc", spoiler_text: "#def"})
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_req_header("content-type", "application/json")
|
||||||
|
|> put("/api/v1/statuses/#{activity.id}", %{
|
||||||
|
"status" => "edited",
|
||||||
|
"spoiler_text" => "lol"
|
||||||
|
})
|
||||||
|
|> json_response_and_validate_schema(:forbidden)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it returns 404 if the user cannot see the post", %{conn: conn} do
|
||||||
|
another_user = insert(:user)
|
||||||
|
|
||||||
|
{:ok, activity} =
|
||||||
|
CommonAPI.post(another_user, %{
|
||||||
|
status: "mew mew #abc",
|
||||||
|
spoiler_text: "#def",
|
||||||
|
visibility: "private"
|
||||||
|
})
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_req_header("content-type", "application/json")
|
||||||
|
|> put("/api/v1/statuses/#{activity.id}", %{
|
||||||
|
"status" => "edited",
|
||||||
|
"spoiler_text" => "lol"
|
||||||
|
})
|
||||||
|
|> json_response_and_validate_schema(:not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -285,6 +285,32 @@ test "Report notification" do
|
||||||
test_notifications_rendering([notification], moderator_user, [expected])
|
test_notifications_rendering([notification], moderator_user, [expected])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "Edit notification" do
|
||||||
|
user = insert(:user)
|
||||||
|
repeat_user = insert(:user)
|
||||||
|
|
||||||
|
{:ok, activity} = CommonAPI.post(user, %{status: "mew"})
|
||||||
|
{:ok, _} = CommonAPI.repeat(activity.id, repeat_user)
|
||||||
|
{:ok, update} = CommonAPI.update(user, activity, %{status: "mew mew"})
|
||||||
|
|
||||||
|
user = Pleroma.User.get_by_ap_id(user.ap_id)
|
||||||
|
activity = Pleroma.Activity.normalize(activity)
|
||||||
|
update = Pleroma.Activity.normalize(update)
|
||||||
|
|
||||||
|
{:ok, [notification]} = Notification.create_notifications(update)
|
||||||
|
|
||||||
|
expected = %{
|
||||||
|
id: to_string(notification.id),
|
||||||
|
pleroma: %{is_seen: false, is_muted: false},
|
||||||
|
type: "update",
|
||||||
|
account: AccountView.render("show.json", %{user: user, for: repeat_user}),
|
||||||
|
created_at: Utils.to_masto_date(notification.inserted_at),
|
||||||
|
status: StatusView.render("show.json", %{activity: activity, for: repeat_user})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_notifications_rendering([notification], repeat_user, [expected])
|
||||||
|
end
|
||||||
|
|
||||||
test "muted notification" do
|
test "muted notification" do
|
||||||
user = insert(:user)
|
user = insert(:user)
|
||||||
another_user = insert(:user)
|
another_user = insert(:user)
|
||||||
|
|
|
@ -267,6 +267,7 @@ test "a note activity" do
|
||||||
content: HTML.filter_tags(object_data["content"]),
|
content: HTML.filter_tags(object_data["content"]),
|
||||||
text: nil,
|
text: nil,
|
||||||
created_at: created_at,
|
created_at: created_at,
|
||||||
|
edited_at: nil,
|
||||||
reblogs_count: 0,
|
reblogs_count: 0,
|
||||||
replies_count: 0,
|
replies_count: 0,
|
||||||
favourites_count: 0,
|
favourites_count: 0,
|
||||||
|
@ -788,4 +789,55 @@ test "has a field for parent visibility" do
|
||||||
status = StatusView.render("show.json", activity: visible, for: poster)
|
status = StatusView.render("show.json", activity: visible, for: poster)
|
||||||
assert status.pleroma.parent_visible
|
assert status.pleroma.parent_visible
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "it shows edited_at" do
|
||||||
|
poster = insert(:user)
|
||||||
|
|
||||||
|
{:ok, post} = CommonAPI.post(poster, %{status: "hey"})
|
||||||
|
|
||||||
|
status = StatusView.render("show.json", activity: post)
|
||||||
|
refute status.edited_at
|
||||||
|
|
||||||
|
{:ok, _} = CommonAPI.update(poster, post, %{status: "mew mew"})
|
||||||
|
edited = Pleroma.Activity.normalize(post)
|
||||||
|
|
||||||
|
status = StatusView.render("show.json", activity: edited)
|
||||||
|
assert status.edited_at
|
||||||
|
end
|
||||||
|
|
||||||
|
test "with a source object" do
|
||||||
|
note =
|
||||||
|
insert(:note,
|
||||||
|
data: %{"source" => %{"content" => "object source", "mediaType" => "text/markdown"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
activity = insert(:note_activity, note: note)
|
||||||
|
|
||||||
|
status = StatusView.render("show.json", activity: activity, with_source: true)
|
||||||
|
assert status.text == "object source"
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "source.json" do
|
||||||
|
test "with a source object, renders both source and content type" do
|
||||||
|
note =
|
||||||
|
insert(:note,
|
||||||
|
data: %{"source" => %{"content" => "object source", "mediaType" => "text/markdown"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
activity = insert(:note_activity, note: note)
|
||||||
|
|
||||||
|
status = StatusView.render("source.json", activity: activity)
|
||||||
|
assert status.text == "object source"
|
||||||
|
assert status.content_type == "text/markdown"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "with a source string, renders source and put text/plain as the content type" do
|
||||||
|
note = insert(:note, data: %{"source" => "string source"})
|
||||||
|
activity = insert(:note_activity, note: note)
|
||||||
|
|
||||||
|
status = StatusView.render("source.json", activity: activity)
|
||||||
|
assert status.text == "string source"
|
||||||
|
assert status.content_type == "text/plain"
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
defmodule Pleroma.Web.Metadata.UtilsTest do
|
defmodule Pleroma.Web.Metadata.UtilsTest do
|
||||||
use Pleroma.DataCase, async: true
|
use Pleroma.DataCase, async: false
|
||||||
import Pleroma.Factory
|
import Pleroma.Factory
|
||||||
alias Pleroma.Web.Metadata.Utils
|
alias Pleroma.Web.Metadata.Utils
|
||||||
|
|
||||||
|
@ -22,6 +22,20 @@ test "it returns text without encode HTML" do
|
||||||
|
|
||||||
assert Utils.scrub_html_and_truncate(note) == "Pleroma's really cool!"
|
assert Utils.scrub_html_and_truncate(note) == "Pleroma's really cool!"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "it does not return old content after editing" do
|
||||||
|
user = insert(:user)
|
||||||
|
|
||||||
|
{:ok, activity} = Pleroma.Web.CommonAPI.post(user, %{status: "mew mew #def"})
|
||||||
|
|
||||||
|
object = Pleroma.Object.normalize(activity)
|
||||||
|
assert Utils.scrub_html_and_truncate(object) == "mew mew #def"
|
||||||
|
|
||||||
|
{:ok, update} = Pleroma.Web.CommonAPI.update(user, activity, %{status: "mew mew #abc"})
|
||||||
|
update = Pleroma.Activity.normalize(update)
|
||||||
|
object = Pleroma.Object.normalize(update)
|
||||||
|
assert Utils.scrub_html_and_truncate(object) == "mew mew #abc"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "scrub_html_and_truncate/2" do
|
describe "scrub_html_and_truncate/2" do
|
||||||
|
|
|
@ -383,6 +383,33 @@ test "it sends follow relationships updates to the 'user' stream", %{
|
||||||
"state" => "follow_accept"
|
"state" => "follow_accept"
|
||||||
} = Jason.decode!(payload)
|
} = Jason.decode!(payload)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "it streams edits in the 'user' stream", %{user: user, token: oauth_token} do
|
||||||
|
sender = insert(:user)
|
||||||
|
{:ok, _, _, _} = CommonAPI.follow(user, sender)
|
||||||
|
|
||||||
|
{:ok, activity} = CommonAPI.post(sender, %{status: "hey"})
|
||||||
|
|
||||||
|
Streamer.get_topic_and_add_socket("user", user, oauth_token)
|
||||||
|
{:ok, edited} = CommonAPI.update(sender, activity, %{status: "mew mew"})
|
||||||
|
create = Pleroma.Activity.get_create_by_object_ap_id_with_object(activity.object.data["id"])
|
||||||
|
|
||||||
|
stream = "user:#{user.id}"
|
||||||
|
assert_receive {:render_with_user, _, "status_update.json", ^create, ^stream}
|
||||||
|
refute Streamer.filtered_by_user?(user, edited)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it streams own edits in the 'user' stream", %{user: user, token: oauth_token} do
|
||||||
|
{:ok, activity} = CommonAPI.post(user, %{status: "hey"})
|
||||||
|
|
||||||
|
Streamer.get_topic_and_add_socket("user", user, oauth_token)
|
||||||
|
{:ok, edited} = CommonAPI.update(user, activity, %{status: "mew mew"})
|
||||||
|
create = Pleroma.Activity.get_create_by_object_ap_id_with_object(activity.object.data["id"])
|
||||||
|
|
||||||
|
stream = "user:#{user.id}"
|
||||||
|
assert_receive {:render_with_user, _, "status_update.json", ^create, ^stream}
|
||||||
|
refute Streamer.filtered_by_user?(user, edited)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "public streams" do
|
describe "public streams" do
|
||||||
|
@ -425,6 +452,54 @@ test "handles deletions" do
|
||||||
assert_receive {:text, event}
|
assert_receive {:text, event}
|
||||||
assert %{"event" => "delete", "payload" => ^activity_id} = Jason.decode!(event)
|
assert %{"event" => "delete", "payload" => ^activity_id} = Jason.decode!(event)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "it streams edits in the 'public' stream" do
|
||||||
|
sender = insert(:user)
|
||||||
|
|
||||||
|
Streamer.get_topic_and_add_socket("public", nil, nil)
|
||||||
|
{:ok, activity} = CommonAPI.post(sender, %{status: "hey"})
|
||||||
|
assert_receive {:text, _}
|
||||||
|
|
||||||
|
{:ok, edited} = CommonAPI.update(sender, activity, %{status: "mew mew"})
|
||||||
|
|
||||||
|
edited = Pleroma.Activity.normalize(edited)
|
||||||
|
|
||||||
|
%{id: activity_id} = Pleroma.Activity.get_create_by_object_ap_id(edited.object.data["id"])
|
||||||
|
|
||||||
|
assert_receive {:text, event}
|
||||||
|
assert %{"event" => "status.update", "payload" => payload} = Jason.decode!(event)
|
||||||
|
assert %{"id" => ^activity_id} = Jason.decode!(payload)
|
||||||
|
refute Streamer.filtered_by_user?(sender, edited)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it streams multiple edits in the 'public' stream correctly" do
|
||||||
|
sender = insert(:user)
|
||||||
|
|
||||||
|
Streamer.get_topic_and_add_socket("public", nil, nil)
|
||||||
|
{:ok, activity} = CommonAPI.post(sender, %{status: "hey"})
|
||||||
|
assert_receive {:text, _}
|
||||||
|
|
||||||
|
{:ok, edited} = CommonAPI.update(sender, activity, %{status: "mew mew"})
|
||||||
|
|
||||||
|
edited = Pleroma.Activity.normalize(edited)
|
||||||
|
|
||||||
|
%{id: activity_id} = Pleroma.Activity.get_create_by_object_ap_id(edited.object.data["id"])
|
||||||
|
|
||||||
|
assert_receive {:text, event}
|
||||||
|
assert %{"event" => "status.update", "payload" => payload} = Jason.decode!(event)
|
||||||
|
assert %{"id" => ^activity_id} = Jason.decode!(payload)
|
||||||
|
refute Streamer.filtered_by_user?(sender, edited)
|
||||||
|
|
||||||
|
{:ok, edited} = CommonAPI.update(sender, activity, %{status: "mew mew 2"})
|
||||||
|
|
||||||
|
edited = Pleroma.Activity.normalize(edited)
|
||||||
|
|
||||||
|
%{id: activity_id} = Pleroma.Activity.get_create_by_object_ap_id(edited.object.data["id"])
|
||||||
|
assert_receive {:text, event}
|
||||||
|
assert %{"event" => "status.update", "payload" => payload} = Jason.decode!(event)
|
||||||
|
assert %{"id" => ^activity_id, "content" => "mew mew 2"} = Jason.decode!(payload)
|
||||||
|
refute Streamer.filtered_by_user?(sender, edited)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "thread_containment/2" do
|
describe "thread_containment/2" do
|
||||||
|
|
|
@ -111,6 +111,18 @@ def note_factory(attrs \\ %{}) do
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def attachment_factory(attrs \\ %{}) do
|
||||||
|
user = attrs[:user] || insert(:user)
|
||||||
|
|
||||||
|
data =
|
||||||
|
attachment_data(user.ap_id, nil)
|
||||||
|
|> Map.put("id", Pleroma.Web.ActivityPub.Utils.generate_object_id())
|
||||||
|
|
||||||
|
%Pleroma.Object{
|
||||||
|
data: merge_attributes(data, Map.get(attrs, :data, %{}))
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def attachment_note_factory(attrs \\ %{}) do
|
def attachment_note_factory(attrs \\ %{}) do
|
||||||
user = attrs[:user] || insert(:user)
|
user = attrs[:user] || insert(:user)
|
||||||
{length, attrs} = Map.pop(attrs, :length, 1)
|
{length, attrs} = Map.pop(attrs, :length, 1)
|
||||||
|
|
Loading…
Reference in a new issue