Merge remote-tracking branch 'remotes/upstream/develop' into 1304-user-info-deprecation

# Conflicts:
#	lib/pleroma/notification.ex
This commit is contained in:
Ivan Tashkinov 2019-10-23 17:22:42 +03:00
commit 8cc809e44e
18 changed files with 552 additions and 37 deletions

View File

@ -51,6 +51,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Admin API: `POST/DELETE /api/pleroma/admin/users/:nickname/permission_group/:permission_group` are deprecated in favor of: `POST/DELETE /api/pleroma/admin/users/permission_group/:permission_group` (both accept `nicknames` array), `DELETE /api/pleroma/admin/users` (`nickname` query param or `nickname` sent in JSON body) is deprecated in favor of: `DELETE /api/pleroma/admin/users` (`nicknames` query array param or `nicknames` sent in JSON body).
- Admin API: Add `GET /api/pleroma/admin/relay` endpoint - lists all followed relays
- Pleroma API: `POST /api/v1/pleroma/conversations/read` to mark all conversations as read
- Mastodon API: Add `/api/v1/markers` for managing timeline read markers
### Changed
- **Breaking:** Elixir >=1.8 is now required (was >= 1.7)

74
lib/pleroma/marker.ex Normal file
View File

@ -0,0 +1,74 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Marker do
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
alias Ecto.Multi
alias Pleroma.Repo
alias Pleroma.User
@timelines ["notifications"]
schema "markers" do
field(:last_read_id, :string, default: "")
field(:timeline, :string, default: "")
field(:lock_version, :integer, default: 0)
belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
timestamps()
end
def get_markers(user, timelines \\ []) do
Repo.all(get_query(user, timelines))
end
def upsert(%User{} = user, attrs) do
attrs
|> Map.take(@timelines)
|> Enum.reduce(Multi.new(), fn {timeline, timeline_attrs}, multi ->
marker =
user
|> get_marker(timeline)
|> changeset(timeline_attrs)
Multi.insert(multi, timeline, marker,
returning: true,
on_conflict: {:replace, [:last_read_id]},
conflict_target: [:user_id, :timeline]
)
end)
|> Repo.transaction()
end
defp get_marker(user, timeline) do
case Repo.find_resource(get_query(user, timeline)) do
{:ok, marker} -> %__MODULE__{marker | user: user}
_ -> %__MODULE__{timeline: timeline, user_id: user.id}
end
end
@doc false
defp changeset(marker, attrs) do
marker
|> cast(attrs, [:last_read_id])
|> validate_required([:user_id, :timeline, :last_read_id])
|> validate_inclusion(:timeline, @timelines)
end
defp by_timeline(query, timeline) do
from(m in query, where: m.timeline in ^List.wrap(timeline))
end
defp by_user_id(query, id), do: from(m in query, where: m.user_id == ^id)
defp get_query(user, timelines) do
__MODULE__
|> by_user_id(user.id)
|> by_timeline(timelines)
end
end

View File

@ -55,9 +55,19 @@ def for_user_query(user, opts \\ []) do
)
|> preload([n, a, o], activity: {a, object: o})
|> exclude_muted(user, opts)
|> exclude_blocked(user)
|> exclude_visibility(opts)
end
defp exclude_blocked(query, user) do
query
|> where([n, a], a.actor not in ^user.info.blocks)
|> where(
[n, a],
fragment("substring(? from '.*://([^/]*)')", a.actor) not in ^user.info.domain_blocks
)
end
defp exclude_muted(query, _, %{with_muted: true}) do
query
end
@ -65,11 +75,6 @@ defp exclude_muted(query, _, %{with_muted: true}) do
defp exclude_muted(query, user, _opts) do
query
|> where([n, a], a.actor not in ^user.muted_notifications)
|> where([n, a], a.actor not in ^user.blocks)
|> where(
[n, a],
fragment("substring(? from '.*://([^/]*)')", a.actor) not in ^user.domain_blocks
)
|> join(:left, [n, a], tm in Pleroma.ThreadMute,
on: tm.user_id == ^user.id and tm.context == fragment("?->>'context'", a.data)
)

View File

