# Pleroma: A lightweight social networking server # Copyright © 2017-2022 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Object.Updater do require Pleroma.Constants 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 data -> if Map.has_key?(data, "anyOf"), do: "anyOf", else: "oneOf" end with true <- to_be_updated["type"] == "Question", key <- choice_key.(updated_object), true <- key == choice_key.(to_be_updated), orig_choices <- to_be_updated[key] |> Enum.map(&Map.drop(&1, ["replies"])), new_choices <- updated_object[key] |> Enum.map(&Map.drop(&1, ["replies"])), true <- orig_choices == new_choices do # Choices are the same, but counts are different to_be_updated |> Map.put(key, updated_object[key]) else # Choices (or vote type) have changed, do not allow this _ -> to_be_updated end end # This calculates the data to be sent as the object of an Update. # new_data's formerRepresentations is not considered. # formerRepresentations is added to the returned data. def make_update_object_data(original_data, new_data, date) do %{data: updated_data, updated: updated} = original_data |> update_content_fields(new_data) if not updated do updated_data else %{updated_object: updated_data} = updated_data |> maybe_update_history(original_data, updated: updated, use_history_in_new_object?: false) updated_data |> Map.put("updated", date) end end # This calculates the data of the new Object from an Update. # new_data's formerRepresentations is considered. def make_new_object_data_from_update_object(original_data, new_data) do update_is_reasonable = with {_, updated} when not is_nil(updated) <- {:cur_updated, new_data["updated"]}, {_, {:ok, updated_time, _}} <- {:cur_updated, DateTime.from_iso8601(updated)}, {_, last_updated} when not is_nil(last_updated) <- {:last_updated, original_data["updated"] || original_data["published"]}, {_, {:ok, last_updated_time, _}} <- {:last_updated, DateTime.from_iso8601(last_updated)}, :gt <- DateTime.compare(updated_time, last_updated_time) do :update_everything else # only allow poll updates {:cur_updated, _} -> :no_content_update :eq -> :no_content_update # allow all updates {:last_updated, _} -> :update_everything # allow no updates _ -> false end %{ updated_object: updated_data, used_history_in_new_object?: used_history_in_new_object?, updated: updated } = if update_is_reasonable == :update_everything do %{data: updated_data, updated: updated} = original_data |> update_content_fields(new_data) updated_data |> maybe_update_history(original_data, updated: updated, use_history_in_new_object?: true, new_data: new_data ) |> Map.put(:updated, updated) else %{ updated_object: original_data, used_history_in_new_object?: false, updated: false } end updated_data = if update_is_reasonable != false do updated_data |> maybe_update_poll(new_data) else updated_data end %{ updated_data: updated_data, updated: updated, used_history_in_new_object?: used_history_in_new_object? } end def for_each_history_item(%{"orderedItems" => items} = history, _object, fun) do new_items = Enum.map(items, fun) |> Enum.reduce_while( {:ok, []}, fn {:ok, item}, {:ok, acc} -> {:cont, {:ok, acc ++ [item]}} e, _acc -> {:halt, e} end ) case new_items do {:ok, items} -> {:ok, Map.put(history, "orderedItems", items)} e -> e end end def for_each_history_item(history, _, _) do {:ok, history} end def do_with_history(object, fun) do with history <- object["formerRepresentations"], object <- Map.drop(object, ["formerRepresentations"]), {_, {:ok, object}} <- {:main_body, fun.(object)}, {_, {:ok, history}} <- {:history_items, for_each_history_item(history, object, fun)} do object = if history do Map.put(object, "formerRepresentations", history) else object end {:ok, object} else {:main_body, e} -> e {:history_items, e} -> e end end 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