- `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
|> 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)
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
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
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(
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: [])
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: %{})
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,
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"
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))
:uri,
:follower_address,
:following_address,
+ :featured_address,
:hide_followers,
:hide_follows,
:hide_followers_count,
:invisible,
:actor_type,
:also_known_as,
- :accepts_chat_messages
+ :accepts_chat_messages,
+ :pinned_objects
]
)
|> cast(params, [:name], empty_values: [])
|> 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
|> 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
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
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
|> 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
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
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"]
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
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)
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)
|> 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
"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
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
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
--- /dev/null
+# 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
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)
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
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
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
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
"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,
|> 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
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
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
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"
}
}
},
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
|> 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)
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
|> 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"],
favourited: present?(favorited),
bookmarked: present?(bookmarked),
muted: false,
- pinned: pinned?(activity, user),
+ pinned: pinned?,
sensitive: false,
spoiler_text: "",
visibility: get_visibility(activity),
language: nil,
emojis: [],
pleroma: %{
- local: activity.local
+ local: activity.local,
+ pinned_at: pinned_at
}
}
end
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"],
favourited: present?(favorited),
bookmarked: present?(bookmarked),
muted: muted,
- pinned: pinned?(activity, user),
+ pinned: pinned?,
sensitive: sensitive,
spoiler_text: summary,
visibility: get_visibility(object),
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
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
%{
# 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
--- /dev/null
+defmodule Pleroma.Repo.Migrations.AddPinnedObjectsToUsers do
+ use Ecto.Migration
+
+ def change do
+ alter table(:users) do
+ add(:pinned_objects, :map)
+ end
+ end
+end
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+{
+ "@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"
+}
--- /dev/null
+{
+ "@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}}"
+}
--- /dev/null
+{
+ "@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"
+}
--- /dev/null
+{
+ "@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": []
+}
--- /dev/null
+{
+ "@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}}"
+}
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
|> 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
|> 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
"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
use Oban.Testing, repo: Pleroma.Repo
use Pleroma.DataCase
+ require Pleroma.Constants
+
alias Pleroma.Activity
alias Pleroma.Object
alias Pleroma.Tests.ObanHelpers
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
[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
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
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
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
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
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
|> 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"})
|> 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
|> 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
direct_conversation_id: nil,
thread_muted: false,
emoji_reactions: [],
- parent_visible: false
+ parent_visible: false,
+ pinned_at: nil
}
}
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,
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 =
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)
%{
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
}}
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{
}}
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{