@ -49,26 +49,28 @@ def determine_explicit_mentions(%{"tag" => tag} = object) when is_map(tag) do
def determine_explicit_mentions(_), do: []
@spec recipient_in_collection(any(), any()) :: boolean()
defp recipient_in_collection(ap_id, coll) when is_binary(coll), do: ap_id == coll
defp recipient_in_collection(ap_id, coll) when is_list(coll), do: ap_id in coll
defp recipient_in_collection(_, _), do: false
@spec label_in_collection?(any(), any()) :: boolean()
defp label_in_collection?(ap_id, coll) when is_binary(coll), do: ap_id == coll
defp label_in_collection?(ap_id, coll) when is_list(coll), do: ap_id in coll
defp label_in_collection?(_, _), do: false
@spec label_in_message?(String.t(), map()) :: boolean()
def label_in_message?(label, params),
do:
[params["to"], params["cc"], params["bto"], params["bcc"]]
|> Enum.any?(&label_in_collection?(label, &1))
@spec unaddressed_message?(map()) :: boolean()
def unaddressed_message?(params),
do:
[params["to"], params["cc"], params["bto"], params["bcc"]]
|> Enum.all?(&is_nil(&1))
@spec recipient_in_message(User.t(), User.t(), map()) :: boolean()
def recipient_in_message(%User{ap_id: ap_id} = recipient, %User{} = actor, params) do
addresses = [params["to"], params["cc"], params["bto"], params["bcc"]]
cond do
Enum.any?(addresses, &recipient_in_collection(ap_id, &1)) -> true
# if the message is unaddressed at all, then assume it is directly addressed
# to the recipient
Enum.all?(addresses, &is_nil(&1)) -> true
# if the message is sent from somebody the user is following, then assume it
# is addressed to the recipient
User.following?(recipient, actor) -> true
true -> false
end
end
def recipient_in_message(%User{ap_id: ap_id} = recipient, %User{} = actor, params),
do:
label_in_message?(ap_id, params) || unaddressed_message?(params) ||
User.following?(recipient, actor)
defp extract_list(target) when is_binary(target), do: [target]
defp extract_list(lst) when is_list(lst), do: lst
@ -76,8 +78,8 @@ defp extract_list(_), do: []
def maybe_splice_recipient(ap_id, params) do
need_splice? =
!recipient_in_collection(ap_id, params["to"]) &&
!recipient_in_collection(ap_id, params["cc"])
!label_in_collection?(ap_id, params["to"]) &&
!label_in_collection?(ap_id, params["cc"])
if need_splice? do
cc_list = extract_list(params["cc"])

View File

@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.Visibility do
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web.ActivityPub.Utils
require Pleroma.Constants
@ -15,7 +16,7 @@ def is_public?(%Object{data: %{"type" => "Tombstone"}}), do: false
def is_public?(%Object{data: data}), do: is_public?(data)
def is_public?(%Activity{data: data}), do: is_public?(data)
def is_public?(%{"directMessage" => true}), do: false
def is_public?(data), do: Pleroma.Constants.as_public() in (data["to"] ++ (data["cc"] || []))
def is_public?(data), do: Utils.label_in_message?(Pleroma.Constants.as_public(), data)
def is_private?(activity) do
with false <- is_public?(activity),

View File

@ -0,0 +1,32 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.MarkerController do
use Pleroma.Web, :controller
alias Pleroma.Plugs.OAuthScopesPlug
plug(
OAuthScopesPlug,
%{scopes: ["read:statuses"]}
when action == :index
)
plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action == :upsert)
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
# GET /api/v1/markers
def index(%{assigns: %{user: user}} = conn, params) do
markers = Pleroma.Marker.get_markers(user, params["timeline"])
render(conn, "markers.json", %{markers: markers})
end
# POST /api/v1/markers
def upsert(%{assigns: %{user: user}} = conn, params) do
with {:ok, result} <- Pleroma.Marker.upsert(user, params),
markers <- Map.values(result) do
render(conn, "markers.json", %{markers: markers})
end
end
end

View File

@ -0,0 +1,17 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.MarkerView do
use Pleroma.Web, :view
def render("markers.json", %{markers: markers}) do
Enum.reduce(markers, %{}, fn m, acc ->
Map.put_new(acc, m.timeline, %{
last_read_id: m.last_read_id,
version: m.lock_version,
updated_at: NaiveDateTime.to_iso8601(m.updated_at)
})
end)
end
end

View File

