Merge remote-tracking branch 'tusooa/from/upstream-develop/tusooa/edits' into emr_develop

This commit is contained in:
a1batross 2022-06-09 14:32:59 +02:00
commit dea83d3cc1
40 changed files with 1610 additions and 56 deletions

View File

@ -17,11 +17,11 @@ su pleroma -s $SHELL -lc "./bin/pleroma_ctl migrate"
## For from source installations (using git) ## For from source installations (using git)
1. Go to the working directory of Pleroma (default is `/opt/pleroma`) 1. Go to the working directory of Pleroma (default is `/opt/pleroma`)
2. Run `git pull`. This pulls the latest changes from upstream. 2. Run `git pull` [^1]. This pulls the latest changes from upstream.
3. Run `mix deps.get` [^1]. This pulls in any new dependencies. 3. Run `mix deps.get` [^1]. This pulls in any new dependencies.
4. Stop the Pleroma service. 4. Stop the Pleroma service.
5. Run `mix ecto.migrate` [^1] [^2]. This task performs database migrations, if there were any. 5. Run `mix ecto.migrate` [^1] [^2]. This task performs database migrations, if there were any.
6. Start the Pleroma service. 6. Start the Pleroma service.
[^1]: Depending on which install guide you followed (for example on Debian/Ubuntu), you want to run `mix` tasks as `pleroma` user by adding `sudo -Hu pleroma` before the command. [^1]: Depending on which install guide you followed (for example on Debian/Ubuntu), you want to run `git` and `mix` tasks as `pleroma` user by adding `sudo -Hu pleroma` before the command.
[^2]: Prefix with `MIX_ENV=prod` to run it using the production config file. [^2]: Prefix with `MIX_ENV=prod` to run it using the production config file.

View File

