Merge branch 'feature/user-status-subscriptions' into 'develop'

Add ability to subscribe to users

See merge request pleroma/pleroma!1024
This commit is contained in:
lambda 2019-04-10 10:04:20 +00:00
commit 6504b43f96
13 changed files with 257 additions and 2 deletions

View File

@ -74,7 +74,7 @@ Request parameters can be passed via [query strings](https://en.wikipedia.org/wi
* `confirm` * `confirm`
* `captcha_solution`: optional, contains provider-specific captcha solution, * `captcha_solution`: optional, contains provider-specific captcha solution,
* `captcha_token`: optional, contains provider-specific captcha token * `captcha_token`: optional, contains provider-specific captcha token
* `token`: invite token required when the registerations aren't public. * `token`: invite token required when the registrations aren't public.
* Response: JSON. Returns a user object on success, otherwise returns `{"error": "error_msg"}` * Response: JSON. Returns a user object on success, otherwise returns `{"error": "error_msg"}`
* Example response: * Example response:
``` ```
@ -136,8 +136,57 @@ See [Admin-API](Admin-API.md)
* Method `POST` * Method `POST`
* Authentication: required * Authentication: required
* Params: * Params:
* `id`: notifications's id * `id`: notification's id
* Response: JSON. Returns `{"status": "success"}` if the reading was successful, otherwise returns `{"error": "error_msg"}` * Response: JSON. Returns `{"status": "success"}` if the reading was successful, otherwise returns `{"error": "error_msg"}`
## `/api/v1/pleroma/accounts/:id/subscribe`
### Subscribe to receive notifications for all statuses posted by a user
* Method `POST`
* Authentication: required
* Params:
* `id`: account id to subscribe to
* Response: JSON, returns a mastodon relationship object on success, otherwise returns `{"error": "error_msg"}`
* Example response:
```json
{
"id": "abcdefg",
"following": true,
"followed_by": false,
"blocking": false,
"muting": false,
"muting_notifications": false,
"subscribing": true,
"requested": false,
"domain_blocking": false,
"showing_reblogs": true,
"endorsed": false
}
```
## `/api/v1/pleroma/accounts/:id/unsubscribe`
### Unsubscribe to stop receiving notifications from user statuses
* Method `POST`
* Authentication: required
* Params:
* `id`: account id to unsubscribe from
* Response: JSON, returns a mastodon relationship object on success, otherwise returns `{"error": "error_msg"}`
* Example response:
```json
{
"id": "abcdefg",
"following": true,
"followed_by": false,
"blocking": false,
"muting": false,
"muting_notifications": false,
"subscribing": false,
"requested": false,
"domain_blocking": false,
"showing_reblogs": true,
"endorsed": false
}
```
## `/api/pleroma/notification_settings` ## `/api/pleroma/notification_settings`
### Updates user notification settings ### Updates user notification settings
* Method `PUT` * Method `PUT`

View File

@ -142,6 +142,7 @@ def get_notified_from_activity(
[] []
|> Utils.maybe_notify_to_recipients(activity) |> Utils.maybe_notify_to_recipients(activity)
|> Utils.maybe_notify_mentioned_recipients(activity) |> Utils.maybe_notify_mentioned_recipients(activity)
|> Utils.maybe_notify_subscribers(activity)
|> Enum.uniq() |> Enum.uniq()
User.get_users_from_set(recipients, local_only) User.get_users_from_set(recipients, local_only)

View File

@ -931,6 +931,38 @@ def unmute(muter, %{ap_id: ap_id}) do
update_and_set_cache(cng) update_and_set_cache(cng)
end end
def subscribe(subscriber, %{ap_id: ap_id}) do
deny_follow_blocked = Pleroma.Config.get([:user, :deny_follow_blocked])
with %User{} = subscribed <- get_cached_by_ap_id(ap_id) do
blocked = blocks?(subscribed, subscriber) and deny_follow_blocked
if blocked do
{:error, "Could not subscribe: #{subscribed.nickname} is blocking you"}
else
info_cng =
subscribed.info
|> User.Info.add_to_subscribers(subscriber.ap_id)
change(subscribed)
|> put_embed(:info, info_cng)
|> update_and_set_cache()
end
end
end
def unsubscribe(unsubscriber, %{ap_id: ap_id}) do
with %User{} = user <- get_cached_by_ap_id(ap_id) do
info_cng =
user.info
|> User.Info.remove_from_subscribers(unsubscriber.ap_id)
change(user)
|> put_embed(:info, info_cng)
|> update_and_set_cache()
end
end
def block(blocker, %User{ap_id: ap_id} = blocked) do def block(blocker, %User{ap_id: ap_id} = blocked) do
# sever any follow relationships to prevent leaks per activitypub (Pleroma issue #213) # sever any follow relationships to prevent leaks per activitypub (Pleroma issue #213)
blocker = blocker =
@ -941,6 +973,14 @@ def block(blocker, %User{ap_id: ap_id} = blocked) do
blocker blocker
end end
blocker =
if subscribed_to?(blocked, blocker) do
{:ok, blocker} = unsubscribe(blocked, blocker)
blocker
else
blocker
end
if following?(blocked, blocker) do if following?(blocked, blocker) do
unfollow(blocked, blocker) unfollow(blocked, blocker)
end end
@ -989,12 +1029,21 @@ def blocks?(user, %{ap_id: ap_id}) do
end) end)
end end
def subscribed_to?(user, %{ap_id: ap_id}) do
with %User{} = target <- User.get_by_ap_id(ap_id) do
Enum.member?(target.info.subscribers, user.ap_id)
end
end
def muted_users(user), def muted_users(user),
do: Repo.all(from(u in User, where: u.ap_id in ^user.info.mutes)) do: Repo.all(from(u in User, where: u.ap_id in ^user.info.mutes))
def blocked_users(user), def blocked_users(user),
do: Repo.all(from(u in User, where: u.ap_id in ^user.info.blocks)) do: Repo.all(from(u in User, where: u.ap_id in ^user.info.blocks))
def subscribers(user),
do: Repo.all(from(u in User, where: u.ap_id in ^user.info.subscribers))
def block_domain(user, domain) do def block_domain(user, domain) do
info_cng = info_cng =
user.info user.info