@ -125,6 +125,10 @@ def format_body(
end
end
def format_title(%{activity: %{data: %{"directMessage" => true}}}) do
"New Direct Message"
end
def format_title(%{activity: %{data: %{"type" => type}}}) do
case type do
"Create" -> "New Mention"

View File

@ -405,6 +405,9 @@ defmodule Pleroma.Web.Router do
get("/push/subscription", SubscriptionController, :get)
put("/push/subscription", SubscriptionController, :update)
delete("/push/subscription", SubscriptionController, :delete)
get("/markers", MarkerController, :index)
post("/markers", MarkerController, :upsert)
end
scope "/api/web", Pleroma.Web do

View File

@ -0,0 +1,15 @@
defmodule Pleroma.Repo.Migrations.CreateMarkers do
use Ecto.Migration
def change do
create_if_not_exists table(:markers) do
add(:user_id, references(:users, type: :uuid, on_delete: :delete_all))
add(:timeline, :string, default: "", null: false)
add(:last_read_id, :string, default: "", null: false)
add(:lock_version, :integer, default: 0, null: false)
timestamps()
end
create_if_not_exists(unique_index(:markers, [:user_id, :timeline]))
end
end

51
test/marker_test.exs Normal file
View File

@ -0,0 +1,51 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.MarkerTest do
use Pleroma.DataCase
alias Pleroma.Marker
import Pleroma.Factory
describe "get_markers/2" do
test "returns user markers" do
user = insert(:user)
marker = insert(:marker, user: user)
insert(:marker, timeline: "home", user: user)
assert Marker.get_markers(user, ["notifications"]) == [refresh_record(marker)]
end
end
describe "upsert/2" do
test "creates a marker" do
user = insert(:user)
{:ok, %{"notifications" => %Marker{} = marker}} =
Marker.upsert(
user,
%{"notifications" => %{"last_read_id" => "34"}}
)
assert marker.timeline == "notifications"
assert marker.last_read_id == "34"
assert marker.lock_version == 0
end
test "updates exist marker" do
user = insert(:user)
marker = insert(:marker, user: user, last_read_id: "8909")
{:ok, %{"notifications" => %Marker{}}} =
Marker.upsert(
user,
%{"notifications" => %{"last_read_id" => "9909"}}
)
marker = refresh_record(marker)
assert marker.timeline == "notifications"
assert marker.last_read_id == "9909"
assert marker.lock_version == 0
end
end
end

View File

@ -683,7 +683,7 @@ test "it doesn't return notifications for muted thread" do
assert Notification.for_user(user) == []
end
test "it returns notifications for muted user with notifications and with_muted parameter" do
test "it returns notifications from a muted user when with_muted is set" do
user = insert(:user)
muted = insert(:user)
{:ok, user} = User.mute(user, muted)
@ -693,27 +693,27 @@ test "it returns notifications for muted user with notifications and with_muted
assert length(Notification.for_user(user, %{with_muted: true})) == 1
end
test "it returns notifications for blocked user and with_muted parameter" do
test "it doesn't return notifications from a blocked user when with_muted is set" do
user = insert(:user)
blocked = insert(:user)
{:ok, user} = User.block(user, blocked)
{:ok, _activity} = CommonAPI.post(blocked, %{"status" => "hey @#{user.nickname}"})
assert length(Notification.for_user(user, %{with_muted: true})) == 1
assert length(Notification.for_user(user, %{with_muted: true})) == 0
end
test "it returns notificatitons for blocked domain and with_muted parameter" do
test "it doesn't return notifications from a domain-blocked user when with_muted is set" do
user = insert(:user)
blocked = insert(:user, ap_id: "http://some-domain.com")
{:ok, user} = User.block_domain(user, "some-domain.com")
{:ok, _activity} = CommonAPI.post(blocked, %{"status" => "hey @#{user.nickname}"})
assert length(Notification.for_user(user, %{with_muted: true})) == 1
assert length(Notification.for_user(user, %{with_muted: true})) == 0
end
test "it returns notifications for muted thread with_muted parameter" do
test "it returns notifications from muted threads when with_muted is set" do
user = insert(:user)
another_user = insert(:user)

View File

@ -377,4 +377,13 @@ def config_factory do
)
}
end
def marker_factory do
%Pleroma.Marker{
user: build(:user),
timeline: "notifications",
lock_version: 0,
last_read_id: "1"
}
end
end

View File