@ -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`:

View File

@ -27,4 +27,28 @@ 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(actor_types,
do: [
"Application",
"Group",
"Organization",
"Person",
"Service"
]
)
end end

View File

@ -385,7 +385,7 @@ defmodule Pleroma.Notification do
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
@ -439,6 +439,9 @@ defmodule Pleroma.Notification 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
@ -513,7 +516,16 @@ defmodule Pleroma.Notification 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 =
@ -553,6 +565,21 @@ defmodule Pleroma.Notification do
(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)

View File

@ -425,4 +425,46 @@ defmodule Pleroma.Object do
end end
def object_data_hashtags(_), do: [] def object_data_hashtags(_), do: []
def history_for(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
_ -> history_skeleton()
end
end
defp history_skeleton do
%{
"type" => "OrderedCollection",
"totalItems" => 0,
"orderedItems" => []
}
end
def maybe_update_history(updated_object, orig_object_data, updated) do
if not updated do
updated_object
else
# Put edit history
# Note that we may have got the edit history by first fetching the object
history = Object.history_for(orig_object_data)
latest_history_item =
orig_object_data
|> Map.drop(["id", "formerRepresentations"])
new_history =
history
|> Map.put("orderedItems", [latest_history_item | history["orderedItems"]])
|> Map.put("totalItems", history["totalItems"] + 1)
updated_object
|> Map.put("formerRepresentations", new_history)
end
end
end end

View File

@ -26,8 +26,35 @@ defmodule Pleroma.Object.Fetcher 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)
new_data |> Object.maybe_update_history(old_data, changed?)
else
new_data
end
Map.merge(new_data, internal_fields) Map.merge(new_data, internal_fields)
end end

View File

@ -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 @@ defmodule Pleroma.Upload 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" => [

View File

@ -190,7 +190,16 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub 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)
@ -256,7 +265,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do
@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)

View File

@ -218,10 +218,16 @@ defmodule Pleroma.Web.ActivityPub.Builder 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,
%{ %{
@ -229,7 +235,8 @@ defmodule Pleroma.Web.ActivityPub.Builder 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

View File

@ -12,6 +12,14 @@ defmodule Pleroma.Web.ActivityPub.MRF.StealEmojiPolicy do
defp accept_host?(host), do: host in Config.get([:mrf_steal_emoji, :hosts], []) defp accept_host?(host), do: host in Config.get([:mrf_steal_emoji, :hosts], [])
defp shortcode_matches?(shortcode, pattern) when is_binary(pattern) do
shortcode == pattern
end
defp shortcode_matches?(shortcode, pattern) do
String.match?(shortcode, pattern)
end
defp steal_emoji({shortcode, url}, emoji_dir_path) do defp steal_emoji({shortcode, url}, emoji_dir_path) do
url = Pleroma.Web.MediaProxy.url(url) url = Pleroma.Web.MediaProxy.url(url)
@ -72,7 +80,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.StealEmojiPolicy do
reject_emoji? = reject_emoji? =
[:mrf_steal_emoji, :rejected_shortcodes] [:mrf_steal_emoji, :rejected_shortcodes]
|> Config.get([]) |> Config.get([])
|> Enum.find(false, fn regex -> String.match?(shortcode, regex) end) |> Enum.find(false, fn pattern -> shortcode_matches?(shortcode, pattern) end)
!reject_emoji? !reject_emoji?
end) end)
@ -122,8 +130,12 @@ defmodule Pleroma.Web.ActivityPub.MRF.StealEmojiPolicy do
%{ %{
key: :rejected_shortcodes, key: :rejected_shortcodes,
type: {:list, :string}, type: {:list, :string},
description: "Regex-list of shortcodes to reject", description: """
suggestions: [""] A list of patterns or matches to reject shortcodes with.
Each pattern can be a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`.
""",
suggestions: ["foo", ~r/foo/]
}, },
%{ %{
key: :size_limit, key: :size_limit,

View File

@ -51,7 +51,9 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UpdateValidator 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 ->

View File

@ -25,6 +25,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
@cachex Pleroma.Config.get([:cachex, :provider], Cachex) @cachex Pleroma.Config.get([:cachex, :provider], Cachex)
@ -153,23 +154,25 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
# 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)},
{_, user} <- {:user, Pleroma.User.get_by_ap_id(updated_object_id)} do
if 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:
@ -390,6 +393,96 @@ defmodule Pleroma.Web.ActivityPub.SideEffects 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
@updatable_object_types ["Note", "Question"]
defp 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 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
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
defp handle_update_object(
%{data: %{"type" => "Update", "object" => updated_object}} = object,
meta
) do
orig_object = Object.get_by_ap_id(updated_object["id"])
orig_object_data = orig_object.data
if orig_object_data["type"] in @updatable_object_types do
%{data: updated_object_data, updated: updated} =
orig_object_data
|> update_content_fields(updated_object)
updated_object_data =
updated_object_data
|> Object.maybe_update_history(orig_object_data, updated)
|> maybe_update_poll(updated_object)
orig_object
|> Repo.preload(:hashtags)
|> Object.change(%{data: updated_object_data})
|> Object.update_and_set_cache()
if updated do
object
|> Activity.normalize()
|> ActivityPub.notify_and_stream()
end
end
{:ok, object, meta}
end
def handle_object_creation(%{"type" => "ChatMessage"} = object, _activity, meta) do def handle_object_creation(%{"type" => "ChatMessage"} = object, _activity, meta) do
with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do
actor = User.get_cached_by_ap_id(object.data["actor"]) actor = User.get_cached_by_ap_id(object.data["actor"])

View File

@ -902,7 +902,24 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
end end
def strip_internal_fields(object) do def strip_internal_fields(object) do
Map.drop(object, Pleroma.Constants.object_internal_fields()) outer = Map.drop(object, Pleroma.Constants.object_internal_fields())
case outer do
%{"formerRepresentations" => %{"orderedItems" => list}} when is_list(list) ->
update_in(
outer["formerRepresentations"]["orderedItems"],
&Enum.map(
&1,
fn
item when is_map(item) -> Map.drop(item, Pleroma.Constants.object_internal_fields())
item -> item
end
)
)
_ ->
outer
end
end end
defp strip_internal_tags(%{"tag" => tags} = object) do defp strip_internal_tags(%{"tag" => tags} = object) do

View File

@ -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
@ -434,6 +438,59 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation 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
@ -537,6 +594,60 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation 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,
@ -579,6 +690,87 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation 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",

View File

@ -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,

View File

@ -402,6 +402,42 @@ defmodule Pleroma.Web.CommonAPI 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)
}
params = Map.merge(changes, kept_params)
with {:ok, draft} <- ActivityDraft.create(user, params) do
change =
Pleroma.Constants.status_updatable_fields()
|> Enum.reduce(orig_object.data, fn key, acc ->
if Map.has_key?(draft.object, key) do
acc |> Map.put(key, Map.get(draft.object, key))
else
acc |> Map.drop([key])
end
end)
|> Map.put("updated", 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),

View File

@ -224,7 +224,10 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do
object = object =
note_data note_data
|> Map.put("emoji", emoji) |> Map.put("emoji", emoji)
|> Map.put("source", draft.status) |> Map.put("source", %{
"content" => draft.status,
"mediaType" => Utils.get_content_type(draft.params[:content_type])
})
|> Map.put("generator", draft.params[:generator]) |> Map.put("generator", draft.params[:generator])
%__MODULE__{draft | object: object} %__MODULE__{draft | object: object}

View File

@ -37,7 +37,7 @@ defmodule Pleroma.Web.CommonAPI.Utils 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 @@ defmodule Pleroma.Web.CommonAPI.Utils 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 @@ defmodule Pleroma.Web.CommonAPI.Utils 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

View File

@ -51,6 +51,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController 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 =

View File

@ -38,7 +38,9 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
:index, :index,
:show, :show,
:card, :card,
:context :context,
:show_history,
:show_source
] ]
) )
@ -49,7 +51,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
:create, :create,
:delete, :delete,
:reblog, :reblog,
:unreblog :unreblog,
:update
] ]
) )
@ -191,6 +194,57 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
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: %{user: user}} = conn, %{id: id} = params) do
with %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: %{user: user}} = conn, %{id: id} = _params) do
with %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),

View File

@ -19,7 +19,11 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MastodonAPI.StatusView
alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView
@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)
@ -30,7 +34,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
%{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()
@ -78,9 +82,9 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
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
@ -109,6 +113,9 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
"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, %{})

View File

@ -258,6 +258,16 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
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"])
@ -344,8 +354,9 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
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,
@ -384,6 +395,82 @@ defmodule Pleroma.Web.MastodonAPI.StatusView 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.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]
individual_opts =
opts
|> Map.put(:as, :object)
|> 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, object: object, 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"
)
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)
@ -436,10 +523,19 @@ defmodule Pleroma.Web.MastodonAPI.StatusView 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,
@ -601,4 +697,24 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
end end
defp build_image_url(_, _), do: nil defp build_image_url(_, _), do: nil
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

View File

@ -552,6 +552,9 @@ defmodule Pleroma.Web.Router do
get("/bookmarks", StatusController, :bookmarks) get("/bookmarks", StatusController, :bookmarks)
post("/statuses", StatusController, :create) post("/statuses", StatusController, :create)
get("/statuses/:id/history", StatusController, :show_history)
get("/statuses/:id/source", StatusController, :show_source)
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)

View File

@ -296,6 +296,20 @@ defmodule Pleroma.Web.Streamer do
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
anon_render = StreamerView.render("status_update.json", item)
Registry.dispatch(@registry, topic, fn list ->
Enum.each(list, fn {pid, auth?} ->
if auth? do
send(pid, {:render_with_user, StreamerView, "status_update.json", item})
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) anon_render = StreamerView.render("update.json", item)

View File

@ -25,6 +25,22 @@ defmodule Pleroma.Web.StreamerView do
|> Jason.encode!() |> Jason.encode!()
end end
def render("status_update.json", %Activity{} = activity, %User{} = user) do
activity = Activity.get_create_by_object_ap_id_with_object(activity.object.data["id"])
%{
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) do def render("notification.json", %Notification{} = notify, %User{} = user) do
%{ %{
event: "notification", event: "notification",
@ -51,6 +67,21 @@ defmodule Pleroma.Web.StreamerView do
|> Jason.encode!() |> Jason.encode!()
end end
def render("status_update.json", %Activity{} = activity) do
activity = Activity.get_create_by_object_ap_id_with_object(activity.object.data["id"])
%{
event: "status.update",
payload:
Pleroma.Web.MastodonAPI.StatusView.render(
"show.json",
activity: activity
)
|> Jason.encode!()
}
|> Jason.encode!()
end
def render("chat_update.json", %{chat_message_reference: cm_ref}) do def render("chat_update.json", %{chat_message_reference: cm_ref}) do
# Explicitly giving the cmr for the object here, so we don't accidentally # Explicitly giving the cmr for the object here, so we don't accidentally
# send a later 'last_message' that was inserted between inserting this and # send a later 'last_message' that was inserted between inserting this and

View File

@ -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

View File

@ -36,7 +36,8 @@
"@id": "as:alsoKnownAs", "@id": "as:alsoKnownAs",
"@type": "@id" "@type": "@id"
}, },
"vcard": "http://www.w3.org/2006/vcard/ns#" "vcard": "http://www.w3.org/2006/vcard/ns#",
"formerRepresentations": "litepub:formerRepresentations"
} }
] ]
} }

View File

@ -127,6 +127,28 @@ defmodule Pleroma.NotificationTest do
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
@ -839,6 +861,30 @@ defmodule Pleroma.NotificationTest do
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

View File

@ -269,4 +269,191 @@ defmodule Pleroma.Object.FetcherTest 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
end end

View File

@ -49,20 +49,22 @@ defmodule Pleroma.UploadTest do
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

View File

@ -60,7 +60,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.StealEmojiPolicyTest do
|> File.exists?() |> File.exists?()
end end
test "reject shortcode", %{message: message} do test "reject regex shortcode", %{message: message} do
refute "firedfox" in installed() refute "firedfox" in installed()
clear_config(:mrf_steal_emoji, clear_config(:mrf_steal_emoji,
@ -74,6 +74,20 @@ defmodule Pleroma.Web.ActivityPub.MRF.StealEmojiPolicyTest do
refute "firedfox" in installed() refute "firedfox" in installed()
end end
test "reject string shortcode", %{message: message} do
refute "firedfox" in installed()
clear_config(:mrf_steal_emoji,
hosts: ["example.org"],
size_limit: 284_468,
rejected_shortcodes: ["firedfox"]
)
assert {:ok, _message} = StealEmojiPolicy.filter(message)
refute "firedfox" in installed()
end
test "reject if size is above the limit", %{message: message} do test "reject if size is above the limit", %{message: message} do
refute "firedfox" in installed() refute "firedfox" in installed()

View File

@ -32,7 +32,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UpdateHandlingTest 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,27 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UpdateHandlingTest do
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 end
end end

View File

@ -140,6 +140,127 @@ defmodule Pleroma.Web.ActivityPub.SideEffectsTest do
end end
end end
describe "update notes" do
setup do
user = insert(:user)
note = insert(:note, user: user)
_note_activity = insert(:note_activity, note: note)
updated_note =
note.data
|> Map.put("summary", "edited summary")
|> Map.put("content", "edited content")
{: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}
end
test "it updates the note", %{object_id: object_id, update: update} do
{:ok, _, _} = SideEffects.handle(update)
new_note = Pleroma.Object.get_by_id(object_id)
assert %{"summary" => "edited summary", "content" => "edited content"} = new_note.data
end
test "it records the original note in formerRepresentations", %{
note: note,
object_id: object_id,
update: update
} do
{:ok, _, _} = SideEffects.handle(update)
%{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
} do
{:ok, _, _} = SideEffects.handle(update)
%{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")
{:ok, second_update_data, []} = Builder.update(user, second_updated_note)
{:ok, update, _meta} = ActivityPub.persist(second_update_data, local: true)
{:ok, _, _} = SideEffects.handle(update)
%{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
} do
{:ok, _, _} = SideEffects.handle(update)
%{data: _first_edit} = Pleroma.Object.get_by_id(object_id)
{:ok, _, _} = SideEffects.handle(update)
%{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)
%{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)
{:ok, update_data, []} = Builder.update(user, updated_question)
{:ok, update, _meta} = ActivityPub.persist(update_data, local: true)
{:ok, _, _} = SideEffects.handle(update)
%{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)

View File

@ -575,4 +575,58 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do
assert Transmogrifier.fix_attachments(object) == expected assert Transmogrifier.fix_attachments(object) == expected
end end
end end
describe "strip_internal_fields/1" do
test "it strips internal fields in formerRepresentations" do
original = %{
"formerRepresentations" => %{
"orderedItems" => [
%{"generator" => %{}}
]
}
}
stripped = Transmogrifier.strip_internal_fields(original)
refute Map.has_key?(
Enum.at(stripped["formerRepresentations"]["orderedItems"], 0),
"generator"
)
end
test "it strips internal fields in maybe badly-formed formerRepresentations" do
original = %{
"formerRepresentations" => %{
"orderedItems" => [
%{"generator" => %{}},
"https://example.com/1"
]
}
}
stripped = Transmogrifier.strip_internal_fields(original)
refute Map.has_key?(
Enum.at(stripped["formerRepresentations"]["orderedItems"], 0),
"generator"
)
assert Enum.at(stripped["formerRepresentations"]["orderedItems"], 1) ==
"https://example.com/1"
end
test "it ignores if formerRepresentations does not look like an OrderedCollection" do
original = %{
"formerRepresentations" => %{
"items" => [
%{"generator" => %{}}
]
}
}
stripped = Transmogrifier.strip_internal_fields(original)
assert Map.has_key?(Enum.at(stripped["formerRepresentations"]["items"], 0), "generator")
end
end
end end

View File

@ -586,7 +586,7 @@ defmodule Pleroma.Web.CommonAPITest do
object = Object.normalize(activity, fetch: false) object = Object.normalize(activity, fetch: false)
assert object.data["content"] == "<p><b>2hu</b></p>alert(&#39;xss&#39;)" assert object.data["content"] == "<p><b>2hu</b></p>alert(&#39;xss&#39;)"
assert object.data["source"] == post assert object.data["source"]["content"] == post
end end
test "it filters out obviously bad tags when accepting a post as Markdown" do test "it filters out obviously bad tags when accepting a post as Markdown" do
@ -603,7 +603,7 @@ defmodule Pleroma.Web.CommonAPITest do
object = Object.normalize(activity, fetch: false) object = Object.normalize(activity, fetch: false)
assert object.data["content"] == "<p><b>2hu</b></p>" assert object.data["content"] == "<p><b>2hu</b></p>"
assert object.data["source"] == post assert object.data["source"]["content"] == post
end end
test "it does not allow replies to direct messages that are not direct messages themselves" do test "it does not allow replies to direct messages that are not direct messages themselves" do
@ -1541,4 +1541,33 @@ defmodule Pleroma.Web.CommonAPITest 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
end
end end

View File

@ -1990,4 +1990,164 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do
} = result } = result
end end
end end
describe "get status history" do
setup do
oauth_access(["read:statuses"])
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 2"}, %{"spoiler_text" => "title 1"}] =
json_response_and_validate_schema(conn, 200)
end
end
describe "get status source" do
setup do
oauth_access(["read:statuses"])
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", %{conn: conn, user: user} do
{: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" => "edited",
"spoiler_text" => "lol"
})
|> 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

View File

@ -237,6 +237,32 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest 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)

View File

@ -246,6 +246,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest 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,
@ -708,4 +709,55 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest 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

View File

@ -442,6 +442,31 @@ defmodule Pleroma.Web.StreamerTest do
"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"})
edited = Pleroma.Activity.normalize(edited)
assert_receive {:render_with_user, _, "status_update.json", ^edited}
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"})
edited = Pleroma.Activity.normalize(edited)
assert_receive {:render_with_user, _, "status_update.json", ^edited}
refute Streamer.filtered_by_user?(user, edited)
end
end end
describe "public streams" do describe "public streams" do
@ -484,6 +509,25 @@ defmodule Pleroma.Web.StreamerTest 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
end end
describe "thread_containment/2" do describe "thread_containment/2" do

View File

@ -111,6 +111,18 @@ defmodule Pleroma.Factory 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)