- save object ids on pin, instead of activity ids - pins federation - removed pinned_activities field from the users table - activityPub endpoint for user pins - pulling remote users pinsfeature/2295-email-mention-notification
@@ -38,6 +38,7 @@ Has these additional fields under the `pleroma` object: | |||
- `thread_muted`: true if the thread the post belongs to is muted | |||
- `emoji_reactions`: A list with emoji / reaction maps. The format is `{name: "☕", count: 1, me: true}`. Contains no information about the reacting users, for that use the `/statuses/:id/reactions` endpoint. | |||
- `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. | |||
## Scheduled statuses | |||
@@ -184,40 +184,48 @@ defmodule Pleroma.Activity do | |||
|> Repo.one() | |||
end | |||
@spec get_by_id(String.t()) :: Activity.t() | nil | |||
def get_by_id(id) do | |||
case FlakeId.flake_id?(id) do | |||
true -> | |||
Activity | |||
|> where([a], a.id == ^id) | |||
|> restrict_deactivated_users() | |||
|> Repo.one() | |||
_ -> | |||
nil | |||
end | |||
end | |||
def get_by_id_with_user_actor(id) do | |||
case FlakeId.flake_id?(id) do | |||
true -> | |||
Activity | |||
|> where([a], a.id == ^id) | |||
|> with_preloaded_user_actor() | |||
|> Repo.one() | |||
_ -> | |||
nil | |||
@doc """ | |||
Gets activity by ID, doesn't load activities from deactivated actors by default. | |||
""" | |||
@spec get_by_id(String.t(), keyword()) :: t() | nil | |||
def get_by_id(id, opts \\ [filter: [:restrict_deactivated]]), do: get_by_id_with_opts(id, opts) | |||
@spec get_by_id_with_user_actor(String.t()) :: t() | nil | |||
def get_by_id_with_user_actor(id), do: get_by_id_with_opts(id, preload: [:user_actor]) | |||
@spec get_by_id_with_object(String.t()) :: t() | nil | |||
def get_by_id_with_object(id), do: get_by_id_with_opts(id, preload: [:object]) | |||
defp get_by_id_with_opts(id, opts) do | |||
if FlakeId.flake_id?(id) do | |||
query = Queries.by_id(id) | |||
with_filters_query = | |||
if is_list(opts[:filter]) do | |||
Enum.reduce(opts[:filter], query, fn | |||
{:type, type}, acc -> Queries.by_type(acc, type) | |||
:restrict_deactivated, acc -> restrict_deactivated_users(acc) | |||
_, acc -> acc | |||
end) | |||
else | |||
query | |||
end | |||
with_preloads_query = | |||
if is_list(opts[:preload]) do | |||
Enum.reduce(opts[:preload], with_filters_query, fn | |||
:user_actor, acc -> with_preloaded_user_actor(acc) | |||
:object, acc -> with_preloaded_object(acc) | |||
_, acc -> acc | |||
end) | |||
else | |||
with_filters_query | |||
end | |||
Repo.one(with_preloads_query) | |||
end | |||
end | |||
def get_by_id_with_object(id) do | |||
Activity | |||
|> where(id: ^id) | |||
|> with_preloaded_object() | |||
|> Repo.one() | |||
end | |||
def all_by_ids_with_object(ids) do | |||
Activity | |||
|> where([a], a.id in ^ids) | |||
@@ -269,6 +277,11 @@ defmodule Pleroma.Activity do | |||
def get_create_by_object_ap_id_with_object(_), do: nil | |||
@spec create_by_id_with_object(String.t()) :: t() | nil | |||
def create_by_id_with_object(id) do | |||
get_by_id_with_opts(id, preload: [:object], filter: [type: "Create"]) | |||
end | |||
defp get_in_reply_to_activity_from_object(%Object{data: %{"inReplyTo" => ap_id}}) do | |||
get_create_by_object_ap_id_with_object(ap_id) | |||
end | |||
@@ -368,12 +381,6 @@ defmodule Pleroma.Activity do | |||
end | |||
end | |||
@spec pinned_by_actor?(Activity.t()) :: boolean() | |||
def pinned_by_actor?(%Activity{} = activity) do | |||
actor = user_actor(activity) | |||
activity.id in actor.pinned_activities | |||
end | |||
@spec get_by_object_ap_id_with_object(String.t()) :: t() | nil | |||
def get_by_object_ap_id_with_object(ap_id) when is_binary(ap_id) do | |||
ap_id | |||
@@ -14,6 +14,11 @@ defmodule Pleroma.Activity.Queries do | |||
alias Pleroma.Activity | |||
alias Pleroma.User | |||
@spec by_id(query(), String.t()) :: query() | |||
def by_id(query \\ Activity, id) do | |||
from(a in query, where: a.id == ^id) | |||
end | |||
@spec by_ap_id(query, String.t()) :: query | |||
def by_ap_id(query \\ Activity, ap_id) do | |||
from( | |||
@@ -99,6 +99,7 @@ defmodule Pleroma.User do | |||
field(:local, :boolean, default: true) | |||
field(:follower_address, :string) | |||
field(:following_address, :string) | |||
field(:featured_address, :string) | |||
field(:search_rank, :float, virtual: true) | |||
field(:search_type, :integer, virtual: true) | |||
field(:tags, {:array, :string}, default: []) | |||
@@ -130,7 +131,6 @@ defmodule Pleroma.User do | |||
field(:hide_followers, :boolean, default: false) | |||
field(:hide_follows, :boolean, default: false) | |||
field(:hide_favorites, :boolean, default: true) | |||
field(:pinned_activities, {:array, :string}, default: []) | |||
field(:email_notifications, :map, default: %{"digest" => false}) | |||
field(:mascot, :map, default: nil) | |||
field(:emoji, :map, default: %{}) | |||
@@ -148,6 +148,7 @@ defmodule Pleroma.User do | |||
field(:accepts_chat_messages, :boolean, default: nil) | |||
field(:last_active_at, :naive_datetime) | |||
field(:disclose_client, :boolean, default: true) | |||
field(:pinned_objects, :map, default: %{}) | |||
embeds_one( | |||
:notification_settings, | |||
@@ -372,8 +373,10 @@ defmodule Pleroma.User do | |||
end | |||
# Should probably be renamed or removed | |||
@spec ap_id(User.t()) :: String.t() | |||
def ap_id(%User{nickname: nickname}), do: "#{Web.base_url()}/users/#{nickname}" | |||
@spec ap_followers(User.t()) :: String.t() | |||
def ap_followers(%User{follower_address: fa}) when is_binary(fa), do: fa | |||
def ap_followers(%User{} = user), do: "#{ap_id(user)}/followers" | |||
@@ -381,6 +384,11 @@ defmodule Pleroma.User do | |||
def ap_following(%User{following_address: fa}) when is_binary(fa), do: fa | |||
def ap_following(%User{} = user), do: "#{ap_id(user)}/following" | |||
@spec ap_featured_collection(User.t()) :: String.t() | |||
def ap_featured_collection(%User{featured_address: fa}) when is_binary(fa), do: fa | |||
def ap_featured_collection(%User{} = user), do: "#{ap_id(user)}/collections/featured" | |||
defp truncate_fields_param(params) do | |||
if Map.has_key?(params, :fields) do | |||
Map.put(params, :fields, Enum.map(params[:fields], &truncate_field/1)) | |||
@@ -443,6 +451,7 @@ defmodule Pleroma.User do | |||
:uri, | |||
:follower_address, | |||
:following_address, | |||
:featured_address, | |||
:hide_followers, | |||
:hide_follows, | |||
:hide_followers_count, | |||
@@ -454,7 +463,8 @@ defmodule Pleroma.User do | |||
:invisible, | |||
:actor_type, | |||
:also_known_as, | |||
:accepts_chat_messages | |||
:accepts_chat_messages, | |||
:pinned_objects | |||
] | |||
) | |||
|> cast(params, [:name], empty_values: []) | |||
@@ -686,7 +696,7 @@ defmodule Pleroma.User do | |||
|> validate_format(:nickname, local_nickname_regex()) | |||
|> put_ap_id() | |||
|> unique_constraint(:ap_id) | |||
|> put_following_and_follower_address() | |||
|> put_following_and_follower_and_featured_address() | |||
end | |||
def register_changeset(struct, params \\ %{}, opts \\ []) do | |||
@@ -747,7 +757,7 @@ defmodule Pleroma.User do | |||
|> put_password_hash | |||
|> put_ap_id() | |||
|> unique_constraint(:ap_id) | |||
|> put_following_and_follower_address() | |||
|> put_following_and_follower_and_featured_address() | |||
end | |||
def maybe_validate_required_email(changeset, true), do: changeset | |||
@@ -765,11 +775,16 @@ defmodule Pleroma.User do | |||
put_change(changeset, :ap_id, ap_id) | |||
end | |||
defp put_following_and_follower_address(changeset) do | |||
followers = ap_followers(%User{nickname: get_field(changeset, :nickname)}) | |||
defp put_following_and_follower_and_featured_address(changeset) do | |||
user = %User{nickname: get_field(changeset, :nickname)} | |||
followers = ap_followers(user) | |||
following = ap_following(user) | |||
featured = ap_featured_collection(user) | |||
changeset | |||
|> put_change(:follower_address, followers) | |||
|> put_change(:following_address, following) | |||
|> put_change(:featured_address, featured) | |||
end | |||
defp autofollow_users(user) do | |||
@@ -2343,45 +2358,35 @@ defmodule Pleroma.User do | |||
cast(user, %{is_approved: approved?}, [:is_approved]) | |||
end | |||
def add_pinnned_activity(user, %Pleroma.Activity{id: id}) do | |||
if id not in user.pinned_activities do | |||
max_pinned_statuses = Config.get([:instance, :max_pinned_statuses], 0) | |||
params = %{pinned_activities: user.pinned_activities ++ [id]} | |||
# if pinned activity was scheduled for deletion, we remove job | |||
if expiration = Pleroma.Workers.PurgeExpiredActivity.get_expiration(id) do | |||
Oban.cancel_job(expiration.id) | |||
end | |||
@spec add_pinned_object_id(User.t(), String.t()) :: {:ok, User.t()} | {:error, term()} | |||
def add_pinned_object_id(%User{} = user, object_id) do | |||
if !user.pinned_objects[object_id] do | |||
params = %{pinned_objects: Map.put(user.pinned_objects, object_id, NaiveDateTime.utc_now())} | |||
user | |||
|> cast(params, [:pinned_activities]) | |||
|> validate_length(:pinned_activities, | |||
max: max_pinned_statuses, | |||
message: "You have already pinned the maximum number of statuses" | |||
) | |||
|> cast(params, [:pinned_objects]) | |||
|> validate_change(:pinned_objects, fn :pinned_objects, pinned_objects -> | |||
max_pinned_statuses = Config.get([:instance, :max_pinned_statuses], 0) | |||
if Enum.count(pinned_objects) <= max_pinned_statuses do | |||
[] | |||
else | |||
[pinned_objects: "You have already pinned the maximum number of statuses"] | |||
end | |||
end) | |||
else | |||
change(user) | |||
end | |||
|> update_and_set_cache() | |||
end | |||
def remove_pinnned_activity(user, %Pleroma.Activity{id: id, data: data}) do | |||
params = %{pinned_activities: List.delete(user.pinned_activities, id)} | |||
# if pinned activity was scheduled for deletion, we reschedule it for deletion | |||
if data["expires_at"] do | |||
# MRF.ActivityExpirationPolicy used UTC timestamps for expires_at in original implementation | |||
{:ok, expires_at} = | |||
data["expires_at"] |> Pleroma.EctoType.ActivityPub.ObjectValidators.DateTime.cast() | |||
Pleroma.Workers.PurgeExpiredActivity.enqueue(%{ | |||
activity_id: id, | |||
expires_at: expires_at | |||
}) | |||
end | |||
@spec remove_pinned_object_id(User.t(), String.t()) :: {:ok, t()} | {:error, term()} | |||
def remove_pinned_object_id(%User{} = user, object_id) do | |||
user | |||
|> cast(params, [:pinned_activities]) | |||
|> cast( | |||
%{pinned_objects: Map.delete(user.pinned_objects, object_id)}, | |||
[:pinned_objects] | |||
) | |||
|> update_and_set_cache() | |||
end | |||
@@ -630,7 +630,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do | |||
|> Map.put(:type, ["Create", "Announce"]) | |||
|> Map.put(:user, reading_user) | |||
|> Map.put(:actor_id, user.ap_id) | |||
|> Map.put(:pinned_activity_ids, user.pinned_activities) | |||
|> Map.put(:pinned_object_ids, Map.keys(user.pinned_objects)) | |||
params = | |||
if User.blocks?(reading_user, user) do | |||
@@ -1075,8 +1075,18 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do | |||
defp restrict_unlisted(query, _), do: query | |||
defp restrict_pinned(query, %{pinned: true, pinned_activity_ids: ids}) do | |||
from(activity in query, where: activity.id in ^ids) | |||
defp restrict_pinned(query, %{pinned: true, pinned_object_ids: ids}) do | |||
from( | |||
[activity, object: o] in query, | |||
where: | |||
fragment( | |||
"(?)->>'type' = 'Create' and coalesce((?)->'object'->>'id', (?)->>'object') = any (?)", | |||
activity.data, | |||
activity.data, | |||
activity.data, | |||
^ids | |||
) | |||
) | |||
end | |||
defp restrict_pinned(query, _), do: query | |||
@@ -1419,6 +1429,9 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do | |||
invisible = data["invisible"] || false | |||
actor_type = data["type"] || "Person" | |||
featured_address = data["featured"] | |||
{:ok, pinned_objects} = fetch_and_prepare_featured_from_ap_id(featured_address) | |||
public_key = | |||
if is_map(data["publicKey"]) && is_binary(data["publicKey"]["publicKeyPem"]) do | |||
data["publicKey"]["publicKeyPem"] | |||
@@ -1447,13 +1460,15 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do | |||
name: data["name"], | |||
follower_address: data["followers"], | |||
following_address: data["following"], | |||
featured_address: featured_address, | |||
bio: data["summary"] || "", | |||
actor_type: actor_type, | |||
also_known_as: Map.get(data, "alsoKnownAs", []), | |||
public_key: public_key, | |||
inbox: data["inbox"], | |||
shared_inbox: shared_inbox, | |||
accepts_chat_messages: accepts_chat_messages | |||
accepts_chat_messages: accepts_chat_messages, | |||
pinned_objects: pinned_objects | |||
} | |||
# nickname can be nil because of virtual actors | |||
@@ -1591,6 +1606,41 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do | |||
end | |||
end | |||
def pin_data_from_featured_collection(%{ | |||
"type" => type, | |||
"orderedItems" => objects | |||
}) | |||
when type in ["OrderedCollection", "Collection"] do | |||
Map.new(objects, fn %{"id" => object_ap_id} -> {object_ap_id, NaiveDateTime.utc_now()} end) | |||
end | |||
def fetch_and_prepare_featured_from_ap_id(nil) do | |||
{:ok, %{}} | |||
end | |||
def fetch_and_prepare_featured_from_ap_id(ap_id) do | |||
with {:ok, data} <- Fetcher.fetch_and_contain_remote_object_from_id(ap_id) do | |||
{:ok, pin_data_from_featured_collection(data)} | |||
else | |||
e -> | |||
Logger.error("Could not decode featured collection at fetch #{ap_id}, #{inspect(e)}") | |||
{:ok, %{}} | |||
end | |||
end | |||
def pinned_fetch_task(nil), do: nil | |||
def pinned_fetch_task(%{pinned_objects: pins}) do | |||
if Enum.all?(pins, fn {ap_id, _} -> | |||
Object.get_cached_by_ap_id(ap_id) || | |||
match?({:ok, _object}, Fetcher.fetch_object_from_id(ap_id)) | |||
end) do | |||
:ok | |||
else | |||
:error | |||
end | |||
end | |||
def make_user_from_ap_id(ap_id) do | |||
user = User.get_cached_by_ap_id(ap_id) | |||
@@ -1598,6 +1648,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do | |||
Transmogrifier.upgrade_user_from_ap_id(ap_id) | |||
else | |||
with {:ok, data} <- fetch_and_prepare_user_from_ap_id(ap_id) do | |||
{:ok, _pid} = Task.start(fn -> pinned_fetch_task(data) end) | |||
if user do | |||
user | |||
|> User.remote_user_changeset(data) | |||
@@ -543,4 +543,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do | |||
|> json(object.data) | |||
end | |||
end | |||
def pinned(conn, %{"nickname" => nickname}) do | |||
with %User{} = user <- User.get_cached_by_nickname(nickname) do | |||
conn | |||
|> put_resp_header("content-type", "application/activity+json") | |||
|> json(UserView.render("featured.json", %{user: user})) | |||
end | |||
end | |||
end |
@@ -273,4 +273,36 @@ defmodule Pleroma.Web.ActivityPub.Builder do | |||
"context" => object.data["context"] | |||
}, []} | |||
end | |||
@spec pin(User.t(), Object.t()) :: {:ok, map(), keyword()} | |||
def pin(%User{} = user, object) do | |||
{:ok, | |||
%{ | |||
"id" => Utils.generate_activity_id(), | |||
"target" => pinned_url(user.nickname), | |||
"object" => object.data["id"], | |||
"actor" => user.ap_id, | |||
"type" => "Add", | |||
"to" => [Pleroma.Constants.as_public()], | |||
"cc" => [user.follower_address] | |||
}, []} | |||
end | |||
@spec unpin(User.t(), Object.t()) :: {:ok, map, keyword()} | |||
def unpin(%User{} = user, object) do | |||
{:ok, | |||
%{ | |||
"id" => Utils.generate_activity_id(), | |||
"target" => pinned_url(user.nickname), | |||
"object" => object.data["id"], | |||
"actor" => user.ap_id, | |||
"type" => "Remove", | |||
"to" => [Pleroma.Constants.as_public()], | |||
"cc" => [user.follower_address] | |||
}, []} | |||
end | |||
defp pinned_url(nickname) when is_binary(nickname) do | |||
Pleroma.Web.Router.Helpers.activity_pub_url(Pleroma.Web.Endpoint, :pinned, nickname) | |||
end | |||
end |
@@ -30,6 +30,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do | |||
alias Pleroma.Web.ActivityPub.ObjectValidators.EventValidator | |||
alias Pleroma.Web.ActivityPub.ObjectValidators.FollowValidator | |||
alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator | |||
alias Pleroma.Web.ActivityPub.ObjectValidators.PinValidator | |||
alias Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator | |||
alias Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator | |||
alias Pleroma.Web.ActivityPub.ObjectValidators.UpdateValidator | |||
@@ -234,6 +235,16 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do | |||
end | |||
end | |||
def validate(%{"type" => type} = object, meta) when type in ~w(Add Remove) do | |||
with {:ok, object} <- | |||
object | |||
|> PinValidator.cast_and_validate() | |||
|> Ecto.Changeset.apply_action(:insert) do | |||
object = stringify_keys(object) | |||
{:ok, object, meta} | |||
end | |||
end | |||
def cast_and_apply(%{"type" => "ChatMessage"} = object) do | |||
ChatMessageValidator.cast_and_apply(object) | |||
end | |||
@@ -0,0 +1,42 @@ | |||
# Pleroma: A lightweight social networking server | |||
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> | |||
# SPDX-License-Identifier: AGPL-3.0-only | |||
defmodule Pleroma.Web.ActivityPub.ObjectValidators.PinValidator do | |||
use Ecto.Schema | |||
import Ecto.Changeset | |||
import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations | |||
alias Pleroma.EctoType.ActivityPub.ObjectValidators | |||
@primary_key false | |||
embedded_schema do | |||
field(:id, ObjectValidators.ObjectID, primary_key: true) | |||
field(:target) | |||
field(:object, ObjectValidators.ObjectID) | |||
field(:actor, ObjectValidators.ObjectID) | |||
field(:type) | |||
field(:to, ObjectValidators.Recipients, default: []) | |||
field(:cc, ObjectValidators.Recipients, default: []) | |||
end | |||
def cast_and_validate(data) do | |||
data | |||
|> cast_data() | |||
|> validate_data() | |||
end | |||
defp cast_data(data) do | |||
cast(%__MODULE__{}, data, __schema__(:fields)) | |||
end | |||
defp validate_data(changeset) do | |||
changeset | |||
|> validate_required([:id, :target, :object, :actor, :type, :to, :cc]) | |||
|> validate_inclusion(:type, ~w(Add Remove)) | |||
|> validate_actor_presence() | |||
|> validate_object_presence() | |||
end | |||
end |
@@ -276,10 +276,10 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do | |||
result = | |||
case deleted_object do | |||
%Object{} -> | |||
with {:ok, deleted_object, activity} <- Object.delete(deleted_object), | |||
with {:ok, deleted_object, _activity} <- Object.delete(deleted_object), | |||
{_, actor} when is_binary(actor) <- {:actor, deleted_object.data["actor"]}, | |||
%User{} = user <- User.get_cached_by_ap_id(actor) do | |||
User.remove_pinnned_activity(user, activity) | |||
User.remove_pinned_object_id(user, deleted_object.data["id"]) | |||
{:ok, user} = ActivityPub.decrease_note_count_if_public(user, deleted_object) | |||
@@ -312,6 +312,58 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do | |||
end | |||
end | |||
# Tasks this handles: | |||
# - adds pin to user | |||
# - removes expiration job for pinned activity, if was set for expiration | |||
@impl true | |||
def handle(%{data: %{"type" => "Add"} = data} = object, meta) do | |||
with %User{} = user <- User.get_cached_by_ap_id(data["actor"]), | |||
{:ok, _user} <- User.add_pinned_object_id(user, data["object"]) do | |||
# if pinned activity was scheduled for deletion, we remove job | |||
if expiration = Pleroma.Workers.PurgeExpiredActivity.get_expiration(meta[:activity_id]) do | |||
Oban.cancel_job(expiration.id) | |||
end | |||
{:ok, object, meta} | |||
else | |||
nil -> | |||
{:error, :user_not_found} | |||
{:error, changeset} -> | |||
if changeset.errors[:pinned_objects] do | |||
{:error, :pinned_statuses_limit_reached} | |||
else | |||
changeset.errors | |||
end | |||
end | |||
end | |||
# Tasks this handles: | |||
# - removes pin from user | |||
# - if activity had expiration, recreates activity expiration job | |||
@impl true | |||
def handle(%{data: %{"type" => "Remove"} = data} = object, meta) do | |||
with %User{} = user <- User.get_cached_by_ap_id(data["actor"]), | |||
{:ok, _user} <- User.remove_pinned_object_id(user, data["object"]) do | |||
# if pinned activity was scheduled for deletion, we reschedule it for deletion | |||
if meta[:expires_at] do | |||
# MRF.ActivityExpirationPolicy used UTC timestamps for expires_at in original implementation | |||
{:ok, expires_at} = | |||
Pleroma.EctoType.ActivityPub.ObjectValidators.DateTime.cast(meta[:expires_at]) | |||
Pleroma.Workers.PurgeExpiredActivity.enqueue(%{ | |||
activity_id: meta[:activity_id], | |||
expires_at: expires_at | |||
}) | |||
end | |||
{:ok, object, meta} | |||
else | |||
nil -> {:error, :user_not_found} | |||
error -> error | |||
end | |||
end | |||
# Nothing to do | |||
@impl true | |||
def handle(object, meta) do | |||
@@ -556,6 +556,14 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do | |||
end | |||
end | |||
def handle_incoming(%{"type" => type} = data, _options) when type in ~w(Add Remove) do | |||
with :ok <- ObjectValidator.fetch_actor_and_object(data), | |||
%Object{} <- Object.normalize(data["object"], fetch: true), | |||
{:ok, activity, _meta} <- Pipeline.common_pipeline(data, local: false) do | |||
{:ok, activity} | |||
end | |||
end | |||
def handle_incoming( | |||
%{"type" => "Delete"} = data, | |||
_options | |||
@@ -1000,6 +1008,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do | |||
with %User{local: false} = user <- User.get_cached_by_ap_id(ap_id), | |||
{:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id), | |||
{:ok, user} <- update_user(user, data) do | |||
{:ok, _pid} = Task.start(fn -> ActivityPub.pinned_fetch_task(user) end) | |||
TransmogrifierWorker.enqueue("user_upgrade", %{"user_id" => user.id}) | |||
{:ok, user} | |||
else | |||
@@ -6,8 +6,10 @@ defmodule Pleroma.Web.ActivityPub.UserView do | |||
use Pleroma.Web, :view | |||
alias Pleroma.Keys | |||
alias Pleroma.Object | |||
alias Pleroma.Repo | |||
alias Pleroma.User | |||
alias Pleroma.Web.ActivityPub.ObjectView | |||
alias Pleroma.Web.ActivityPub.Transmogrifier | |||
alias Pleroma.Web.ActivityPub.Utils | |||
alias Pleroma.Web.Endpoint | |||
@@ -97,6 +99,7 @@ defmodule Pleroma.Web.ActivityPub.UserView do | |||
"followers" => "#{user.ap_id}/followers", | |||
"inbox" => "#{user.ap_id}/inbox", | |||
"outbox" => "#{user.ap_id}/outbox", | |||
"featured" => "#{user.ap_id}/collections/featured", | |||
"preferredUsername" => user.nickname, | |||
"name" => user.name, | |||
"summary" => user.bio, | |||
@@ -245,6 +248,24 @@ defmodule Pleroma.Web.ActivityPub.UserView do | |||
|> Map.merge(pagination) | |||
end | |||
def render("featured.json", %{ | |||
user: %{featured_address: featured_address, pinned_objects: pinned_objects} | |||
}) do | |||
objects = | |||
pinned_objects | |||
|> Enum.sort_by(fn {_, pinned_at} -> pinned_at end, &>=/2) | |||
|> Enum.map(fn {id, _} -> | |||
ObjectView.render("object.json", %{object: Object.get_cached_by_ap_id(id)}) | |||
end) | |||
%{ | |||
"id" => featured_address, | |||
"type" => "OrderedCollection", | |||
"orderedItems" => objects | |||
} | |||
|> Map.merge(Utils.make_json_ld_header()) | |||
end | |||
defp maybe_put_total_items(map, false, _total), do: map | |||
defp maybe_put_total_items(map, true, total) do | |||
@@ -182,7 +182,34 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do | |||
parameters: [id_param()], | |||
responses: %{ | |||
200 => status_response(), | |||
400 => Operation.response("Error", "application/json", ApiError) | |||
400 => | |||
Operation.response("Bad Request", "application/json", %Schema{ | |||
allOf: [ApiError], | |||
title: "Unprocessable Entity", | |||
example: %{ | |||
"error" => "You have already pinned the maximum number of statuses" | |||
} | |||
}), | |||
404 => | |||
Operation.response("Not found", "application/json", %Schema{ | |||
allOf: [ApiError], | |||
title: "Unprocessable Entity", | |||
example: %{ | |||
"error" => "Record not found" | |||
} | |||
}), | |||
422 => | |||
Operation.response( | |||
"Unprocessable Entity", | |||
"application/json", | |||
%Schema{ | |||
allOf: [ApiError], | |||
title: "Unprocessable Entity", | |||
example: %{ | |||
"error" => "Someone else's status cannot be pinned" | |||
} | |||
} | |||
) | |||
} | |||
} | |||
end | |||
@@ -197,7 +224,22 @@ defmodule Pleroma.Web.ApiSpec.StatusOperation do | |||
parameters: [id_param()], | |||
responses: %{ | |||
200 => status_response(), | |||
400 => Operation.response("Error", "application/json", ApiError) | |||
400 => | |||
Operation.response("Bad Request", "application/json", %Schema{ | |||
allOf: [ApiError], | |||
title: "Unprocessable Entity", | |||
example: %{ | |||
"error" => "You have already pinned the maximum number of statuses" | |||
} | |||
}), | |||
404 => | |||
Operation.response("Not found", "application/json", %Schema{ | |||
allOf: [ApiError], | |||
title: "Unprocessable Entity", | |||
example: %{ | |||
"error" => "Record not found" | |||
} | |||
}) | |||
} | |||
} | |||
end | |||
@@ -194,6 +194,13 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do | |||
parent_visible: %Schema{ | |||
type: :boolean, | |||
description: "`true` if the parent post is visible to the user" | |||
}, | |||
pinned_at: %Schema{ | |||
type: :string, | |||
format: "date-time", | |||
nullable: true, | |||
description: | |||
"A datetime (ISO 8601) that states when the post was pinned or `null` if the post is not pinned" | |||
} | |||
} | |||
}, | |||
@@ -411,29 +411,54 @@ defmodule Pleroma.Web.CommonAPI do | |||
end | |||
end | |||
def pin(id, %{ap_id: user_ap_id} = user) do | |||
with %Activity{ | |||
actor: ^user_ap_id, | |||
data: %{"type" => "Create"}, | |||
object: %Object{data: %{"type" => object_type}} | |||
} = activity <- Activity.get_by_id_with_object(id), | |||
true <- object_type in ["Note", "Article", "Question"], | |||
true <- Visibility.is_public?(activity), | |||
{:ok, _user} <- User.add_pinnned_activity(user, activity) do | |||
@spec pin(String.t(), User.t()) :: {:ok, Activity.t()} | {:error, term()} | |||
def pin(id, %User{ap_id: actor} = user) do | |||
with %Activity{} = activity <- create_activity_by_id(id), | |||
true <- activity_belongs_to_actor(activity, actor), | |||
true <- object_type_is_allowed_for_pin(activity.object), | |||
true <- activity_is_public(activity), | |||
{:ok, pin_data, _} <- Builder.pin(user, activity.object), | |||
{:ok, _pin, _} <- | |||
Pipeline.common_pipeline(pin_data, local: true, activity_id: id) do | |||
{:ok, activity} | |||
else | |||
{:error, %{errors: [pinned_activities: {err, _}]}} -> {:error, err} | |||
_ -> {:error, dgettext("errors", "Could not pin")} | |||
{:error, {:execute_side_effects, error}} -> error | |||
error -> error | |||
end | |||
end | |||
defp create_activity_by_id(id) do | |||
with nil <- Activity.create_by_id_with_object(id) do | |||
{:error, :not_found} | |||
end | |||
end | |||
defp activity_belongs_to_actor(%{actor: actor}, actor), do: true | |||
defp activity_belongs_to_actor(_, _), do: {:error, :ownership_error} | |||
defp object_type_is_allowed_for_pin(%{data: %{"type" => type}}) do | |||
with false <- type in ["Note", "Article", "Question"] do | |||
{:error, :not_allowed} | |||
end | |||
end | |||
defp activity_is_public(activity) do | |||
with false <- Visibility.is_public?(activity) do | |||
{:error, :visibility_error} | |||
end | |||
end | |||
@spec unpin(String.t(), User.t()) :: {:ok, User.t()} | {:error, term()} | |||
def unpin(id, user) do | |||
with %Activity{data: %{"type" => "Create"}} = activity <- Activity.get_by_id(id), | |||
{:ok, _user} <- User.remove_pinnned_activity(user, activity) do | |||
with %Activity{} = activity <- create_activity_by_id(id), | |||
{:ok, unpin_data, _} <- Builder.unpin(user, activity.object), | |||
{:ok, _unpin, _} <- | |||
Pipeline.common_pipeline(unpin_data, | |||
local: true, | |||
activity_id: activity.id, | |||
expires_at: activity.data["expires_at"] | |||
) do | |||
{:ok, activity} | |||
else | |||
{:error, %{errors: [pinned_activities: {err, _}]}} -> {:error, err} | |||
_ -> {:error, dgettext("errors", "Could not unpin")} | |||
end | |||
end | |||
@@ -30,6 +30,12 @@ defmodule Pleroma.Web.MastodonAPI.FallbackController do | |||
|> json(%{error: error_message}) | |||
end | |||
def call(conn, {:error, status, message}) do | |||
conn | |||
|> put_status(status) | |||
|> json(%{error: message}) | |||
end | |||
def call(conn, _) do | |||
conn | |||
|> put_status(:internal_server_error) | |||
@@ -260,6 +260,18 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do | |||
def pin(%{assigns: %{user: user}} = conn, %{id: ap_id_or_id}) do | |||
with {:ok, activity} <- CommonAPI.pin(ap_id_or_id, user) do | |||
try_render(conn, "show.json", activity: activity, for: user, as: :activity) | |||
else | |||
{:error, :pinned_statuses_limit_reached} -> | |||
{:error, "You have already pinned the maximum number of statuses"} | |||
{:error, :ownership_error} -> | |||
{:error, :unprocessable_entity, "Someone else's status cannot be pinned"} | |||
{:error, :visibility_error} -> | |||
{:error, :unprocessable_entity, "Non-public status cannot be pinned"} | |||
error -> | |||
error | |||
end | |||
end | |||
@@ -152,6 +152,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do | |||
|> Enum.filter(& &1) | |||
|> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end) | |||
{pinned?, pinned_at} = pin_data(activity_object, user) | |||
%{ | |||
id: to_string(activity.id), | |||
uri: object.data["id"], | |||
@@ -173,7 +175,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do | |||
favourited: present?(favorited), | |||
bookmarked: present?(bookmarked), | |||
muted: false, | |||
pinned: pinned?(activity, user), | |||
pinned: pinned?, | |||
sensitive: false, | |||
spoiler_text: "", | |||
visibility: get_visibility(activity), | |||
@@ -184,7 +186,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do | |||
language: nil, | |||
emojis: [], | |||
pleroma: %{ | |||
local: activity.local | |||
local: activity.local, | |||
pinned_at: pinned_at | |||
} | |||
} | |||
end | |||
@@ -316,6 +319,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do | |||
fn for_user, user -> User.mutes?(for_user, user) end | |||
) | |||
{pinned?, pinned_at} = pin_data(object, user) | |||
%{ | |||
id: to_string(activity.id), | |||
uri: object.data["id"], | |||
@@ -339,7 +344,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do | |||
favourited: present?(favorited), | |||
bookmarked: present?(bookmarked), | |||
muted: muted, | |||
pinned: pinned?(activity, user), | |||
pinned: pinned?, | |||
sensitive: sensitive, | |||
spoiler_text: summary, | |||
visibility: get_visibility(object), | |||
@@ -360,7 +365,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do | |||
direct_conversation_id: direct_conversation_id, | |||
thread_muted: thread_muted?, | |||
emoji_reactions: emoji_reactions, | |||
parent_visible: visible_for_user?(reply_to, opts[:for]) | |||
parent_visible: visible_for_user?(reply_to, opts[:for]), | |||
pinned_at: pinned_at | |||
} | |||
} | |||
end | |||
@@ -529,8 +535,13 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do | |||
defp present?(false), do: false | |||
defp present?(_), do: true | |||
defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}), | |||
do: id in pinned_activities | |||
defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do | |||
if pinned_at = pinned_objects[object_id] do | |||
{true, Utils.to_masto_date(pinned_at)} | |||
else | |||
{false, nil} | |||
end | |||
end | |||
defp build_emoji_map(emoji, users, current_user) do | |||
%{ | |||
@@ -704,6 +704,7 @@ defmodule Pleroma.Web.Router do | |||
# The following two are S2S as well, see `ActivityPub.fetch_follow_information_for_user/1`: | |||
get("/users/:nickname/followers", ActivityPubController, :followers) | |||
get("/users/:nickname/following", ActivityPubController, :following) | |||
get("/users/:nickname/collections/featured", ActivityPubController, :pinned) | |||
end | |||
scope "/", Pleroma.Web.ActivityPub do | |||
@@ -0,0 +1,9 @@ | |||
defmodule Pleroma.Repo.Migrations.AddPinnedObjectsToUsers do | |||
use Ecto.Migration | |||
def change do | |||
alter table(:users) do | |||
add(:pinned_objects, :map) | |||
end | |||
end | |||
end |
@@ -0,0 +1,23 @@ | |||
defmodule Pleroma.Repo.Migrations.AddFeaturedAddressToUsers do | |||
use Ecto.Migration | |||
def up do | |||
alter table(:users) do | |||
add(:featured_address, :string) | |||
end | |||
create(index(:users, [:featured_address])) | |||
execute(""" | |||
update users set featured_address = concat(ap_id, '/collections/featured') where local = true and featured_address is null; | |||
""") | |||
end | |||
def down do | |||
alter table(:users) do | |||
remove(:featured_address) | |||
end | |||
end | |||
end |
@@ -0,0 +1,28 @@ | |||
defmodule Pleroma.Repo.Migrations.MovePinnedActivitiesIntoPinnedObjects do | |||
use Ecto.Migration | |||
import Ecto.Query | |||
alias Pleroma.Repo | |||
alias Pleroma.User | |||
def up do | |||
from(u in User) | |||
|> select([u], {u.id, fragment("?.pinned_activities", u)}) | |||
|> Repo.stream() | |||
|> Stream.each(fn {user_id, pinned_activities_ids} -> | |||
pinned_activities = Pleroma.Activity.all_by_ids_with_object(pinned_activities_ids) | |||
pins = | |||
Map.new(pinned_activities, fn %{object: %{data: %{"id" => object_id}}} -> | |||
{object_id, NaiveDateTime.utc_now()} | |||
end) | |||
from(u in User, where: u.id == ^user_id) | |||
|> Repo.update_all(set: [pinned_objects: pins]) | |||
end) | |||
|> Stream.run() | |||
end | |||
def down, do: :noop | |||
end |
@@ -0,0 +1,15 @@ | |||
defmodule Pleroma.Repo.Migrations.RemovePinnedActivitiesFromUsers do | |||
use Ecto.Migration | |||
def up do | |||
alter table(:users) do | |||
remove(:pinned_activities) | |||
end | |||
end | |||
def down do | |||
alter table(:users) do | |||
add(:pinned_activities, {:array, :string}, default: []) | |||
end | |||
end | |||
end |
@@ -0,0 +1,39 @@ | |||
{ | |||
"@context": [ | |||
"https://www.w3.org/ns/activitystreams", | |||
"https://{{domain}}/schemas/litepub-0.1.jsonld", | |||
{ | |||
"@language": "und" | |||
} | |||
], | |||
"id": "https://{{domain}}/users/{{nickname}}/collections/featured", | |||
"orderedItems": [ | |||
{ | |||
"@context": [ | |||
"https://www.w3.org/ns/activitystreams", | |||
"https://{{domain}}/schemas/litepub-0.1.jsonld", | |||
{ | |||
"@language": "und" | |||
} | |||
], | |||
"actor": "https://{{domain}}/users/{{nickname}}", | |||
"attachment": [], | |||
"attributedTo": "https://{{domain}}/users/{{nickname}}", | |||
"cc": [ | |||
"https://{{domain}}/users/{{nickname}}/followers" | |||
], | |||
"content": "", | |||
"id": "https://{{domain}}/objects/{{object_id}}", | |||
"published": "2021-02-12T15:13:43.915429Z", | |||
"sensitive": false, | |||
"source": "", | |||
"summary": "", | |||
"tag": [], | |||
"to": [ | |||
"https://www.w3.org/ns/activitystreams#Public" | |||
], | |||
"type": "Note" | |||
} | |||
], | |||
"type": "OrderedCollection" | |||
} |
@@ -0,0 +1,41 @@ | |||
{ | |||
"@context": [ | |||
"https://www.w3.org/ns/activitystreams", | |||
"https://w3id.org/security/v1", | |||
{ | |||
"Emoji": "toot:Emoji", | |||
"Hashtag": "as:Hashtag", | |||
"PropertyValue": "schema:PropertyValue", | |||
"alsoKnownAs": { | |||
"@id": "as:alsoKnownAs", | |||
"@type": "@id" | |||
}, | |||
"atomUri": "ostatus:atomUri", | |||
"conversation": "ostatus:conversation", | |||
"featured": { | |||
"@id": "toot:featured", | |||
"@type": "@id" | |||
}, | |||
"focalPoint": { | |||
"@container": "@list", | |||
"@id": "toot:focalPoint" | |||
}, | |||
"inReplyToAtomUri": "ostatus:inReplyToAtomUri", | |||
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers", | |||
"movedTo": { | |||
"@id": "as:movedTo", | |||
"@type": "@id" | |||
}, | |||
"ostatus": "http://ostatus.org#", | |||
"schema": "http://schema.org#", | |||
"sensitive": "as:sensitive", | |||
"toot": "http://joinmastodon.org/ns#", | |||
"value": "schema:value" | |||
} | |||
], | |||
"id": "https://example.com/users/nickname/statuses/{{id}}", | |||
"actor": "https://example.com/users/nickname", | |||
"object": "https://example.com/users/nickname/statuses/101355175004496751", | |||
"target": "https://example.com/users/nickname/collections/featured", | |||
"type": "{{type}}" | |||
} |
@@ -0,0 +1,27 @@ | |||
{ | |||
"@context": [ | |||
"https://www.w3.org/ns/activitystreams", | |||
"https://example.com/schemas/litepub-0.1.jsonld", | |||
{ | |||
"@language": "und" | |||
} | |||
], | |||
"actor": "https://example.com/users/{{nickname}}", | |||
"attachment": [], | |||
"attributedTo": "https://example.com/users/{{nickname}}", | |||
"cc": [ | |||
"https://example.com/users/{{nickname}}/followers" | |||
], | |||
"content": "Content", | |||
"context": "https://example.com/contexts/e4b180e1-7403-477f-aeb4-de57e7a3fe7f", | |||
"conversation": "https://example.com/contexts/e4b180e1-7403-477f-aeb4-de57e7a3fe7f", | |||
"id": "https://example.com/objects/{{object_id}}", | |||
"published": "2019-12-15T22:00:05.279583Z", | |||
"sensitive": false, | |||
"summary": "", | |||
"tag": [], | |||
"to": [ | |||
"https://www.w3.org/ns/activitystreams#Public" | |||
], | |||
"type": "Note" | |||
} |
@@ -0,0 +1,18 @@ | |||
{ | |||
"@context": [ | |||
"https://www.w3.org/ns/activitystreams", | |||
{ | |||
"ostatus": "http://ostatus.org#", | |||
"atomUri": "ostatus:atomUri", | |||
"inReplyToAtomUri": "ostatus:inReplyToAtomUri", | |||
"conversation": "ostatus:conversation", | |||
"sensitive": "as:sensitive", | |||
"toot": "http://joinmastodon.org/ns#", | |||
"votersCount": "toot:votersCount" | |||
} | |||
], | |||
"id": "https://{{domain}}/users/{{nickname}}/collections/featured", | |||
"type": "OrderedCollection", | |||
"totalItems": 0, | |||
"orderedItems": [] | |||
} |
@@ -0,0 +1,41 @@ | |||
{ | |||
"@context": [ | |||
"https://www.w3.org/ns/activitystreams", | |||
"https://example.com/schemas/litepub-0.1.jsonld", | |||
{ | |||
"@language": "und" | |||
} | |||
], | |||
"attachment": [], | |||
"endpoints": { | |||
"oauthAuthorizationEndpoint": "https://example.com/oauth/authorize", | |||
"oauthRegistrationEndpoint": "https://example.com/api/v1/apps", | |||
"oauthTokenEndpoint": "https://example.com/oauth/token", | |||
"sharedInbox": "https://example.com/inbox" | |||
}, | |||
"followers": "https://example.com/users/{{nickname}}/followers", | |||
"following": "https://example.com/users/{{nickname}}/following", | |||
"icon": { | |||
"type": "Image", | |||
"url": "https://example.com/media/4e914f5b84e4a259a3f6c2d2edc9ab642f2ab05f3e3d9c52c81fc2d984b3d51e.jpg" | |||
}, | |||
"id": "https://example.com/users/{{nickname}}", | |||
"image": { | |||
"type": "Image", | |||
"url": "https://example.com/media/f739efddefeee49c6e67e947c4811fdc911785c16ae43da4c3684051fbf8da6a.jpg?name=f739efddefeee49c6e67e947c4811fdc911785c16ae43da4c3684051fbf8da6a.jpg" | |||
}, | |||
"inbox": "https://example.com/users/{{nickname}}/inbox", | |||
"manuallyApprovesFollowers": false, | |||
"name": "{{nickname}}", | |||
"outbox": "https://example.com/users/{{nickname}}/outbox", | |||
"preferredUsername": "{{nickname}}", | |||
"publicKey": { | |||
"id": "https://example.com/users/{{nickname}}#main-key", | |||
"owner": "https://example.com/users/{{nickname}}", | |||
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5DLtwGXNZElJyxFGfcVc\nXANhaMadj/iYYQwZjOJTV9QsbtiNBeIK54PJrYuU0/0YIdrvS1iqheX5IwXRhcwa\nhm3ZyLz7XeN9st7FBni4BmZMBtMpxAuYuu5p/jbWy13qAiYOhPreCx0wrWgm/lBD\n9mkgaxIxPooBE0S4ZWEJIDIV1Vft3AWcRUyWW1vIBK0uZzs6GYshbQZB952S0yo4\nFzI1hABGHncH8UvuFauh4EZ8tY7/X5I0pGRnDOcRN1dAht5w5yTA+6r5kebiFQjP\nIzN/eCO/a9Flrj9YGW7HDNtjSOH0A31PLRGlJtJO3yK57dnf5ppyCZGfL4emShQo\ncQIDAQAB\n-----END PUBLIC KEY-----\n\n" | |||
}, | |||
"summary": "your friendly neighborhood pleroma developer<br>I like cute things and distributed systems, and really hate delete and redrafts", | |||
"tag": [], | |||
"type": "Person", | |||
"url": "https://example.com/users/{{nickname}}" | |||
} |
@@ -2338,4 +2338,49 @@ defmodule Pleroma.UserTest do | |||
assert User.active_user_count(6) == 3 | |||
assert User.active_user_count(1) == 1 | |||
end | |||
describe "pins" do | |||
setup do | |||
user = insert(:user) | |||
[user: user, object_id: object_id_from_created_activity(user)] | |||
end | |||
test "unique pins", %{user: user, object_id: object_id} do | |||
assert {:ok, %{pinned_objects: %{^object_id => pinned_at1} = pins} = updated_user} = | |||
User.add_pinned_object_id(user, object_id) | |||
assert Enum.count(pins) == 1 | |||
assert {:ok, %{pinned_objects: %{^object_id => pinned_at2} = pins}} = | |||
User.add_pinned_object_id(updated_user, object_id) | |||
assert pinned_at1 == pinned_at2 | |||
assert Enum.count(pins) == 1 | |||
end | |||
test "respects max_pinned_statuses limit", %{user: user, object_id: object_id} do | |||
clear_config([:instance, :max_pinned_statuses], 1) | |||
{:ok, updated} = User.add_pinned_object_id(user, object_id) | |||
object_id2 = object_id_from_created_activity(user) | |||
{:error, %{errors: errors}} = User.add_pinned_object_id(updated, object_id2) | |||
assert Keyword.has_key?(errors, :pinned_objects) | |||
end | |||
test "remove_pinned_object_id/2", %{user: user, object_id: object_id} do | |||
assert {:ok, updated} = User.add_pinned_object_id(user, object_id) | |||
{:ok, after_remove} = User.remove_pinned_object_id(updated, object_id) | |||
assert after_remove.pinned_objects == %{} | |||
end | |||
end | |||
defp object_id_from_created_activity(user) do | |||
%{id: id} = insert(:note_activity, user: user) | |||
%{object: %{data: %{"id" => object_id}}} = Activity.get_by_id_with_object(id) | |||
object_id | |||
end | |||
end |
@@ -636,6 +636,86 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do | |||
|> post("/inbox", non_create_data) | |||
|> json_response(400) | |||
end | |||
test "accepts Add/Remove activities", %{conn: conn} do | |||
object_id = "c61d6733-e256-4fe1-ab13-1e369789423f" | |||
status = | |||
File.read!("test/fixtures/statuses/note.json") | |||
|> String.replace("{{nickname}}", "lain") | |||
|> String.replace("{{object_id}}", object_id) | |||
object_url = "https://example.com/objects/#{object_id}" | |||
user = | |||
File.read!("test/fixtures/users_mock/user.json") | |||
|> String.replace("{{nickname}}", "lain") | |||
actor = "https://example.com/users/lain" | |||
Tesla.Mock.mock(fn | |||
%{ | |||
method: :get, | |||
url: ^object_url | |||
} -> | |||
%Tesla.Env{ | |||
status: 200, | |||
body: status, | |||
headers: [{"content-type", "application/activity+json"}] | |||
} | |||
%{ | |||
method: :get, | |||
url: ^actor | |||
} -> | |||
%Tesla.Env{ | |||
status: 200, | |||
body: user, | |||
headers: [{"content-type", "application/activity+json"}] | |||
} | |||
end) | |||
data = %{ | |||
"id" => "https://example.com/objects/d61d6733-e256-4fe1-ab13-1e369789423f", | |||
"actor" => actor, | |||
"object" => object_url, | |||
"target" => "https://example.com/users/lain/collections/featured", | |||
"type" => "Add", | |||
"to" => [Pleroma.Constants.as_public()] | |||
} | |||
assert "ok" == | |||
conn | |||
|> assign(:valid_signature, true) | |||
|> put_req_header("content-type", "application/activity+json") | |||
|> post("/inbox", data) | |||
|> json_response(200) | |||
ObanHelpers.perform(all_enqueued(worker: ReceiverWorker)) | |||
assert Activity.get_by_ap_id(data["id"]) | |||
user = User.get_cached_by_ap_id(data["actor"]) | |||
assert user.pinned_objects[data["object"]] | |||
data = %{ | |||
"id" => "https://example.com/objects/d61d6733-e256-4fe1-ab13-1e369789423d", | |||
"actor" => actor, | |||
"object" => object_url, | |||
"target" => "https://example.com/users/lain/collections/featured", | |||
"type" => "Remove", | |||
"to" => [Pleroma.Constants.as_public()] | |||
} | |||
assert "ok" == | |||
conn | |||
|> assign(:valid_signature, true) | |||
|> put_req_header("content-type", "application/activity+json") | |||
|> post("/inbox", data) | |||
|> json_response(200) | |||
ObanHelpers.perform(all_enqueued(worker: ReceiverWorker)) | |||
user = refresh_record(user) | |||
refute user.pinned_objects[data["object"]] | |||
end | |||
end | |||
describe "/users/:nickname/inbox" do | |||
@@ -1772,4 +1852,29 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do | |||
|> json_response(403) | |||
end | |||
end | |||
test "pinned collection", %{conn: conn} do | |||
clear_config([:instance, :max_pinned_statuses], 2) | |||
user = insert(:user) | |||
objects = insert_list(2, :note, user: user) | |||
Enum.reduce(objects, user, fn %{data: %{"id" => object_id}}, user -> | |||
{:ok, updated} = User.add_pinned_object_id(user, object_id) | |||
updated | |||
end) | |||
%{nickname: nickname, featured_address: featured_address, pinned_objects: pinned_objects} = | |||
refresh_record(user) | |||
%{"id" => ^featured_address, "orderedItems" => items} = | |||
conn | |||
|> get("/users/#{nickname}/collections/featured") | |||
|> json_response(200) | |||
object_ids = Enum.map(items, & &1["id"]) | |||
assert Enum.all?(pinned_objects, fn {obj_id, _} -> | |||
obj_id in object_ids | |||
end) | |||
end | |||
end |
@@ -235,6 +235,83 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do | |||
"url" => [%{"href" => "https://jk.nipponalba.scot/images/profile.jpg"}] | |||
} | |||
end | |||
test "fetches user featured collection" do | |||
ap_id = "https://example.com/users/lain" | |||
featured_url = "https://example.com/users/lain/collections/featured" | |||
user_data = | |||
"test/fixtures/users_mock/user.json" | |||
|> File.read!() | |||
|> String.replace("{{nickname}}", "lain") | |||
|> Jason.decode!() | |||
|> Map.put("featured", featured_url) | |||
|> Jason.encode!() | |||
object_id = Ecto.UUID.generate() | |||
featured_data = | |||
"test/fixtures/collections/featured.json" | |||
|> File.read!() | |||
|> String.replace("{{domain}}", "example.com") | |||
|> String.replace("{{nickname}}", "lain") | |||
|> String.replace("{{object_id}}", object_id) | |||
object_url = "https://example.com/objects/#{object_id}" | |||
object_data = | |||
"test/fixtures/statuses/note.json" | |||
|> File.read!() | |||
|> String.replace("{{object_id}}", object_id) | |||
|> String.replace("{{nickname}}", "lain") | |||
Tesla.Mock.mock(fn | |||
%{ | |||
method: :get, | |||
url: ^ap_id | |||
} -> | |||
%Tesla.Env{ | |||
status: 200, | |||
body: user_data, | |||
headers: [{"content-type", "application/activity+json"}] | |||
} | |||
%{ | |||
method: :get, | |||
url: ^featured_url | |||
} -> | |||
%Tesla.Env{ | |||
status: 200, | |||
body: featured_data, | |||
headers: [{"content-type", "application/activity+json"}] | |||
} | |||
end) | |||
Tesla.Mock.mock_global(fn | |||
%{ | |||
method: :get, | |||
url: ^object_url | |||
} -> | |||
%Tesla.Env{ | |||
status: 200, | |||
body: object_data, | |||
headers: [{"content-type", "application/activity+json"}] | |||
} | |||
end) | |||
{:ok, user} = ActivityPub.make_user_from_ap_id(ap_id) | |||
Process.sleep(50) | |||
assert user.featured_address == featured_url | |||
assert Map.has_key?(user.pinned_objects, object_url) | |||
in_db = Pleroma.User.get_by_ap_id(ap_id) | |||
assert in_db.featured_address == featured_url | |||
assert Map.has_key?(user.pinned_objects, object_url) | |||
assert %{data: %{"id" => ^object_url}} = Object.get_by_ap_id(object_url) | |||
end | |||
end | |||
test "it fetches the appropriate tag-restricted posts" do | |||
@@ -6,6 +6,8 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do | |||
use Oban.Testing, repo: Pleroma.Repo | |||
use Pleroma.DataCase | |||
require Pleroma.Constants | |||
alias Pleroma.Activity | |||
alias Pleroma.Object | |||
alias Pleroma.Tests.ObanHelpers | |||
@@ -106,6 +108,78 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do | |||
assert activity.data["target"] == new_user.ap_id | |||
assert activity.data["type"] == "Move" | |||
end | |||
test "it accepts Add/Remove activities" do | |||
user = | |||
"test/fixtures/users_mock/user.json" | |||
|> File.read!() | |||
|> String.replace("{{nickname}}", "lain") | |||
object_id = "c61d6733-e256-4fe1-ab13-1e369789423f" | |||
object = | |||
"test/fixtures/statuses/note.json" | |||
|> File.read!() | |||
|> String.replace("{{nickname}}", "lain") | |||
|> String.replace("{{object_id}}", object_id) | |||
object_url = "https://example.com/objects/#{object_id}" | |||
actor = "https://example.com/users/lain" | |||
Tesla.Mock.mock(fn | |||
%{ | |||
method: :get, | |||
url: ^actor | |||
} -> | |||
%Tesla.Env{ | |||
status: 200, | |||
body: user, | |||
headers: [{"content-type", "application/activity+json"}] | |||
} | |||
%{ | |||
method: :get, | |||
url: ^object_url | |||
} -> | |||
%Tesla.Env{ | |||
status: 200, | |||
body: object, | |||
headers: [{"content-type", "application/activity+json"}] | |||
} | |||
end) | |||
message = %{ | |||
"id" => "https://example.com/objects/d61d6733-e256-4fe1-ab13-1e369789423f", | |||
"actor" => actor, | |||
"object" => object_url, | |||
"target" => "https://example.com/users/lain/collections/featured", | |||
"type" => "Add", | |||
"to" => [Pleroma.Constants.as_public()], | |||
"cc" => ["https://example.com/users/lain/followers"] | |||
} | |||
assert {:ok, activity} = Transmogrifier.handle_incoming(message) | |||
assert activity.data == message | |||
user = User.get_cached_by_ap_id(actor) | |||
assert user.pinned_objects[object_url] | |||
remove = %{ | |||
"id" => "http://localhost:400/objects/d61d6733-e256-4fe1-ab13-1e369789423d", | |||
"actor" => actor, | |||
"object" => object_url, | |||
"target" => "http://example.com/users/lain/collections/featured", | |||
"type" => "Remove", | |||
"to" => [Pleroma.Constants.as_public()], | |||
"cc" => ["https://example.com/users/lain/followers"] | |||
} | |||
assert {:ok, activity} = Transmogrifier.handle_incoming(remove) | |||
assert activity.data == remove | |||
user = refresh_record(user) | |||
refute user.pinned_objects[object_url] | |||
end | |||
end | |||
describe "prepare outgoing" do | |||
@@ -827,13 +827,17 @@ defmodule Pleroma.Web.CommonAPITest do | |||
[user: user, activity: activity] | |||
end | |||
test "activity not found error", %{user: user} do | |||
assert {:error, :not_found} = CommonAPI.pin("id", user) | |||
end | |||
test "pin status", %{user: user, activity: activity} do | |||
assert {:ok, ^activity} = CommonAPI.pin(activity.id, user) | |||
id = activity.id | |||
%{data: %{"id" => object_id}} = Object.normalize(activity) | |||
user = refresh_record(user) | |||
assert %User{pinned_activities: [^id]} = user | |||
assert user.pinned_objects |> Map.keys() == [object_id] | |||
end | |||
test "pin poll", %{user: user} do | |||
@@ -845,10 +849,11 @@ defmodule Pleroma.Web.CommonAPITest do | |||
assert {:ok, ^activity} = CommonAPI.pin(activity.id, user) | |||
id = activity.id | |||
%{data: %{"id" => object_id}} = Object.normalize(activity) | |||
user = refresh_record(user) | |||
assert %User{pinned_activities: [^id]} = user | |||
assert user.pinned_objects |> Map.keys() == [object_id] | |||
end | |||
test "unlisted statuses can be pinned", %{user: user} do | |||
@@ -859,7 +864,7 @@ defmodule Pleroma.Web.CommonAPITest do | |||
test "only self-authored can be pinned", %{activity: activity} do | |||
user = insert(:user) | |||
assert {:error, "Could not pin"} = CommonAPI.pin(activity.id, user) | |||
assert {:error, :ownership_error} = CommonAPI.pin(activity.id, user) | |||
end | |||
test "max pinned statuses", %{user: user, activity: activity_one} do | |||
@@ -869,8 +874,12 @@ defmodule Pleroma.Web.CommonAPITest do | |||
user = refresh_record(user) | |||
assert {:error, "You have already pinned the maximum number of statuses"} = | |||
CommonAPI.pin(activity_two.id, user) | |||
assert {:error, :pinned_statuses_limit_reached} = CommonAPI.pin(activity_two.id, user) | |||
end | |||
test "only public can be pinned", %{user: user} do | |||
{:ok, activity} = CommonAPI.post(user, %{status: "private status", visibility: "private"}) | |||
{:error, :visibility_error} = CommonAPI.pin(activity.id, user) | |||
end | |||
test "unpin status", %{user: user, activity: activity} do | |||
@@ -884,7 +893,7 @@ defmodule Pleroma.Web.CommonAPITest do | |||
user = refresh_record(user) | |||
assert %User{pinned_activities: []} = user | |||
assert user.pinned_objects == %{} | |||
end | |||
test "should unpin when deleting a status", %{user: user, activity: activity} do | |||
@@ -896,7 +905,40 @@ defmodule Pleroma.Web.CommonAPITest do | |||
user = refresh_record(user) | |||
assert %User{pinned_activities: []} = user | |||
assert user.pinned_objects == %{} | |||
end | |||
test "ephemeral activity won't be deleted if was pinned", %{user: user} do | |||
{:ok, activity} = CommonAPI.post(user, %{status: "Hello!", expires_in: 601}) | |||
assert Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) | |||
{:ok, _activity} = CommonAPI.pin(activity.id, user) | |||
refute Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) | |||
user = refresh_record(user) | |||
{:ok, _} = CommonAPI.unpin(activity.id, user) | |||
# recreates expiration job on unpin | |||
assert Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) | |||
end | |||
test "ephemeral activity deletion job won't be deleted on pinning error", %{ | |||
user: user, | |||
activity: activity | |||
} do | |||
clear_config([:instance, :max_pinned_statuses], 1) | |||
{:ok, _activity} = CommonAPI.pin(activity.id, user) | |||
{:ok, activity2} = CommonAPI.post(user, %{status: "another status", expires_in: 601}) | |||
assert Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity2.id) | |||
user = refresh_record(user) | |||
{:error, :pinned_statuses_limit_reached} = CommonAPI.pin(activity2.id, user) | |||
assert Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity2.id) | |||
end | |||
end | |||
@@ -1223,6 +1223,13 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do | |||
|> json_response_and_validate_schema(200) | |||
end | |||
test "non authenticated user", %{activity: activity} do | |||
assert build_conn() | |||
|> put_req_header("content-type", "application/json") | |||
|> post("/api/v1/statuses/#{activity.id}/pin") | |||
|> json_response(403) == %{"error" => "Invalid credentials."} | |||
end | |||
test "/pin: returns 400 error when activity is not public", %{conn: conn, user: user} do | |||
{:ok, dm} = CommonAPI.post(user, %{status: "test", visibility: "direct"}) | |||
@@ -1231,7 +1238,18 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do | |||
|> put_req_header("content-type", "application/json") | |||
|> post("/api/v1/statuses/#{dm.id}/pin") | |||
assert json_response_and_validate_schema(conn, 400) == %{"error" => "Could not pin"} | |||
assert json_response_and_validate_schema(conn, 422) == %{ | |||
"error" => "Non-public status cannot be pinned" | |||
} | |||
end | |||
test "pin by another user", %{activity: activity} do | |||
%{conn: conn} = oauth_access(["write:accounts"]) | |||
assert conn | |||
|> put_req_header("content-type", "application/json") | |||
|> post("/api/v1/statuses/#{activity.id}/pin") | |||
|> json_response(422) == %{"error" => "Someone else's status cannot be pinned"} | |||
end | |||
test "unpin status", %{conn: conn, user: user, activity: activity} do | |||
@@ -1252,13 +1270,11 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do | |||
|> json_response_and_validate_schema(200) | |||
end | |||
test "/unpin: returns 400 error when activity is not exist", %{conn: conn} do | |||
conn = | |||
conn | |||
|> put_req_header("content-type", "application/json") | |||
|> post("/api/v1/statuses/1/unpin") | |||
assert json_response_and_validate_schema(conn, 400) == %{"error" => "Could not unpin"} | |||
test "/unpin: returns 404 error when activity doesn't exist", %{conn: conn} do | |||
assert conn | |||
|> put_req_header("content-type", "application/json") | |||
|> post("/api/v1/statuses/1/unpin") | |||
|> json_response_and_validate_schema(404) == %{"error" => "Record not found"} | |||
end | |||
test "max pinned statuses", %{conn: conn, user: user, activity: activity_one} do | |||
@@ -286,7 +286,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do | |||
direct_conversation_id: nil, | |||
thread_muted: false, | |||
emoji_reactions: [], | |||
parent_visible: false | |||
parent_visible: false, | |||
pinned_at: nil | |||
} | |||
} | |||
@@ -27,6 +27,16 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowControllerTest do | |||
body: File.read!("test/fixtures/tesla_mock/status.emelie.json") | |||
} | |||
%{method: :get, url: "https://mastodon.social/users/emelie/collections/featured"} -> | |||
%Tesla.Env{ | |||
status: 200, | |||
headers: [{"content-type", "application/activity+json"}], | |||
body: | |||
File.read!("test/fixtures/users_mock/masto_featured.json") | |||
|> String.replace("{{domain}}", "mastodon.social") | |||
|> String.replace("{{nickname}}", "emelie") | |||
} | |||
%{method: :get, url: "https://mastodon.social/users/emelie"} -> | |||
%Tesla.Env{ | |||
status: 200, | |||
@@ -52,6 +62,16 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowControllerTest do | |||
headers: [{"content-type", "application/activity+json"}], | |||
body: File.read!("test/fixtures/tesla_mock/emelie.json") | |||
} | |||
%{method: :get, url: "https://mastodon.social/users/emelie/collections/featured"} -> | |||
%Tesla.Env{ | |||
status: 200, | |||
headers: [{"content-type", "application/activity+json"}], | |||
body: | |||
File.read!("test/fixtures/users_mock/masto_featured.json") | |||
|> String.replace("{{domain}}", "mastodon.social") | |||
|> String.replace("{{nickname}}", "emelie") | |||
} | |||
end) | |||
response = | |||
@@ -70,6 +90,16 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowControllerTest do | |||
headers: [{"content-type", "application/activity+json"}], | |||
body: File.read!("test/fixtures/tesla_mock/emelie.json") | |||
} | |||
%{method: :get, url: "https://mastodon.social/users/emelie/collections/featured"} -> | |||
%Tesla.Env{ | |||
status: 200, | |||
headers: [{"content-type", "application/activity+json"}], | |||
body: | |||
File.read!("test/fixtures/users_mock/masto_featured.json") | |||
|> String.replace("{{domain}}", "mastodon.social") | |||
|> String.replace("{{nickname}}", "emelie") | |||
} | |||
end) | |||
user = insert(:user) | |||
@@ -48,13 +48,15 @@ defmodule Pleroma.Factory do | |||
%{ | |||
ap_id: ap_id, | |||
follower_address: ap_id <> "/followers", | |||
following_address: ap_id <> "/following" | |||
following_address: ap_id <> "/following", | |||
featured_address: ap_id <> "/collections/featured" | |||
} | |||
else | |||
%{ | |||
ap_id: User.ap_id(user), | |||
follower_address: User.ap_followers(user), | |||
following_address: User.ap_following(user) | |||
following_address: User.ap_following(user), | |||
featured_address: User.ap_featured_collection(user) | |||
} | |||
end | |||
@@ -89,6 +89,18 @@ defmodule HttpRequestMock do | |||
}} | |||
end | |||
def get("https://mastodon.sdf.org/users/rinpatch/collections/featured", _, _, _) do | |||
{:ok, | |||
%Tesla.Env{ | |||
status: 200, | |||
body: | |||
File.read!("test/fixtures/users_mock/masto_featured.json") | |||
|> String.replace("{{domain}}", "mastodon.sdf.org") | |||
|> String.replace("{{nickname}}", "rinpatch"), | |||
headers: [{"content-type", "application/activity+json"}] | |||
}} | |||
end | |||
def get("https://patch.cx/objects/tesla_mock/poll_attachment", _, _, _) do | |||
{:ok, | |||
%Tesla.Env{ | |||
@@ -905,6 +917,17 @@ defmodule HttpRequestMock do | |||
}} | |||
end | |||
def get("https://mastodon.social/users/lambadalambda/collections/featured", _, _, _) do | |||
{:ok, | |||
%Tesla.Env{ | |||
status: 200, | |||
body: | |||
File.read!("test/fixtures/users_mock/masto_featured.json") | |||
|> String.replace("{{domain}}", "mastodon.social") | |||
|> String.replace("{{nickname}}", "lambadalambda") | |||
}} | |||
end | |||
def get("https://apfed.club/channel/indio", _, _, _) do | |||
{:ok, | |||
%Tesla.Env{ | |||