@@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). | |||
- Authentication: Added rate limit for password-authorized actions / login existence checks | |||
- Metadata Link: Atom syndication Feed | |||
- Mix task to re-count statuses for all users (`mix pleroma.count_statuses`) | |||
- Mastodon API: Add `exclude_visibilities` parameter to the timeline and notification endpoints | |||
### Changed | |||
- **Breaking:** Elixir >=1.8 is now required (was >= 1.7) | |||
@@ -13,6 +13,7 @@ Some apps operate under the assumption that no more than 4 attachments can be re | |||
## Timelines | |||
Adding the parameter `with_muted=true` to the timeline queries will also return activities by muted (not by blocked!) users. | |||
Adding the parameter `exclude_visibilities` to the timeline queries will exclude the statuses with the given visibilities. The parameter accepts an array of visibility types (`public`, `unlisted`, `private`, `direct`), e.g., `exclude_visibilities[]=direct&exclude_visibilities[]=private`. | |||
## Statuses | |||
@@ -84,6 +85,12 @@ Has these additional fields under the `pleroma` object: | |||
- `is_seen`: true if the notification was read by the user | |||
## GET `/api/v1/notifications` | |||
Accepts additional parameters: | |||
- `exclude_visibilities`: will exclude the notifications for activities with the given visibilities. The parameter accepts an array of visibility types (`public`, `unlisted`, `private`, `direct`). Usage example: `GET /api/v1/notifications?exclude_visibilities[]=direct&exclude_visibilities[]=private`. | |||
## POST `/api/v1/statuses` | |||
Additional parameters can be added to the JSON body/Form data: | |||
@@ -17,6 +17,7 @@ defmodule Pleroma.Notification do | |||
import Ecto.Query | |||
import Ecto.Changeset | |||
require Logger | |||
@type t :: %__MODULE__{} | |||
@@ -34,43 +35,92 @@ defmodule Pleroma.Notification do | |||
end | |||
def for_user_query(user, opts \\ []) do | |||
query = | |||
Notification | |||
|> where(user_id: ^user.id) | |||
|> where( | |||
[n, a], | |||
Notification | |||
|> where(user_id: ^user.id) | |||
|> where( | |||
[n, a], | |||
fragment( | |||
"? not in (SELECT ap_id FROM users WHERE info->'deactivated' @> 'true')", | |||
a.actor | |||
) | |||
) | |||
|> join(:inner, [n], activity in assoc(n, :activity)) | |||
|> join(:left, [n, a], object in Object, | |||
on: | |||
fragment( | |||
"? not in (SELECT ap_id FROM users WHERE info->'deactivated' @> 'true')", | |||
a.actor | |||
"(?->>'id') = COALESCE((? -> 'object'::text) ->> 'id'::text)", | |||
object.data, | |||
a.data | |||
) | |||
) | |||
|> join(:inner, [n], activity in assoc(n, :activity)) | |||
|> join(:left, [n, a], object in Object, | |||
on: | |||
fragment( | |||
"(?->>'id') = COALESCE((? -> 'object'::text) ->> 'id'::text)", | |||
object.data, | |||
a.data | |||
) | |||
) | |||
|> preload([n, a, o], activity: {a, object: o}) | |||
) | |||
|> preload([n, a, o], activity: {a, object: o}) | |||
|> exclude_muted(user, opts) | |||
|> exclude_visibility(opts) | |||
end | |||
defp exclude_muted(query, _, %{with_muted: true}) do | |||
query | |||
end | |||
defp exclude_muted(query, user, _opts) do | |||
query | |||
|> where([n, a], a.actor not in ^user.info.muted_notifications) | |||
|> where([n, a], a.actor not in ^user.info.blocks) | |||
|> where( | |||
[n, a], | |||
fragment("substring(? from '.*://([^/]*)')", a.actor) not in ^user.info.domain_blocks | |||
) | |||
|> join(:left, [n, a], tm in Pleroma.ThreadMute, | |||
on: tm.user_id == ^user.id and tm.context == fragment("?->>'context'", a.data) | |||
) | |||
|> where([n, a, o, tm], is_nil(tm.user_id)) | |||
end | |||
if opts[:with_muted] do | |||
@valid_visibilities ~w[direct unlisted public private] | |||
defp exclude_visibility(query, %{exclude_visibilities: visibility}) | |||
when is_list(visibility) do | |||
if Enum.all?(visibility, &(&1 in @valid_visibilities)) do | |||
query | |||
else | |||
where(query, [n, a], a.actor not in ^user.info.muted_notifications) | |||
|> where([n, a], a.actor not in ^user.info.blocks) | |||
|> where( | |||
[n, a], | |||
fragment("substring(? from '.*://([^/]*)')", a.actor) not in ^user.info.domain_blocks | |||
) | |||
|> join(:left, [n, a], tm in Pleroma.ThreadMute, | |||
on: tm.user_id == ^user.id and tm.context == fragment("?->>'context'", a.data) | |||
not fragment( | |||
"activity_visibility(?, ?, ?) = ANY (?)", | |||
a.actor, | |||
a.recipients, | |||
a.data, | |||
^visibility | |||
) | |||
) | |||
|> where([n, a, o, tm], is_nil(tm.user_id)) | |||
else | |||
Logger.error("Could not exclude visibility to #{visibility}") | |||
query | |||
end | |||
end | |||
defp exclude_visibility(query, %{exclude_visibilities: visibility}) | |||
when visibility in @valid_visibilities do | |||
query | |||
|> where( | |||
[n, a], | |||
not fragment( | |||
"activity_visibility(?, ?, ?) = (?)", | |||
a.actor, | |||
a.recipients, | |||
a.data, | |||
^visibility | |||
) | |||
) | |||
end | |||
defp exclude_visibility(query, %{exclude_visibilities: visibility}) | |||
when visibility not in @valid_visibilities do | |||
Logger.error("Could not exclude visibility to #{visibility}") | |||
query | |||
end | |||
defp exclude_visibility(query, _visibility), do: query | |||
def for_user(user, opts \\ %{}) do | |||
user | |||
|> for_user_query(opts) | |||
@@ -596,6 +596,49 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do | |||
defp restrict_visibility(query, _visibility), do: query | |||
defp exclude_visibility(query, %{"exclude_visibilities" => visibility}) | |||
when is_list(visibility) do | |||
if Enum.all?(visibility, &(&1 in @valid_visibilities)) do | |||
from( | |||
a in query, | |||
where: | |||
not fragment( | |||
"activity_visibility(?, ?, ?) = ANY (?)", | |||
a.actor, | |||
a.recipients, | |||
a.data, | |||
^visibility | |||
) | |||
) | |||
else | |||
Logger.error("Could not exclude visibility to #{visibility}") | |||
query | |||
end | |||
end | |||
defp exclude_visibility(query, %{"exclude_visibilities" => visibility}) | |||
when visibility in @valid_visibilities do | |||
from( | |||
a in query, | |||
where: | |||
not fragment( | |||
"activity_visibility(?, ?, ?) = ?", | |||
a.actor, | |||
a.recipients, | |||
a.data, | |||
^visibility | |||
) | |||
) | |||
end | |||
defp exclude_visibility(query, %{"exclude_visibilities" => visibility}) | |||
when visibility not in @valid_visibilities do | |||
Logger.error("Could not exclude visibility to #{visibility}") | |||
query | |||
end | |||
defp exclude_visibility(query, _visibility), do: query | |||
defp restrict_thread_visibility(query, _, %{skip_thread_containment: true} = _), | |||
do: query | |||
@@ -960,6 +1003,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do | |||
|> restrict_muted_reblogs(opts) | |||
|> Activity.restrict_deactivated_users() | |||
|> exclude_poll_votes(opts) | |||
|> exclude_visibility(opts) | |||
end | |||
def fetch_activities(recipients, opts \\ %{}, pagination \\ :keyset) do | |||
@@ -71,6 +71,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do | |||
defp cast_params(params) do | |||
param_types = %{ | |||
exclude_types: {:array, :string}, | |||
exclude_visibilities: {:array, :string}, | |||
reblogs: :boolean, | |||
with_muted: :boolean | |||
} | |||
@@ -87,6 +87,66 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do | |||
end | |||
end | |||
describe "fetching excluded by visibility" do | |||
test "it excludes by the appropriate visibility" do | |||
user = insert(:user) | |||
{:ok, public_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "public"}) | |||
{:ok, direct_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"}) | |||
{:ok, unlisted_activity} = | |||
CommonAPI.post(user, %{"status" => ".", "visibility" => "unlisted"}) | |||
{:ok, private_activity} = | |||
CommonAPI.post(user, %{"status" => ".", "visibility" => "private"}) | |||
activities = | |||
ActivityPub.fetch_activities([], %{ | |||
"exclude_visibilities" => "direct", | |||
"actor_id" => user.ap_id | |||
}) | |||
assert public_activity in activities | |||
assert unlisted_activity in activities | |||
assert private_activity in activities | |||
refute direct_activity in activities | |||
activities = | |||
ActivityPub.fetch_activities([], %{ | |||
"exclude_visibilities" => "unlisted", | |||
"actor_id" => user.ap_id | |||
}) | |||
assert public_activity in activities | |||
refute unlisted_activity in activities | |||
assert private_activity in activities | |||
assert direct_activity in activities | |||
activities = | |||
ActivityPub.fetch_activities([], %{ | |||
"exclude_visibilities" => "private", | |||
"actor_id" => user.ap_id | |||
}) | |||
assert public_activity in activities | |||
assert unlisted_activity in activities | |||
refute private_activity in activities | |||
assert direct_activity in activities | |||
activities = | |||
ActivityPub.fetch_activities([], %{ | |||
"exclude_visibilities" => "public", | |||
"actor_id" => user.ap_id | |||
}) | |||
refute public_activity in activities | |||
assert unlisted_activity in activities | |||
assert private_activity in activities | |||
assert direct_activity in activities | |||
end | |||
end | |||
describe "building a user from his ap id" do | |||
test "it returns a user" do | |||
user_id = "http://mastodon.example.org/users/admin" | |||
@@ -237,6 +237,20 @@ defmodule Pleroma.Web.MastodonAPI.AccountControllerTest do | |||
assert [%{"id" => id}] = json_response(conn, 200) | |||
assert id == to_string(post.id) | |||
end | |||
test "the user views their own timelines and excludes direct messages", %{conn: conn} do | |||
user = insert(:user) | |||
{:ok, public_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "public"}) | |||
{:ok, _direct_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"}) | |||
conn = | |||
conn | |||
|> assign(:user, user) | |||
|> get("/api/v1/accounts/#{user.id}/statuses", %{"exclude_visibilities" => ["direct"]}) | |||
assert [%{"id" => id}] = json_response(conn, 200) | |||
assert id == to_string(public_activity.id) | |||
end | |||
end | |||
describe "followers" do | |||
@@ -137,6 +137,57 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do | |||
assert [%{"id" => ^notification3_id}, %{"id" => ^notification2_id}] = result | |||
end | |||
test "filters notifications using exclude_visibilities", %{conn: conn} do | |||
user = insert(:user) | |||
other_user = insert(:user) | |||
{:ok, public_activity} = | |||
CommonAPI.post(other_user, %{"status" => "@#{user.nickname}", "visibility" => "public"}) | |||
{:ok, direct_activity} = | |||
CommonAPI.post(other_user, %{"status" => "@#{user.nickname}", "visibility" => "direct"}) | |||
{:ok, unlisted_activity} = | |||
CommonAPI.post(other_user, %{"status" => "@#{user.nickname}", "visibility" => "unlisted"}) | |||
{:ok, private_activity} = | |||
CommonAPI.post(other_user, %{"status" => "@#{user.nickname}", "visibility" => "private"}) | |||
conn = assign(conn, :user, user) | |||
conn_res = | |||
get(conn, "/api/v1/notifications", %{ | |||
exclude_visibilities: ["public", "unlisted", "private"] | |||
}) | |||
assert [%{"status" => %{"id" => id}}] = json_response(conn_res, 200) | |||
assert id == direct_activity.id | |||
conn_res = | |||
get(conn, "/api/v1/notifications", %{ | |||
exclude_visibilities: ["public", "unlisted", "direct"] | |||
}) | |||
assert [%{"status" => %{"id" => id}}] = json_response(conn_res, 200) | |||
assert id == private_activity.id | |||
conn_res = | |||
get(conn, "/api/v1/notifications", %{ | |||
exclude_visibilities: ["public", "private", "direct"] | |||
}) | |||
assert [%{"status" => %{"id" => id}}] = json_response(conn_res, 200) | |||
assert id == unlisted_activity.id | |||
conn_res = | |||
get(conn, "/api/v1/notifications", %{ | |||
exclude_visibilities: ["unlisted", "private", "direct"] | |||
}) | |||
assert [%{"status" => %{"id" => id}}] = json_response(conn_res, 200) | |||
assert id == public_activity.id | |||
end | |||
test "filters notifications using exclude_types", %{conn: conn} do | |||
user = insert(:user) | |||
other_user = insert(:user) | |||
@@ -20,27 +20,52 @@ defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest do | |||
:ok | |||
end | |||
test "the home timeline", %{conn: conn} do | |||
user = insert(:user) | |||
following = insert(:user) | |||
describe "home" do | |||
test "the home timeline", %{conn: conn} do | |||
user = insert(:user) | |||
following = insert(:user) | |||
{:ok, _activity} = CommonAPI.post(following, %{"status" => "test"}) | |||
{:ok, _activity} = CommonAPI.post(following, %{"status" => "test"}) | |||
conn = | |||
conn | |||
|> assign(:user, user) | |||
|> get("/api/v1/timelines/home") | |||
conn = | |||
conn | |||
|> assign(:user, user) | |||
|> get("/api/v1/timelines/home") | |||
assert Enum.empty?(json_response(conn, :ok)) | |||
assert Enum.empty?(json_response(conn, :ok)) | |||
{:ok, user} = User.follow(user, following) | |||
{:ok, user} = User.follow(user, following) | |||
conn = | |||
build_conn() | |||
|> assign(:user, user) | |||
|> get("/api/v1/timelines/home") | |||
conn = | |||
build_conn() | |||
|> assign(:user, user) | |||
|> get("/api/v1/timelines/home") | |||
assert [%{"content" => "test"}] = json_response(conn, :ok) | |||
assert [%{"content" => "test"}] = json_response(conn, :ok) | |||
end | |||
test "the home timeline when the direct messages are excluded", %{conn: conn} do | |||
user = insert(:user) | |||
{:ok, public_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "public"}) | |||
{:ok, direct_activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"}) | |||
{:ok, unlisted_activity} = | |||
CommonAPI.post(user, %{"status" => ".", "visibility" => "unlisted"}) | |||
{:ok, private_activity} = | |||
CommonAPI.post(user, %{"status" => ".", "visibility" => "private"}) | |||
conn = | |||
conn | |||
|> assign(:user, user) | |||
|> get("/api/v1/timelines/home", %{"exclude_visibilities" => ["direct"]}) | |||
assert status_ids = json_response(conn, :ok) |> Enum.map(& &1["id"]) | |||
assert public_activity.id in status_ids | |||
assert unlisted_activity.id in status_ids | |||
assert private_activity.id in status_ids | |||
refute direct_activity.id in status_ids | |||
end | |||
end | |||
describe "public" do | |||