@ -354,6 +354,87 @@ test "it inserts an incoming activity into the database", %{conn: conn, data: da
assert Activity.get_by_ap_id(data["id"])
end
test "it accepts messages with to as string instead of array", %{conn: conn, data: data} do
user = insert(:user)
data =
Map.put(data, "to", user.ap_id)
|> Map.delete("cc")
conn =
conn
|> assign(:valid_signature, true)
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/inbox", data)
assert "ok" == json_response(conn, 200)
ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
assert Activity.get_by_ap_id(data["id"])
end
test "it accepts messages with cc as string instead of array", %{conn: conn, data: data} do
user = insert(:user)
data =
Map.put(data, "cc", user.ap_id)
|> Map.delete("to")
conn =
conn
|> assign(:valid_signature, true)
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/inbox", data)
assert "ok" == json_response(conn, 200)
ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
%Activity{} = activity = Activity.get_by_ap_id(data["id"])
assert user.ap_id in activity.recipients
end
test "it accepts messages with bcc as string instead of array", %{conn: conn, data: data} do
user = insert(:user)
data =
Map.put(data, "bcc", user.ap_id)
|> Map.delete("to")
|> Map.delete("cc")
conn =
conn
|> assign(:valid_signature, true)
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/inbox", data)
assert "ok" == json_response(conn, 200)
ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
assert Activity.get_by_ap_id(data["id"])
end
test "it accepts announces with to as string instead of array", %{conn: conn} do
user = insert(:user)
data = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"actor" => "http://mastodon.example.org/users/admin",
"id" => "http://mastodon.example.org/users/admin/statuses/19512778738411822/activity",
"object" => "https://mastodon.social/users/emelie/statuses/101849165031453009",
"to" => "https://www.w3.org/ns/activitystreams#Public",
"cc" => [user.ap_id],
"type" => "Announce"
}
conn =
conn
|> assign(:valid_signature, true)
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/inbox", data)
assert "ok" == json_response(conn, 200)
ObanHelpers.perform(all_enqueued(worker: ReceiverWorker))
%Activity{} = activity = Activity.get_by_ap_id(data["id"])
assert "https://www.w3.org/ns/activitystreams#Public" in activity.recipients
end
test "it accepts messages from actors that are followed by the user", %{
conn: conn,
data: data

View File

@ -1106,6 +1106,50 @@ test "it accepts Flag activities" do
assert activity.data["actor"] == other_user.ap_id
assert activity.data["cc"] == [user.ap_id]
end
test "it correctly processes messages with non-array to field" do
user = insert(:user)
message = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"to" => "https://www.w3.org/ns/activitystreams#Public",
"type" => "Create",
"object" => %{
"content" => "blah blah blah",
"type" => "Note",
"attributedTo" => user.ap_id,
"inReplyTo" => nil
},
"actor" => user.ap_id
}
assert {:ok, activity} = Transmogrifier.handle_incoming(message)
assert ["https://www.w3.org/ns/activitystreams#Public"] == activity.data["to"]
end
test "it correctly processes messages with non-array cc field" do
user = insert(:user)
message = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"to" => user.follower_address,
"cc" => "https://www.w3.org/ns/activitystreams#Public",
"type" => "Create",
"object" => %{
"content" => "blah blah blah",
"type" => "Note",
"attributedTo" => user.ap_id,
"inReplyTo" => nil
},
"actor" => user.ap_id
}
assert {:ok, activity} = Transmogrifier.handle_incoming(message)
assert ["https://www.w3.org/ns/activitystreams#Public"] == activity.data["cc"]
assert [user.follower_address] == activity.data["to"]
end
end
describe "prepare outgoing" do

View File

@ -0,0 +1,124 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
use Pleroma.Web.ConnCase
import Pleroma.Factory
describe "GET /api/v1/markers" do
test "gets markers with correct scopes", %{conn: conn} do
user = insert(:user)
token = insert(:oauth_token, user: user, scopes: ["read:statuses"])
{:ok, %{"notifications" => marker}} =
Pleroma.Marker.upsert(
user,
%{"notifications" => %{"last_read_id" => "69420"}}
)
response =
conn
|> assign(:user, user)
|> assign(:token, token)
|> get("/api/v1/markers", %{timeline: ["notifications"]})
|> json_response(200)
assert response == %{
"notifications" => %{
"last_read_id" => "69420",
"updated_at" => NaiveDateTime.to_iso8601(marker.updated_at),
"version" => 0
}
}
end
test "gets markers with missed scopes", %{conn: conn} do
user = insert(:user)
token = insert(:oauth_token, user: user, scopes: [])
Pleroma.Marker.upsert(user, %{"notifications" => %{"last_read_id" => "69420"}})
response =
conn
|> assign(:user, user)
|> assign(:token, token)
|> get("/api/v1/markers", %{timeline: ["notifications"]})
|> json_response(403)
assert response == %{"error" => "Insufficient permissions: read:statuses."}
end
end
describe "POST /api/v1/markers" do
test "creates a marker with correct scopes", %{conn: conn} do
user = insert(:user)
token = insert(:oauth_token, user: user, scopes: ["write:statuses"])
response =
conn
|> assign(:user, user)
|> assign(:token, token)
|> post("/api/v1/markers", %{
home: %{last_read_id: "777"},
notifications: %{"last_read_id" => "69420"}
})
|> json_response(200)
assert %{
"notifications" => %{
"last_read_id" => "69420",
"updated_at" => _,
"version" => 0
}
} = response
end
test "updates exist marker", %{conn: conn} do
user = insert(:user)
token = insert(:oauth_token, user: user, scopes: ["write:statuses"])
{:ok, %{"notifications" => marker}} =
Pleroma.Marker.upsert(
user,
%{"notifications" => %{"last_read_id" => "69477"}}
)
response =
conn
|> assign(:user, user)
|> assign(:token, token)
|> post("/api/v1/markers", %{
home: %{last_read_id: "777"},
notifications: %{"last_read_id" => "69888"}
})
|> json_response(200)
assert response == %{
"notifications" => %{
"last_read_id" => "69888",
"updated_at" => NaiveDateTime.to_iso8601(marker.updated_at),
"version" => 0
}
}
end
test "creates a marker with missed scopes", %{conn: conn} do
user = insert(:user)
token = insert(:oauth_token, user: user, scopes: [])
response =
conn
|> assign(:user, user)
|> assign(:token, token)
|> post("/api/v1/markers", %{
home: %{last_read_id: "777"},
notifications: %{"last_read_id" => "69420"}
})
|> json_response(403)
assert response == %{"error" => "Insufficient permissions: write:statuses."}
end
end
end

