Let pins federate

- 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 pins
This commit is contained in:
Alexander Strizhakov 2021-02-03 16:09:28 +03:00
commit 3ec1dbd922
No known key found for this signature in database
GPG key ID: 022896A53AEF1381
38 changed files with 1127 additions and 122 deletions

View file

@ -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()
@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)
_ ->
nil
@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_user_actor(id) do
case FlakeId.flake_id?(id) do
true ->
Activity
|> where([a], a.id == ^id)
|> with_preloaded_user_actor()
|> Repo.one()
_ ->
nil
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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