View File

@ -22,6 +22,7 @@ defmodule Pleroma.User.Info do
field(:domain_blocks, {:array, :string}, default: []) field(:domain_blocks, {:array, :string}, default: [])
field(:mutes, {:array, :string}, default: []) field(:mutes, {:array, :string}, default: [])
field(:muted_reblogs, {:array, :string}, default: []) field(:muted_reblogs, {:array, :string}, default: [])
field(:subscribers, {:array, :string}, default: [])
field(:deactivated, :boolean, default: false) field(:deactivated, :boolean, default: false)
field(:no_rich_text, :boolean, default: false) field(:no_rich_text, :boolean, default: false)
field(:ap_enabled, :boolean, default: false) field(:ap_enabled, :boolean, default: false)
@ -110,6 +111,14 @@ def set_blocks(info, blocks) do
|> validate_required([:blocks]) |> validate_required([:blocks])
end end
def set_subscribers(info, subscribers) do
params = %{subscribers: subscribers}
info
|> cast(params, [:subscribers])
|> validate_required([:subscribers])
end
def add_to_mutes(info, muted) do def add_to_mutes(info, muted) do
set_mutes(info, Enum.uniq([muted | info.mutes])) set_mutes(info, Enum.uniq([muted | info.mutes]))
end end
@ -126,6 +135,14 @@ def remove_from_block(info, blocked) do
set_blocks(info, List.delete(info.blocks, blocked)) set_blocks(info, List.delete(info.blocks, blocked))
end end
def add_to_subscribers(info, subscribed) do
set_subscribers(info, Enum.uniq([subscribed | info.subscribers]))
end
def remove_from_subscribers(info, subscribed) do
set_subscribers(info, List.delete(info.subscribers, subscribed))
end
def set_domain_blocks(info, domain_blocks) do def set_domain_blocks(info, domain_blocks) do
params = %{domain_blocks: domain_blocks} params = %{domain_blocks: domain_blocks}

View File