View File

@ -0,0 +1,27 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.MarkerViewTest do
use Pleroma.DataCase
alias Pleroma.Web.MastodonAPI.MarkerView
import Pleroma.Factory
test "returns markers" do
marker1 = insert(:marker, timeline: "notifications", last_read_id: "17")
marker2 = insert(:marker, timeline: "home", last_read_id: "42")
assert MarkerView.render("markers.json", %{markers: [marker1, marker2]}) == %{
"home" => %{
last_read_id: "42",
updated_at: NaiveDateTime.to_iso8601(marker2.updated_at),
version: 0
},
"notifications" => %{
last_read_id: "17",
updated_at: NaiveDateTime.to_iso8601(marker1.updated_at),
version: 0
}
}
end
end

View File

@ -84,7 +84,7 @@ test "fail message sending" do
) == :error
end
test "delete subsciption if restult send message between 400..500" do
test "delete subscription if result send message between 400..500" do
subscription = insert(:push_subscription)
assert Impl.push_message(
@ -97,7 +97,7 @@ test "delete subsciption if restult send message between 400..500" do
refute Pleroma.Repo.get(Subscription, subscription.id)
end
test "renders body for create activity" do
test "renders title and body for create activity" do
user = insert(:user, nickname: "Bob")
{:ok, activity} =
@ -116,18 +116,24 @@ test "renders body for create activity" do
object
) ==
"@Bob: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce sagittis fini..."
assert Impl.format_title(%{activity: activity}) ==
"New Mention"
end
test "renders body for follow activity" do
test "renders title and body for follow activity" do
user = insert(:user, nickname: "Bob")
other_user = insert(:user)
{:ok, _, _, activity} = CommonAPI.follow(user, other_user)
object = Object.normalize(activity)
assert Impl.format_body(%{activity: activity}, user, object) == "@Bob has followed you"
assert Impl.format_title(%{activity: activity}) ==
"New Follower"
end
test "renders body for announce activity" do
test "renders title and body for announce activity" do
user = insert(:user)
{:ok, activity} =
@ -141,9 +147,12 @@ test "renders body for announce activity" do
assert Impl.format_body(%{activity: announce_activity}, user, object) ==
"@#{user.nickname} repeated: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce sagittis fini..."
assert Impl.format_title(%{activity: announce_activity}) ==
"New Repeat"
end
test "renders body for like activity" do
test "renders title and body for like activity" do
user = insert(:user, nickname: "Bob")
{:ok, activity} =
@ -156,5 +165,21 @@ test "renders body for like activity" do
object = Object.normalize(activity)
assert Impl.format_body(%{activity: activity}, user, object) == "@Bob has favorited your post"
assert Impl.format_title(%{activity: activity}) ==
"New Favorite"
end
test "renders title for create activity with direct visibility" do
user = insert(:user, nickname: "Bob")
{:ok, activity} =
CommonAPI.post(user, %{
"visibility" => "direct",
"status" => "This is just between you and me, pal"
})
assert Impl.format_title(%{activity: activity}) ==
"New Direct Message"
end
end