Page MenuHomePhorge

No OneTemporary

Size
42 KB
Referenced Files
None
Subscribers
None
diff --git a/changelog.d/notification-type-update.fix b/changelog.d/notification-type-update.fix
new file mode 100644
index 000000000..ee864000c
--- /dev/null
+++ b/changelog.d/notification-type-update.fix
@@ -0,0 +1 @@
+Add `update` to @notification_types
diff --git a/changelog.d/typo.skip b/changelog.d/typo.skip
new file mode 100644
index 000000000..721ba96c5
--- /dev/null
+++ b/changelog.d/typo.skip
@@ -0,0 +1 @@
+Fix typo in test name
diff --git a/changelog.d/update-poll-voters-count.fix b/changelog.d/update-poll-voters-count.fix
new file mode 100644
index 000000000..557c2b0df
--- /dev/null
+++ b/changelog.d/update-poll-voters-count.fix
@@ -0,0 +1 @@
+Update voters count in remote polls when refreshing
diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex
index d8058e617..1240a98d4 100644
--- a/lib/pleroma/notification.ex
+++ b/lib/pleroma/notification.ex
@@ -1,863 +1,864 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Notification do
use Ecto.Schema
alias Ecto.Multi
alias Pleroma.Activity
alias Pleroma.FollowingRelationship
alias Pleroma.Marker
alias Pleroma.Notification
alias Pleroma.Object
alias Pleroma.Pagination
alias Pleroma.Repo
alias Pleroma.ThreadMute
alias Pleroma.User
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.CommonAPI.Utils
alias Pleroma.Web.Push
alias Pleroma.Web.Streamer
import Ecto.Query
import Ecto.Changeset
require Logger
@type t :: %__MODULE__{}
@include_muted_option :with_muted
schema "notifications" do
field(:seen, :boolean, default: false)
# This is an enum type in the database. If you add a new notification type,
# remember to add a migration to add it to the `notifications_type` enum
# as well.
field(:type, :string)
belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType)
timestamps()
end
def update_notification_type(user, activity) do
with %__MODULE__{} = notification <-
Repo.get_by(__MODULE__, user_id: user.id, activity_id: activity.id) do
type =
activity
|> type_from_activity()
notification
|> changeset(%{type: type})
|> Repo.update()
end
end
@spec unread_notifications_count(User.t()) :: integer()
def unread_notifications_count(%User{id: user_id}) do
from(q in __MODULE__,
where: q.user_id == ^user_id and q.seen == false
)
|> Repo.aggregate(:count, :id)
end
@notification_types ~w{
favourite
follow
follow_request
mention
move
pleroma:chat_mention
pleroma:emoji_reaction
pleroma:report
reblog
poll
status
+ update
pleroma:participation_accepted
pleroma:participation_request
pleroma:event_reminder
pleroma:event_update
}
def changeset(%Notification{} = notification, attrs) do
notification
|> cast(attrs, [:seen, :type])
|> validate_inclusion(:type, @notification_types)
end
@spec last_read_query(User.t()) :: Ecto.Queryable.t()
def last_read_query(user) do
from(q in Pleroma.Notification,
where: q.user_id == ^user.id,
where: q.seen == true,
select: type(q.id, :string),
limit: 1,
order_by: fragment("? desc nulls last", q.id)
)
end
defp for_user_query_ap_id_opts(user, opts) do
ap_id_relationships =
[:block] ++
if opts[@include_muted_option], do: [], else: [:notification_mute]
preloaded_ap_ids = User.outgoing_relationships_ap_ids(user, ap_id_relationships)
exclude_blocked_opts = Map.merge(%{blocked_users_ap_ids: preloaded_ap_ids[:block]}, opts)
exclude_notification_muted_opts =
Map.merge(%{notification_muted_users_ap_ids: preloaded_ap_ids[:notification_mute]}, opts)
{exclude_blocked_opts, exclude_notification_muted_opts}
end
def for_user_query(user, opts \\ %{}) do
{exclude_blocked_opts, exclude_notification_muted_opts} =
for_user_query_ap_id_opts(user, opts)
Notification
|> where(user_id: ^user.id)
|> join(:inner, [n], activity in assoc(n, :activity))
|> join(:left, [n, a], object in Object,
on:
fragment(
"(?->>'id') = associated_object_id(?)",
object.data,
a.data
)
)
|> join(:inner, [_n, a], u in User, on: u.ap_id == a.actor, as: :user_actor)
|> preload([n, a, o], activity: {a, object: o})
|> where([user_actor: user_actor], user_actor.is_active)
|> exclude_notification_muted(user, exclude_notification_muted_opts)
|> exclude_blocked(user, exclude_blocked_opts)
|> exclude_blockers(user)
|> exclude_filtered(user)
|> exclude_visibility(opts)
end
# Excludes blocked users and non-followed domain-blocked users
defp exclude_blocked(query, user, opts) do
blocked_ap_ids = opts[:blocked_users_ap_ids] || User.blocked_users_ap_ids(user)
query
|> where([..., user_actor: user_actor], user_actor.ap_id not in ^blocked_ap_ids)
|> FollowingRelationship.keep_following_or_not_domain_blocked(user)
end
defp exclude_blockers(query, user) do
if Pleroma.Config.get([:activitypub, :blockers_visible]) == true do
query
else
blocker_ap_ids = User.incoming_relationships_ungrouped_ap_ids(user, [:block])
query
|> where([..., user_actor: user_actor], user_actor.ap_id not in ^blocker_ap_ids)
end
end
defp exclude_notification_muted(query, _, %{@include_muted_option => true}) do
query
end
defp exclude_notification_muted(query, user, opts) do
notification_muted_ap_ids =
opts[:notification_muted_users_ap_ids] || User.notification_muted_users_ap_ids(user)
query
|> where([..., user_actor: user_actor], user_actor.ap_id not in ^notification_muted_ap_ids)
|> join(:left, [n, a], tm in ThreadMute,
on: tm.user_id == ^user.id and tm.context == fragment("?->>'context'", a.data),
as: :thread_mute
)
|> where([thread_mute: thread_mute], is_nil(thread_mute.user_id))
end
defp exclude_filtered(query, user) do
case Pleroma.Filter.compose_regex(user) do
nil ->
query
regex ->
from([_n, a, o] in query,
where:
fragment("not(?->>'content' ~* ?)", o.data, ^regex) or
fragment("?->>'content' is null", o.data) or
fragment("?->>'actor' = ?", o.data, ^user.ap_id)
)
end
end
@valid_visibilities ~w[direct unlisted public private]
defp exclude_visibility(query, %{exclude_visibilities: visibility})
when is_list(visibility) do
if Enum.all?(visibility, &(&1 in @valid_visibilities)) do
query
|> join(:left, [n, a], mutated_activity in Pleroma.Activity,
on:
fragment(
"associated_object_id(?)",
a.data
) ==
fragment(
"associated_object_id(?)",
mutated_activity.data
) and
fragment("(?->>'type' = 'Like' or ?->>'type' = 'Announce')", a.data, a.data) and
fragment("?->>'type'", mutated_activity.data) == "Create",
as: :mutated_activity
)
|> where(
[n, a, mutated_activity: mutated_activity],
not fragment(
"""
CASE WHEN (?->>'type') = 'Like' or (?->>'type') = 'Announce'
THEN (activity_visibility(?, ?, ?) = ANY (?))
ELSE (activity_visibility(?, ?, ?) = ANY (?)) END
""",
a.data,
a.data,
mutated_activity.actor,
mutated_activity.recipients,
mutated_activity.data,
^visibility,
a.actor,
a.recipients,
a.data,
^visibility
)
)
else
Logger.error("Could not exclude visibility to #{visibility}")
query
end
end
defp exclude_visibility(query, %{exclude_visibilities: visibility})
when visibility in @valid_visibilities do
exclude_visibility(query, [visibility])
end
defp exclude_visibility(query, %{exclude_visibilities: visibility})
when visibility not in @valid_visibilities do
Logger.error("Could not exclude visibility to #{visibility}")
query
end
defp exclude_visibility(query, _visibility), do: query
def for_user(user, opts \\ %{}) do
user
|> for_user_query(opts)
|> Pagination.fetch_paginated(opts)
end
@doc """
Returns notifications for user received since given date.
## Examples
iex> Pleroma.Notification.for_user_since(%Pleroma.User{}, ~N[2019-04-13 11:22:33])
[%Pleroma.Notification{}, %Pleroma.Notification{}]
iex> Pleroma.Notification.for_user_since(%Pleroma.User{}, ~N[2019-04-15 11:22:33])
[]
"""
@spec for_user_since(Pleroma.User.t(), NaiveDateTime.t()) :: [t()]
def for_user_since(user, date) do
from(n in for_user_query(user),
where: n.updated_at > ^date
)
|> Repo.all()
end
def set_read_up_to(%{id: user_id} = user, id) do
query =
from(
n in Notification,
where: n.user_id == ^user_id,
where: n.id <= ^id,
where: n.seen == false,
# Ideally we would preload object and activities here
# but Ecto does not support preloads in update_all
select: n.id
)
Multi.new()
|> Multi.update_all(:ids, query, set: [seen: true, updated_at: NaiveDateTime.utc_now()])
|> Marker.multi_set_last_read_id(user, "notifications")
|> Repo.transaction()
end
@spec read_one(User.t(), String.t()) ::
{:ok, Notification.t()} | {:error, Ecto.Changeset.t()} | nil
def read_one(%User{} = user, notification_id) do
with {:ok, %Notification{} = notification} <- get(user, notification_id) do
Multi.new()
|> Multi.update(:update, changeset(notification, %{seen: true}))
|> Marker.multi_set_last_read_id(user, "notifications")
|> Repo.transaction()
end
end
def get(%{id: user_id} = _user, id) do
query =
from(
n in Notification,
where: n.id == ^id,
join: activity in assoc(n, :activity),
preload: [activity: activity]
)
notification = Repo.one(query)
case notification do
%{user_id: ^user_id} ->
{:ok, notification}
_ ->
{:error, "Cannot get notification"}
end
end
def clear(user) do
from(n in Notification, where: n.user_id == ^user.id)
|> Repo.delete_all()
end
def destroy_multiple(%{id: user_id} = _user, ids) do
from(n in Notification,
where: n.id in ^ids,
where: n.user_id == ^user_id
)
|> Repo.delete_all()
end
def dismiss(%Pleroma.Activity{} = activity) do
Notification
|> where([n], n.activity_id == ^activity.id)
|> Repo.delete_all()
|> case do
{_, notifications} -> {:ok, notifications}
_ -> {:error, "Cannot dismiss notification"}
end
end
def dismiss(%{id: user_id} = _user, id) do
notification = Repo.get(Notification, id)
case notification do
%{user_id: ^user_id} ->
Repo.delete(notification)
_ ->
{:error, "Cannot dismiss notification"}
end
end
@spec create_notifications(Activity.t()) :: {:ok, [Notification.t()] | []}
def create_notifications(activity)
def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = activity) do
object = Object.normalize(activity, fetch: false)
if object && object.data["type"] == "Answer" do
{:ok, []}
else
do_create_notifications(activity)
end
end
def create_notifications(%Activity{data: %{"type" => type}} = activity)
when type in [
"Follow",
"Like",
"Announce",
"Move",
"EmojiReact",
"Flag",
"Update",
"Accept",
"Join"
] do
do_create_notifications(activity)
end
def create_notifications(_), do: {:ok, []}
defp do_create_notifications(%Activity{} = activity) do
enabled_participants = get_notified_participants_from_activity(activity)
enabled_receivers = get_notified_from_activity(activity)
enabled_subscribers = get_notified_subscribers_from_activity(activity)
notifications =
(Enum.map(enabled_receivers -- enabled_participants, fn user ->
create_notification(activity, user)
end) ++
Enum.map(enabled_subscribers -- enabled_receivers, fn user ->
create_notification(activity, user, type: "status")
end) ++
Enum.map(enabled_participants, fn user ->
create_notification(activity, user, type: "pleroma:event_update")
end))
|> Enum.reject(&is_nil/1)
{:ok, notifications}
end
defp type_from_activity(%{data: %{"type" => type}} = activity) do
case type do
"Follow" ->
if Activity.follow_accepted?(activity) do
"follow"
else
"follow_request"
end
"Announce" ->
"reblog"
"Like" ->
"favourite"
"Move" ->
"move"
"EmojiReact" ->
"pleroma:emoji_reaction"
"Flag" ->
"pleroma:report"
# Compatibility with old reactions
"EmojiReaction" ->
"pleroma:emoji_reaction"
"Create" ->
activity
|> type_from_activity_object()
"Update" ->
"update"
"Accept" ->
"pleroma:participation_accepted"
"Join" ->
"pleroma:participation_request"
t ->
raise "No notification type for activity type #{t}"
end
end
defp type_from_activity_object(%{data: %{"type" => "Create", "object" => %{}}}), do: "mention"
defp type_from_activity_object(%{data: %{"type" => "Create"}} = activity) do
object = Object.get_by_ap_id(activity.data["object"])
case object && object.data["type"] do
"ChatMessage" -> "pleroma:chat_mention"
_ -> "mention"
end
end
# TODO move to sql, too.
def create_notification(%Activity{} = activity, %User{} = user, opts \\ []) do
type = Keyword.get(opts, :type, type_from_activity(activity))
unless skip?(activity, user, opts) do
{:ok, %{notification: notification}} =
Multi.new()
|> Multi.insert(:notification, %Notification{
user_id: user.id,
activity: activity,
seen: mark_as_read?(activity, user),
type: type
})
|> Marker.multi_set_last_read_id(user, "notifications")
|> Repo.transaction()
notification
end
end
def create_poll_notifications(%Activity{} = activity) do
with %Object{data: %{"type" => "Question", "actor" => actor} = data} <-
Object.normalize(activity) do
voters =
case data do
%{"voters" => voters} when is_list(voters) -> voters
_ -> []
end
notifications =
Enum.reduce([actor | voters], [], fn ap_id, acc ->
with %User{local: true} = user <- User.get_by_ap_id(ap_id) do
[create_notification(activity, user, type: "poll") | acc]
else
_ -> acc
end
end)
{:ok, notifications}
end
end
def create_event_notifications(%Activity{} = activity) do
with %Object{data: %{"type" => "Event", "actor" => actor} = data} <-
Object.normalize(activity) do
participations =
case data do
%{"participations" => participations} when is_list(participations) -> participations
_ -> []
end
notifications =
Enum.reduce([actor | participations], [], fn ap_id, acc ->
with %User{local: true} = user <- User.get_by_ap_id(ap_id) do
[create_notification(activity, user, type: "pleroma:event_reminder") | acc]
else
_ -> acc
end
end)
{:ok, notifications}
end
end
@doc """
Returns a tuple with 2 elements:
{notification-enabled receivers, currently disabled receivers (blocking / [thread] muting)}
NOTE: might be called for FAKE Activities, see ActivityPub.Utils.get_notified_from_object/1
"""
@spec get_notified_from_activity(Activity.t(), boolean()) :: list(User.t())
def get_notified_from_activity(activity, local_only \\ true)
def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, local_only)
when type in [
"Create",
"Like",
"Announce",
"Follow",
"Move",
"EmojiReact",
"Flag",
"Update",
"Accept",
"Join"
] do
potential_receiver_ap_ids = get_potential_receiver_ap_ids(activity)
potential_receivers =
User.get_users_from_set(potential_receiver_ap_ids, local_only: local_only)
notification_enabled_ap_ids =
potential_receiver_ap_ids
|> exclude_domain_blocker_ap_ids(activity, potential_receivers)
|> exclude_relationship_restricted_ap_ids(activity)
|> exclude_thread_muter_ap_ids(activity)
Enum.filter(potential_receivers, fn u -> u.ap_id in notification_enabled_ap_ids end)
end
def get_notified_from_activity(_, _local_only), do: []
def get_notified_subscribers_from_activity(activity, local_only \\ true)
def get_notified_subscribers_from_activity(
%Activity{data: %{"type" => "Create"}} = activity,
local_only
) do
notification_enabled_ap_ids =
[]
|> Utils.maybe_notify_subscribers(activity)
potential_receivers =
User.get_users_from_set(notification_enabled_ap_ids, local_only: local_only)
Enum.filter(potential_receivers, fn u -> u.ap_id in notification_enabled_ap_ids end)
end
def get_notified_subscribers_from_activity(_, _), do: []
def get_notified_participants_from_activity(activity, local_only \\ true)
def get_notified_participants_from_activity(
%Activity{data: %{"type" => "Update"}} = activity,
local_only
) do
notification_enabled_ap_ids =
[]
|> Utils.maybe_notify_participants(activity)
potential_receivers =
User.get_users_from_set(notification_enabled_ap_ids, local_only: local_only)
Enum.filter(potential_receivers, fn u -> u.ap_id in notification_enabled_ap_ids end)
end
def get_notified_participants_from_activity(_, _), do: []
# For some activities, only notify the author of the object
def get_potential_receiver_ap_ids(%{data: %{"type" => type, "object" => object_id}})
when type in ~w{Like Announce EmojiReact} do
case Object.get_cached_by_ap_id(object_id) do
%Object{data: %{"actor" => actor}} ->
[actor]
_ ->
[]
end
end
def get_potential_receiver_ap_ids(%{data: %{"type" => "Accept", "object" => join_id}}) do
case Activity.get_by_ap_id_with_object(join_id) do
%Activity{
data: %{"type" => "Join"},
object: %Object{data: %{"type" => "Event", "joinMode" => "free"}}
} ->
[]
%Activity{data: %{"type" => "Join", "actor" => actor_id}} ->
[actor_id]
_ ->
[]
end
end
def get_potential_receiver_ap_ids(%{data: %{"type" => "Join", "object" => object_id}}) do
case Object.get_by_ap_id(object_id) do
%Object{data: %{"type" => "Event", "joinMode" => "free"}} ->
[]
%Object{data: %{"type" => "Event", "actor" => actor_id}} ->
[actor_id]
_ ->
[]
end
end
def get_potential_receiver_ap_ids(%{data: %{"type" => "Follow", "object" => object_id}}) do
[object_id]
end
def get_potential_receiver_ap_ids(%{data: %{"type" => "Flag", "actor" => actor}}) do
(User.all_users_with_privilege(:reports_manage_reports)
|> Enum.map(fn user -> user.ap_id end)) --
[actor]
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
[]
|> Utils.maybe_notify_to_recipients(activity)
|> Utils.maybe_notify_mentioned_recipients(activity)
|> Utils.maybe_notify_followers(activity)
|> Enum.uniq()
end
@doc "Filters out AP IDs domain-blocking and not following the activity's actor"
def exclude_domain_blocker_ap_ids(ap_ids, activity, preloaded_users \\ [])
def exclude_domain_blocker_ap_ids([], _activity, _preloaded_users), do: []
def exclude_domain_blocker_ap_ids(ap_ids, %Activity{} = activity, preloaded_users) do
activity_actor_domain = activity.actor && URI.parse(activity.actor).host
users =
ap_ids
|> Enum.map(fn ap_id ->
Enum.find(preloaded_users, &(&1.ap_id == ap_id)) ||
User.get_cached_by_ap_id(ap_id)
end)
|> Enum.filter(& &1)
domain_blocker_ap_ids = for u <- users, activity_actor_domain in u.domain_blocks, do: u.ap_id
domain_blocker_follower_ap_ids =
if Enum.any?(domain_blocker_ap_ids) do
activity
|> Activity.user_actor()
|> FollowingRelationship.followers_ap_ids(domain_blocker_ap_ids)
else
[]
end
ap_ids
|> Kernel.--(domain_blocker_ap_ids)
|> Kernel.++(domain_blocker_follower_ap_ids)
end
@doc "Filters out AP IDs of users basing on their relationships with activity actor user"
def exclude_relationship_restricted_ap_ids([], _activity), do: []
def exclude_relationship_restricted_ap_ids(ap_ids, %Activity{} = activity) do
relationship_restricted_ap_ids =
activity
|> Activity.user_actor()
|> User.incoming_relationships_ungrouped_ap_ids([
:block,
:notification_mute
])
Enum.uniq(ap_ids) -- relationship_restricted_ap_ids
end
@doc "Filters out AP IDs of users who mute activity thread"
def exclude_thread_muter_ap_ids([], _activity), do: []
def exclude_thread_muter_ap_ids(ap_ids, %Activity{} = activity) do
thread_muter_ap_ids = ThreadMute.muter_ap_ids(activity.data["context"])
Enum.uniq(ap_ids) -- thread_muter_ap_ids
end
def skip?(activity, user, opts \\ [])
@spec skip?(Activity.t(), User.t(), Keyword.t()) :: boolean()
def skip?(%Activity{} = activity, %User{} = user, opts) do
[
:self,
:internal,
:invisible,
:block_from_strangers,
:recently_followed,
:filtered
]
|> Enum.find(&skip?(&1, activity, user, opts))
end
def skip?(_activity, _user, _opts), do: false
@spec skip?(atom(), Activity.t(), User.t(), Keyword.t()) :: boolean()
def skip?(:self, %Activity{} = activity, %User{} = user, opts) do
cond do
opts[:type] == "poll" -> false
activity.data["actor"] == user.ap_id -> true
true -> false
end
end
def skip?(:internal, %Activity{} = activity, _user, _opts) do
actor = activity.data["actor"]
user = User.get_cached_by_ap_id(actor)
User.internal?(user)
end
def skip?(:invisible, %Activity{} = activity, _user, _opts) do
actor = activity.data["actor"]
user = User.get_cached_by_ap_id(actor)
User.invisible?(user)
end
def skip?(
:block_from_strangers,
%Activity{} = activity,
%User{notification_settings: %{block_from_strangers: true}} = user,
opts
) do
actor = activity.data["actor"]
follower = User.get_cached_by_ap_id(actor)
cond do
opts[:type] == "poll" -> false
user.ap_id == actor -> false
!User.following?(user, follower) -> true
true -> false
end
end
# To do: consider defining recency in hours and checking FollowingRelationship with a single SQL
def skip?(
:recently_followed,
%Activity{data: %{"type" => "Follow"}} = activity,
%User{} = user,
_opts
) do
actor = activity.data["actor"]
Notification.for_user(user)
|> Enum.any?(fn
%{activity: %{data: %{"type" => "Follow", "actor" => ^actor}}} -> true
_ -> false
end)
end
def skip?(:filtered, %{data: %{"type" => type}}, _user, _opts) when type in ["Follow", "Move"],
do: false
def skip?(:filtered, activity, user, _opts) do
object = Object.normalize(activity, fetch: false)
cond do
is_nil(object) ->
false
object.data["actor"] == user.ap_id ->
false
not is_nil(regex = Pleroma.Filter.compose_regex(user, :re)) ->
Regex.match?(regex, object.data["content"])
true ->
false
end
end
def skip?(_type, _activity, _user, _opts), do: false
def mark_as_read?(activity, target_user) do
user = Activity.user_actor(activity)
User.mutes_user?(target_user, user) || CommonAPI.thread_muted?(activity, target_user)
end
def for_user_and_activity(user, activity) do
from(n in __MODULE__,
where: n.user_id == ^user.id,
where: n.activity_id == ^activity.id
)
|> Repo.one()
end
@spec mark_context_as_read(User.t(), String.t()) :: {integer(), nil | [term()]}
def mark_context_as_read(%User{id: id}, context) do
from(
n in Notification,
join: a in assoc(n, :activity),
where: n.user_id == ^id,
where: n.seen == false,
where: fragment("?->>'context'", a.data) == ^context
)
|> Repo.update_all(set: [seen: true])
end
@doc "Streams a list of notifications over websockets and web push"
@spec stream(list(Notification.t())) :: :ok
def stream(notifications) do
Enum.each(notifications, fn notification ->
Streamer.stream(["user", "user:notification"], notification)
Push.send(notification)
end)
end
end
diff --git a/lib/pleroma/object/updater.ex b/lib/pleroma/object/updater.ex
index b80bc7faf..d7f27184b 100644
--- a/lib/pleroma/object/updater.ex
+++ b/lib/pleroma/object/updater.ex
@@ -1,293 +1,295 @@
# 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
+ alias Pleroma.Maps
alias Pleroma.Object
alias Pleroma.Repo
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
%{"anyOf" => [_ | _]} -> "anyOf"
%{"oneOf" => [_ | _]} -> "oneOf"
_ -> nil
end
with true <- to_be_updated["type"] == "Question",
key when not is_nil(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])
+ |> Maps.put_if_present("votersCount", updated_object["votersCount"])
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
defp maybe_touch_changeset(changeset, true) do
updated_at =
NaiveDateTime.utc_now()
|> NaiveDateTime.truncate(:second)
Ecto.Changeset.put_change(changeset, :updated_at, updated_at)
end
defp maybe_touch_changeset(changeset, _), do: changeset
def do_update_and_invalidate_cache(orig_object, updated_object, touch_changeset? \\ false) do
orig_object_ap_id = updated_object["id"]
orig_object_data = orig_object.data
%{
updated_data: updated_object_data,
updated: updated,
used_history_in_new_object?: used_history_in_new_object?
} = make_new_object_data_from_update_object(orig_object_data, updated_object)
changeset =
orig_object
|> Repo.preload(:hashtags)
|> Object.change(%{data: updated_object_data})
|> maybe_touch_changeset(touch_changeset?)
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
{:ok, new_object, updated}
end
end
end
diff --git a/test/pleroma/web/rich_media/parsers/twitter_card_test.exs b/test/pleroma/web/rich_media/parsers/twitter_card_test.exs
index 15b272ff2..9069a2aed 100644
--- a/test/pleroma/web/rich_media/parsers/twitter_card_test.exs
+++ b/test/pleroma/web/rich_media/parsers/twitter_card_test.exs
@@ -1,132 +1,132 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.RichMedia.Parsers.TwitterCardTest do
use ExUnit.Case, async: true
alias Pleroma.Web.RichMedia.Parsers.TwitterCard
test "returns error when html not contains twitter card" do
assert TwitterCard.parse([{"html", [], [{"head", [], []}, {"body", [], []}]}], %{}) == %{}
end
test "parses twitter card with only name attributes" do
html =
File.read!("test/fixtures/nypd-facial-recognition-children-teenagers3.html")
|> Floki.parse_document!()
assert TwitterCard.parse(html, %{}) ==
%{
"description" =>
"With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.",
"image" =>
"https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-facebookJumbo.jpg",
"type" => "article",
"url" =>
"https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html",
"title" =>
"She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database."
}
end
test "parses twitter card with only property attributes" do
html =
File.read!("test/fixtures/nypd-facial-recognition-children-teenagers2.html")
|> Floki.parse_document!()
assert TwitterCard.parse(html, %{}) ==
%{
"card" => "summary_large_image",
"description" =>
"With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.",
"image" =>
"https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-facebookJumbo.jpg",
"image:alt" => "",
"title" =>
"She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.",
"url" =>
"https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html",
"type" => "article"
}
end
test "parses twitter card with name & property attributes" do
html =
File.read!("test/fixtures/nypd-facial-recognition-children-teenagers.html")
|> Floki.parse_document!()
assert TwitterCard.parse(html, %{}) ==
%{
"card" => "summary_large_image",
"description" =>
"With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.",
"image" =>
"https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-facebookJumbo.jpg",
"image:alt" => "",
"title" =>
"She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.",
"url" =>
"https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html",
"type" => "article"
}
end
test "respect only first title tag on the page" do
image_path =
"https://assets.atlasobscura.com/media/W1siZiIsInVwbG9hZHMvYXNzZXRzLzkwYzgyMzI4LThlMDUtNGRiNS05MDg3LTUzMGUxZTM5N2RmMmVkOTM5ZDM4MGM4OTIx" <>
"YTQ5MF9EQVIgZXhodW1hdGlvbiBvZiBNYXJnYXJldCBDb3JiaW4gZ3JhdmUgMTkyNi5qcGciXSxbInAiLCJjb252ZXJ0IiwiIl0sWyJwIiwiY29udmVydCIsIi1xdWFsaXR5IDgxIC1hdXRvLW9" <>
"yaWVudCJdLFsicCIsInRodW1iIiwiNjAweD4iXV0/DAR%20exhumation%20of%20Margaret%20Corbin%20grave%201926.jpg"
html =
File.read!("test/fixtures/margaret-corbin-grave-west-point.html") |> Floki.parse_document!()
assert TwitterCard.parse(html, %{}) ==
%{
"title" => "The Missing Grave of Margaret Corbin, Revolutionary War Veteran",
"card" => "summary_large_image",
"image" => image_path,
"description" =>
"She's the only woman veteran honored with a monument at West Point. But where was she buried?",
"type" => "article",
"url" => "http://www.atlasobscura.com/articles/margaret-corbin-grave-west-point"
}
end
- test "takes first founded title in html head if there is html markup error" do
+ test "takes first found title in html head if there is html markup error" do
html =
File.read!("test/fixtures/nypd-facial-recognition-children-teenagers4.html")
|> Floki.parse_document!()
assert TwitterCard.parse(html, %{}) ==
%{
"title" =>
"She Was Arrested at 14. Then Her Photo Went to a Facial Recognition Database.",
"description" =>
"With little oversight, the N.Y.P.D. has been using powerful surveillance technology on photos of children and teenagers.",
"image" =>
"https://static01.nyt.com/images/2019/08/01/nyregion/01nypd-juveniles-promo/01nypd-juveniles-promo-facebookJumbo.jpg",
"type" => "article",
"url" =>
"https://www.nytimes.com/2019/08/01/nyregion/nypd-facial-recognition-children-teenagers.html"
}
end
test "takes first image if multiple are specified" do
html =
File.read!("test/fixtures/fulmo.html")
|> Floki.parse_document!()
assert TwitterCard.parse(html, %{}) ==
%{
"description" => "Pri feoj, kiuj devis ordigi falintan arbon.",
"image" => "https://tirifto.xwx.moe/r/ilustrajhoj/pinglordigado.png",
"title" => "Fulmo",
"type" => "website",
"url" => "https://tirifto.xwx.moe/eo/rakontoj/fulmo.html",
"image:alt" =>
"Meze de arbaro kuŝas falinta trunko, sen pingloj kaj kun branĉoj derompitaj. Post ĝi videblas du feoj: florofeo maldekstre kaj nubofeo dekstre. La florofeo iom kaŝas sin post la trunko. La nubofeo staras kaj tenas amason da pigloj. Ili iom rigardas al si.",
"image:height" => "630",
"image:width" => "1200"
}
end
end

File Metadata

Mime Type
text/x-diff
Expires
Sun, Aug 9, 3:43 AM (1 d, 17 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1724166
Default Alt Text
(42 KB)

Event Timeline