@ -12,6 +12,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.Endpoint alias Pleroma.Web.Endpoint
alias Pleroma.Web.MediaProxy alias Pleroma.Web.MediaProxy
@ -335,6 +336,24 @@ def maybe_notify_mentioned_recipients(
def maybe_notify_mentioned_recipients(recipients, _), do: recipients def maybe_notify_mentioned_recipients(recipients, _), do: recipients
def maybe_notify_subscribers(
recipients,
%Activity{data: %{"actor" => actor, "type" => type}} = activity
)
when type == "Create" do
with %User{} = user <- User.get_cached_by_ap_id(actor) do
subscriber_ids =
user
|> User.subscribers()
|> Enum.filter(&Visibility.visible_for_user?(activity, &1))
|> Enum.map(& &1.ap_id)
recipients ++ subscriber_ids
end
end
def maybe_notify_subscribers(recipients, _), do: recipients
def maybe_extract_mentions(%{"tag" => tag}) do def maybe_extract_mentions(%{"tag" => tag}) do
tag tag
|> Enum.filter(fn x -> is_map(x) end) |> Enum.filter(fn x -> is_map(x) end)

View File

@ -931,6 +931,34 @@ def unblock_domain(%{assigns: %{user: blocker}} = conn, %{"domain" => domain}) d
json(conn, %{}) json(conn, %{})
end end
def subscribe(%{assigns: %{user: user}} = conn, %{"id" => id}) do
with %User{} = subscription_target <- User.get_cached_by_id(id),
{:ok, subscription_target} = User.subscribe(user, subscription_target) do
conn
|> put_view(AccountView)
|> render("relationship.json", %{user: user, target: subscription_target})
else
{:error, message} ->
conn
|> put_resp_content_type("application/json")
|> send_resp(403, Jason.encode!(%{"error" => message}))
end
end
def unsubscribe(%{assigns: %{user: user}} = conn, %{"id" => id}) do
with %User{} = subscription_target <- User.get_cached_by_id(id),
{:ok, subscription_target} = User.unsubscribe(user, subscription_target) do
conn
|> put_view(AccountView)
|> render("relationship.json", %{user: user, target: subscription_target})
else
{:error, message} ->
conn
|> put_resp_content_type("application/json")
|> send_resp(403, Jason.encode!(%{"error" => message}))
end
end
def status_search(user, query) do def status_search(user, query) do
fetched = fetched =
if Regex.match?(~r/https?:/, query) do if Regex.match?(~r/https?:/, query) do

View File

@ -53,6 +53,7 @@ def render("relationship.json", %{user: %User{} = user, target: %User{} = target
blocking: User.blocks?(user, target), blocking: User.blocks?(user, target),
muting: User.mutes?(user, target), muting: User.mutes?(user, target),
muting_notifications: false, muting_notifications: false,
subscribing: User.subscribed_to?(user, target),
requested: requested, requested: requested,
domain_blocking: false, domain_blocking: false,
showing_reblogs: User.showing_reblogs?(user, target), showing_reblogs: User.showing_reblogs?(user, target),

View File

@ -337,6 +337,9 @@ defmodule Pleroma.Web.Router do
post("/domain_blocks", MastodonAPIController, :block_domain) post("/domain_blocks", MastodonAPIController, :block_domain)
delete("/domain_blocks", MastodonAPIController, :unblock_domain) delete("/domain_blocks", MastodonAPIController, :unblock_domain)
post("/pleroma/accounts/:id/subscribe", MastodonAPIController, :subscribe)
post("/pleroma/accounts/:id/unsubscribe", MastodonAPIController, :unsubscribe)
end end
scope [] do scope [] do

View File

@ -0,0 +1,8 @@
defmodule Pleroma.Repo.Migrations.AddIndexOnSubscribers do
use Ecto.Migration
@disable_ddl_transaction true
def change do
create index(:users, ["(info->'subscribers')"], name: :users_subscribers_index, using: :gin, concurrently: true)
end
end

View File

@ -29,6 +29,18 @@ test "notifies someone when they are directly addressed" do
assert notification.activity_id == activity.id assert notification.activity_id == activity.id
assert other_notification.activity_id == activity.id assert other_notification.activity_id == activity.id
end end
test "it creates a notification for subscribed users" do
user = insert(:user)
subscriber = insert(:user)
User.subscribe(subscriber, user)
{:ok, status} = TwitterAPI.create_status(user, %{"status" => "Akariiiin"})
{:ok, [notification]} = Notification.create_notifications(status)
assert notification.user_id == subscriber.id
end
end end
describe "create_notification" do describe "create_notification" do
@ -153,6 +165,28 @@ test "it doesn't create a notification for repeat-unrepeat-repeat chains" do
{:ok, dupe} = TwitterAPI.repeat(user, status.id) {:ok, dupe} = TwitterAPI.repeat(user, status.id)
assert nil == Notification.create_notification(dupe, retweeted_user) assert nil == Notification.create_notification(dupe, retweeted_user)
end end
test "it doesn't create duplicate notifications for follow+subscribed users" do
user = insert(:user)
subscriber = insert(:user)
{:ok, _, _, _} = TwitterAPI.follow(subscriber, %{"user_id" => user.id})
User.subscribe(subscriber, user)
{:ok, status} = TwitterAPI.create_status(user, %{"status" => "Akariiiin"})
{:ok, [_notif]} = Notification.create_notifications(status)
end
test "it doesn't create subscription notifications if the recipient cannot see the status" do
user = insert(:user)
subscriber = insert(:user)
User.subscribe(subscriber, user)
{:ok, status} =
TwitterAPI.create_status(user, %{"status" => "inwisible", "visibility" => "direct"})
assert {:ok, []} == Notification.create_notifications(status)
end
end end
describe "get notification" do describe "get notification" do

View File

@ -146,6 +146,15 @@ test "can't follow a user who blocked us" do
{:error, _} = User.follow(blockee, blocker) {:error, _} = User.follow(blockee, blocker)
end end
test "can't subscribe to a user who blocked us" do
blocker = insert(:user)
blocked = insert(:user)
{:ok, blocker} = User.block(blocker, blocked)
{:error, _} = User.subscribe(blocked, blocker)
end
test "local users do not automatically follow local locked accounts" do test "local users do not automatically follow local locked accounts" do
follower = insert(:user, info: %{locked: true}) follower = insert(:user, info: %{locked: true})
followed = insert(:user, info: %{locked: true}) followed = insert(:user, info: %{locked: true})
@ -729,6 +738,22 @@ test "blocks tear down blocked->blocker follow relationships" do
refute User.following?(blocker, blocked) refute User.following?(blocker, blocked)
refute User.following?(blocked, blocker) refute User.following?(blocked, blocker)
end end
test "blocks tear down blocked->blocker subscription relationships" do
blocker = insert(:user)
blocked = insert(:user)
{:ok, blocker} = User.subscribe(blocked, blocker)
assert User.subscribed_to?(blocked, blocker)
refute User.subscribed_to?(blocker, blocked)
{:ok, blocker} = User.block(blocker, blocked)
assert User.blocks?(blocker, blocked)
refute User.subscribed_to?(blocker, blocked)
refute User.subscribed_to?(blocked, blocker)
end
end end
describe "domain blocking" do describe "domain blocking" do

View File

@ -156,6 +156,7 @@ test "represent a relationship" do
blocking: true, blocking: true,
muting: false, muting: false,
muting_notifications: false, muting_notifications: false,
subscribing: false,
requested: false, requested: false,
domain_blocking: false, domain_blocking: false,
showing_reblogs: true, showing_reblogs: true,
@ -212,6 +213,7 @@ test "represent an embedded relationship" do
following: false, following: false,
followed_by: false, followed_by: false,
blocking: true, blocking: true,
subscribing: false,
muting: false, muting: false,
muting_notifications: false, muting_notifications: false,
requested: false, requested: false,

View File

@ -1556,6 +1556,25 @@ test "muting / unmuting a user", %{conn: conn} do
assert %{"id" => _id, "muting" => false} = json_response(conn, 200) assert %{"id" => _id, "muting" => false} = json_response(conn, 200)
end end
test "subscribing / unsubscribing to a user", %{conn: conn} do
user = insert(:user)
subscription_target = insert(:user)
conn =
conn
|> assign(:user, user)
|> post("/api/v1/pleroma/accounts/#{subscription_target.id}/subscribe")
assert %{"id" => _id, "subscribing" => true} = json_response(conn, 200)
conn =
build_conn()
|> assign(:user, user)
|> post("/api/v1/pleroma/accounts/#{subscription_target.id}/unsubscribe")
assert %{"id" => _id, "subscribing" => false} = json_response(conn, 200)
end
test "getting a list of mutes", %{conn: conn} do test "getting a list of mutes", %{conn: conn} do
user = insert(:user) user = insert(:user)
other_user = insert(:user) other_user = insert(:user)