From ddb8a5ef73722478da86f7309e7ef8ee7ab983b1 Mon Sep 17 00:00:00 2001 From: Floatingghost Date: Tue, 16 Apr 2024 13:55:03 +0100 Subject: [PATCH 1/4] yeet AP C2S support literally nothing uses C2S AP, and it's another route into core systems which requires analysis and maintenance. A second API is just extra surface for potentially bad things so let's take it out back and obliterate it --- .../activity_pub/activity_pub_controller.ex | 227 +------- lib/pleroma/web/router.ex | 15 +- .../activity_pub_controller_test.exs | 510 ------------------ 3 files changed, 8 insertions(+), 744 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 793b08f3d..331eb66f9 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -9,16 +9,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do alias Pleroma.Delivery alias Pleroma.Object alias Pleroma.User - alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.InternalFetchActor alias Pleroma.Web.ActivityPub.ObjectView - alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Relay - alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.UserView alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility - alias Pleroma.Web.ControllerHelper alias Pleroma.Web.Endpoint alias Pleroma.Web.Federator alias Pleroma.Web.Plugs.EnsureAuthenticatedPlug @@ -37,14 +33,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do [unless_func: &FederatingPlug.federating?/1] when action not in @federating_only_actions ) - # Note: :following and :followers must be served even without authentication (as via :api) - plug( - EnsureAuthenticatedPlug - when action in [:read_inbox, :update_outbox, :whoami, :upload_media] - ) - - plug(Majic.Plug, [pool: Pleroma.MajicPool] when action in [:upload_media]) - plug( Pleroma.Web.Plugs.Cache, [query_params: false, tracking_fun: &__MODULE__.track_object_fetch/2] @@ -234,43 +222,9 @@ def followers(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname}) d end end - def outbox( - %{assigns: %{user: for_user}} = conn, - %{"nickname" => nickname, "page" => page?} = params - ) - when page? in [true, "true"] do - with %User{} = user <- User.get_cached_by_nickname(nickname) do - # "include_poll_votes" is a hack because postgres generates inefficient - # queries when filtering by 'Answer', poll votes will be hidden by the - # visibility filter in this case anyway - params = - params - |> Map.drop(["nickname", "page"]) - |> Map.put("include_poll_votes", true) - |> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end) - - activities = ActivityPub.fetch_user_activities(user, for_user, params) - - conn - |> put_resp_content_type("application/activity+json") - |> put_view(UserView) - |> render("activity_collection_page.json", %{ - activities: activities, - pagination: ControllerHelper.get_pagination_fields(conn, activities), - iri: "#{user.ap_id}/outbox" - }) - end - end - - def outbox(conn, %{"nickname" => nickname}) do - with %User{} = user <- User.get_cached_by_nickname(nickname) do - conn - |> put_resp_content_type("application/activity+json") - |> put_view(UserView) - |> render("activity_collection.json", %{iri: "#{user.ap_id}/outbox"}) - end - end - + @doc """ + POST /users/:nickname/inbox + """ def inbox(%{assigns: %{valid_signature: true}} = conn, %{"nickname" => nickname} = params) do with %User{} = recipient <- User.get_cached_by_nickname(nickname), {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(params["actor"]), @@ -317,163 +271,6 @@ def internal_fetch(conn, _params) do |> represent_service_actor(conn) end - @doc "Returns the authenticated user's ActivityPub User object or a 404 Not Found if non-authenticated" - def whoami(%{assigns: %{user: %User{} = user}} = conn, _params) do - conn - |> put_resp_content_type("application/activity+json") - |> put_view(UserView) - |> render("user.json", %{user: user}) - end - - def read_inbox( - %{assigns: %{user: %User{nickname: nickname} = user}} = conn, - %{"nickname" => nickname, "page" => page?} = params - ) - when page? in [true, "true"] do - params = - params - |> Map.drop(["nickname", "page"]) - |> Map.put("blocking_user", user) - |> Map.put("user", user) - |> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end) - - activities = - [user.ap_id | User.following(user)] - |> ActivityPub.fetch_activities(params) - |> Enum.reverse() - - conn - |> put_resp_content_type("application/activity+json") - |> put_view(UserView) - |> render("activity_collection_page.json", %{ - activities: activities, - pagination: ControllerHelper.get_pagination_fields(conn, activities), - iri: "#{user.ap_id}/inbox" - }) - end - - def read_inbox(%{assigns: %{user: %User{nickname: nickname} = user}} = conn, %{ - "nickname" => nickname - }) do - conn - |> put_resp_content_type("application/activity+json") - |> put_view(UserView) - |> render("activity_collection.json", %{iri: "#{user.ap_id}/inbox"}) - end - - def read_inbox(%{assigns: %{user: %User{nickname: as_nickname}}} = conn, %{ - "nickname" => nickname - }) do - err = - dgettext("errors", "can't read inbox of %{nickname} as %{as_nickname}", - nickname: nickname, - as_nickname: as_nickname - ) - - conn - |> put_status(:forbidden) - |> json(err) - end - - defp fix_user_message(%User{ap_id: actor}, %{"type" => "Create", "object" => object} = activity) - when is_map(object) do - length = - [object["content"], object["summary"], object["name"]] - |> Enum.filter(&is_binary(&1)) - |> Enum.join("") - |> String.length() - - limit = Pleroma.Config.get([:instance, :limit]) - - if length < limit do - object = - object - |> Transmogrifier.strip_internal_fields() - |> Map.put("attributedTo", actor) - |> Map.put("actor", actor) - |> Map.put("id", Utils.generate_object_id()) - - {:ok, Map.put(activity, "object", object)} - else - {:error, - dgettext( - "errors", - "Character limit (%{limit} characters) exceeded, contains %{length} characters", - limit: limit, - length: length - )} - end - end - - defp fix_user_message( - %User{ap_id: actor} = user, - %{"type" => "Delete", "object" => object} = activity - ) do - with {_, %Object{data: object_data}} <- {:normalize, Object.normalize(object, fetch: false)}, - {_, true} <- {:permission, user.is_moderator || actor == object_data["actor"]} do - {:ok, activity} - else - {:normalize, _} -> - {:error, "No such object found"} - - {:permission, _} -> - {:forbidden, "You can't delete this object"} - end - end - - defp fix_user_message(%User{}, activity) do - {:ok, activity} - end - - def update_outbox( - %{assigns: %{user: %User{nickname: nickname, ap_id: actor} = user}} = conn, - %{"nickname" => nickname} = params - ) do - params = - params - |> Map.drop(["nickname"]) - |> Map.put("id", Utils.generate_activity_id()) - |> Map.put("actor", actor) - - with {:ok, params} <- fix_user_message(user, params), - {:ok, activity, _} <- Pipeline.common_pipeline(params, local: true), - %Activity{data: activity_data} <- Activity.normalize(activity) do - conn - |> put_status(:created) - |> put_resp_header("location", activity_data["id"]) - |> json(activity_data) - else - {:forbidden, message} -> - conn - |> put_status(:forbidden) - |> json(message) - - {:error, message} -> - conn - |> put_status(:bad_request) - |> json(message) - - e -> - Logger.warning(fn -> "AP C2S: #{inspect(e)}" end) - - conn - |> put_status(:bad_request) - |> json("Bad Request") - end - end - - def update_outbox(%{assigns: %{user: %User{} = user}} = conn, %{"nickname" => nickname}) do - err = - dgettext("errors", "can't update outbox of %{nickname} as %{as_nickname}", - nickname: nickname, - as_nickname: user.nickname - ) - - conn - |> put_status(:forbidden) - |> json(err) - end - defp errors(conn, {:error, :not_found}) do conn |> put_status(:not_found) @@ -495,21 +292,9 @@ defp set_requester_reachable(%Plug.Conn{} = conn, _) do conn end - def upload_media(%{assigns: %{user: %User{} = user}} = conn, %{"file" => file} = data) do - with {:ok, object} <- - ActivityPub.upload( - file, - actor: User.ap_id(user), - description: Map.get(data, "description") - ) do - Logger.debug(inspect(object)) - - conn - |> put_status(:created) - |> json(object.data) - end - end - + @doc """ + GET /users/:nickname/collections/featured + """ def pinned(conn, %{"nickname" => nickname}) do with %User{} = user <- User.get_cached_by_nickname(nickname) do conn diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index ca4995281..af5701398 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -797,26 +797,15 @@ defmodule Pleroma.Web.Router do plug(:after_auth) end - scope "/", Pleroma.Web.ActivityPub do - pipe_through([:activitypub_client]) - - get("/api/ap/whoami", ActivityPubController, :whoami) - get("/users/:nickname/inbox", ActivityPubController, :read_inbox) - - get("/users/:nickname/outbox", ActivityPubController, :outbox) - post("/users/:nickname/outbox", ActivityPubController, :update_outbox) - post("/api/ap/upload_media", ActivityPubController, :upload_media) - - get("/users/:nickname/collections/featured", ActivityPubController, :pinned) - end scope "/", Pleroma.Web.ActivityPub do # Note: html format is supported only if static FE is enabled pipe_through([:accepts_html_json, :static_fe, :activitypub_client]) - # The following two are S2S as well, see `ActivityPub.fetch_follow_information_for_user/1`: + # The following two are used in both staticFE and AP 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 diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index dcb5f143c..faae04af6 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -1028,33 +1028,6 @@ test "it accepts messages from actors that are followed by the user", %{ assert Activity.get_by_ap_id(data["id"]) end - test "it rejects reads from other users", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) - - conn = - conn - |> assign(:user, other_user) - |> put_req_header("accept", "application/activity+json") - |> get("/users/#{user.nickname}/inbox") - - assert json_response(conn, 403) - end - - test "it returns a note activity in a collection", %{conn: conn} do - note_activity = insert(:direct_note_activity) - note_object = Object.normalize(note_activity, fetch: false) - user = User.get_cached_by_ap_id(hd(note_activity.data["to"])) - - conn = - conn - |> assign(:user, user) - |> put_req_header("accept", "application/activity+json") - |> get("/users/#{user.nickname}/inbox?page=true") - - assert response(conn, 200) =~ note_object.data["content"] - end - test "it clears `unreachable` federation status of the sender", %{conn: conn, data: data} do user = insert(:user) data = Map.put(data, "bcc", [user.ap_id]) @@ -1123,20 +1096,6 @@ test "it removes all follower collections but actor's", %{conn: conn} do refute recipient.follower_address in activity.data["to"] end - test "it requires authentication", %{conn: conn} do - user = insert(:user) - conn = put_req_header(conn, "accept", "application/activity+json") - - ret_conn = get(conn, "/users/#{user.nickname}/inbox") - assert json_response(ret_conn, 403) - - ret_conn = - conn - |> assign(:user, user) - |> get("/users/#{user.nickname}/inbox") - - assert json_response(ret_conn, 200) - end @tag capture_log: true test "forwarded report", %{conn: conn} do @@ -1276,386 +1235,6 @@ test "forwarded report from mastodon", %{conn: conn} do end end - describe "GET /users/:nickname/outbox" do - test "it paginates correctly", %{conn: conn} do - user = insert(:user) - conn = assign(conn, :user, user) - outbox_endpoint = user.ap_id <> "/outbox" - - _posts = - for i <- 0..25 do - {:ok, activity} = CommonAPI.post(user, %{status: "post #{i}"}) - activity - end - - result = - conn - |> put_req_header("accept", "application/activity+json") - |> get(outbox_endpoint <> "?page=true") - |> json_response(200) - - result_ids = Enum.map(result["orderedItems"], fn x -> x["id"] end) - assert length(result["orderedItems"]) == 20 - assert length(result_ids) == 20 - assert result["next"] - assert String.starts_with?(result["next"], outbox_endpoint) - - result_next = - conn - |> put_req_header("accept", "application/activity+json") - |> get(result["next"]) - |> json_response(200) - - result_next_ids = Enum.map(result_next["orderedItems"], fn x -> x["id"] end) - assert length(result_next["orderedItems"]) == 6 - assert length(result_next_ids) == 6 - refute Enum.find(result_next_ids, fn x -> x in result_ids end) - refute Enum.find(result_ids, fn x -> x in result_next_ids end) - assert String.starts_with?(result["id"], outbox_endpoint) - - result_next_again = - conn - |> put_req_header("accept", "application/activity+json") - |> get(result_next["id"]) - |> json_response(200) - - assert result_next == result_next_again - end - - test "it returns 200 even if there're no activities", %{conn: conn} do - user = insert(:user) - outbox_endpoint = user.ap_id <> "/outbox" - - conn = - conn - |> assign(:user, user) - |> put_req_header("accept", "application/activity+json") - |> get(outbox_endpoint) - - result = json_response(conn, 200) - assert outbox_endpoint == result["id"] - end - - test "it returns a local note activity when authenticated as local user", %{conn: conn} do - user = insert(:user) - reader = insert(:user) - {:ok, note_activity} = CommonAPI.post(user, %{status: "mew mew", visibility: "local"}) - ap_id = note_activity.data["id"] - - resp = - conn - |> assign(:user, reader) - |> put_req_header("accept", "application/activity+json") - |> get("/users/#{user.nickname}/outbox?page=true") - |> json_response(200) - - assert %{"orderedItems" => [%{"id" => ^ap_id}]} = resp - end - - test "it does not return a local note activity when unauthenticated", %{conn: conn} do - user = insert(:user) - {:ok, _note_activity} = CommonAPI.post(user, %{status: "mew mew", visibility: "local"}) - - resp = - conn - |> put_req_header("accept", "application/activity+json") - |> get("/users/#{user.nickname}/outbox?page=true") - |> json_response(200) - - assert %{"orderedItems" => []} = resp - end - - test "it returns a note activity in a collection", %{conn: conn} do - note_activity = insert(:note_activity) - note_object = Object.normalize(note_activity, fetch: false) - user = User.get_cached_by_ap_id(note_activity.data["actor"]) - - conn = - conn - |> assign(:user, user) - |> put_req_header("accept", "application/activity+json") - |> get("/users/#{user.nickname}/outbox?page=true") - - assert response(conn, 200) =~ note_object.data["content"] - end - - test "it returns an announce activity in a collection", %{conn: conn} do - announce_activity = insert(:announce_activity) - user = User.get_cached_by_ap_id(announce_activity.data["actor"]) - - conn = - conn - |> assign(:user, user) - |> put_req_header("accept", "application/activity+json") - |> get("/users/#{user.nickname}/outbox?page=true") - - assert response(conn, 200) =~ announce_activity.data["object"] - end - - test "It returns poll Answers when authenticated", %{conn: conn} do - poller = insert(:user) - voter = insert(:user) - - {:ok, activity} = - CommonAPI.post(poller, %{ - status: "suya...", - poll: %{options: ["suya", "suya.", "suya.."], expires_in: 10} - }) - - assert question = Object.normalize(activity, fetch: false) - - {:ok, [activity], _object} = CommonAPI.vote(voter, question, [1]) - - assert outbox_get = - conn - |> assign(:user, voter) - |> put_req_header("accept", "application/activity+json") - |> get(voter.ap_id <> "/outbox?page=true") - |> json_response(200) - - assert [answer_outbox] = outbox_get["orderedItems"] - assert answer_outbox["id"] == activity.data["id"] - end - end - - describe "POST /users/:nickname/outbox (C2S)" do - setup do: clear_config([:instance, :limit]) - - setup do - [ - activity: %{ - "@context" => "https://www.w3.org/ns/activitystreams", - "type" => "Create", - "object" => %{ - "type" => "Note", - "content" => "AP C2S test", - "to" => "https://www.w3.org/ns/activitystreams#Public", - "cc" => [] - } - } - ] - end - - test "it rejects posts from other users / unauthenticated users", %{ - conn: conn, - activity: activity - } do - user = insert(:user) - other_user = insert(:user) - conn = put_req_header(conn, "content-type", "application/activity+json") - - conn - |> post("/users/#{user.nickname}/outbox", activity) - |> json_response(403) - - conn - |> assign(:user, other_user) - |> post("/users/#{user.nickname}/outbox", activity) - |> json_response(403) - end - - test "it inserts an incoming create activity into the database", %{ - conn: conn, - activity: activity - } do - user = insert(:user) - - result = - conn - |> assign(:user, user) - |> put_req_header("content-type", "application/activity+json") - |> post("/users/#{user.nickname}/outbox", activity) - |> json_response(201) - - assert Activity.get_by_ap_id(result["id"]) - assert result["object"] - assert %Object{data: object} = Object.normalize(result["object"], fetch: false) - assert object["content"] == activity["object"]["content"] - end - - test "it rejects anything beyond 'Note' creations", %{conn: conn, activity: activity} do - user = insert(:user) - - activity = - activity - |> put_in(["object", "type"], "Benis") - - _result = - conn - |> assign(:user, user) - |> put_req_header("content-type", "application/activity+json") - |> post("/users/#{user.nickname}/outbox", activity) - |> json_response(400) - end - - test "it inserts an incoming sensitive activity into the database", %{ - conn: conn, - activity: activity - } do - user = insert(:user) - conn = assign(conn, :user, user) - object = Map.put(activity["object"], "sensitive", true) - activity = Map.put(activity, "object", object) - - response = - conn - |> put_req_header("content-type", "application/activity+json") - |> post("/users/#{user.nickname}/outbox", activity) - |> json_response(201) - - assert Activity.get_by_ap_id(response["id"]) - assert response["object"] - assert %Object{data: response_object} = Object.normalize(response["object"], fetch: false) - assert response_object["sensitive"] == true - assert response_object["content"] == activity["object"]["content"] - - representation = - conn - |> put_req_header("accept", "application/activity+json") - |> get(response["id"]) - |> json_response(200) - - assert representation["object"]["sensitive"] == true - end - - test "it rejects an incoming activity with bogus type", %{conn: conn, activity: activity} do - user = insert(:user) - activity = Map.put(activity, "type", "BadType") - - conn = - conn - |> assign(:user, user) - |> put_req_header("content-type", "application/activity+json") - |> post("/users/#{user.nickname}/outbox", activity) - - assert json_response(conn, 400) - end - - test "it erects a tombstone when receiving a delete activity", %{conn: conn} do - note_activity = insert(:note_activity) - note_object = Object.normalize(note_activity, fetch: false) - user = User.get_cached_by_ap_id(note_activity.data["actor"]) - - data = %{ - "type" => "Delete", - "object" => %{ - "id" => note_object.data["id"] - } - } - - result = - conn - |> assign(:user, user) - |> put_req_header("content-type", "application/activity+json") - |> post("/users/#{user.nickname}/outbox", data) - |> json_response(201) - - assert Activity.get_by_ap_id(result["id"]) - - assert object = Object.get_by_ap_id(note_object.data["id"]) - assert object.data["type"] == "Tombstone" - end - - test "it rejects delete activity of object from other actor", %{conn: conn} do - note_activity = insert(:note_activity) - note_object = Object.normalize(note_activity, fetch: false) - user = insert(:user) - - data = %{ - type: "Delete", - object: %{ - id: note_object.data["id"] - } - } - - conn = - conn - |> assign(:user, user) - |> put_req_header("content-type", "application/activity+json") - |> post("/users/#{user.nickname}/outbox", data) - - assert json_response(conn, 403) - end - - test "it increases like count when receiving a like action", %{conn: conn} do - note_activity = insert(:note_activity) - note_object = Object.normalize(note_activity, fetch: false) - user = User.get_cached_by_ap_id(note_activity.data["actor"]) - - data = %{ - type: "Like", - object: %{ - id: note_object.data["id"] - } - } - - conn = - conn - |> assign(:user, user) - |> put_req_header("content-type", "application/activity+json") - |> post("/users/#{user.nickname}/outbox", data) - - result = json_response(conn, 201) - assert Activity.get_by_ap_id(result["id"]) - - assert object = Object.get_by_ap_id(note_object.data["id"]) - assert object.data["like_count"] == 1 - end - - test "it doesn't spreads faulty attributedTo or actor fields", %{ - conn: conn, - activity: activity - } do - reimu = insert(:user, nickname: "reimu") - cirno = insert(:user, nickname: "cirno") - - assert reimu.ap_id - assert cirno.ap_id - - activity = - activity - |> put_in(["object", "actor"], reimu.ap_id) - |> put_in(["object", "attributedTo"], reimu.ap_id) - |> put_in(["actor"], reimu.ap_id) - |> put_in(["attributedTo"], reimu.ap_id) - - _reimu_outbox = - conn - |> assign(:user, cirno) - |> put_req_header("content-type", "application/activity+json") - |> post("/users/#{reimu.nickname}/outbox", activity) - |> json_response(403) - - cirno_outbox = - conn - |> assign(:user, cirno) - |> put_req_header("content-type", "application/activity+json") - |> post("/users/#{cirno.nickname}/outbox", activity) - |> json_response(201) - - assert cirno_outbox["attributedTo"] == nil - assert cirno_outbox["actor"] == cirno.ap_id - - assert cirno_object = Object.normalize(cirno_outbox["object"], fetch: false) - assert cirno_object.data["actor"] == cirno.ap_id - assert cirno_object.data["attributedTo"] == cirno.ap_id - end - - test "Character limitation", %{conn: conn, activity: activity} do - clear_config([:instance, :limit], 5) - user = insert(:user) - - result = - conn - |> assign(:user, user) - |> put_req_header("content-type", "application/activity+json") - |> post("/users/#{user.nickname}/outbox", activity) - |> json_response(400) - - assert result == "Character limit (5 characters) exceeded, contains 11 characters" - end - end - describe "/relay/followers" do test "it returns relay followers", %{conn: conn} do relay_actor = Relay.get_actor() @@ -1977,95 +1556,6 @@ test "it tracks a signed activity fetch when the json is cached", %{conn: conn} end end - describe "Additional ActivityPub C2S endpoints" do - test "GET /api/ap/whoami", %{conn: conn} do - user = insert(:user) - - conn = - conn - |> assign(:user, user) - |> get("/api/ap/whoami") - - user = User.get_cached_by_id(user.id) - - assert UserView.render("user.json", %{user: user}) == json_response(conn, 200) - - conn - |> get("/api/ap/whoami") - |> json_response(403) - end - - setup do: clear_config([:media_proxy]) - setup do: clear_config([Pleroma.Upload]) - - test "POST /api/ap/upload_media", %{conn: conn} do - user = insert(:user) - - desc = "Description of the image" - - image = %Plug.Upload{ - content_type: "image/jpeg", - path: Path.absname("test/fixtures/image.jpg"), - filename: "an_image.jpg" - } - - object = - conn - |> assign(:user, user) - |> post("/api/ap/upload_media", %{"file" => image, "description" => desc}) - |> json_response(:created) - - assert object["name"] == desc - assert object["type"] == "Document" - assert object["actor"] == user.ap_id - assert [%{"href" => object_href, "mediaType" => object_mediatype}] = object["url"] - assert is_binary(object_href) - assert object_mediatype == "image/jpeg" - assert String.ends_with?(object_href, ".jpg") - - activity_request = %{ - "@context" => "https://www.w3.org/ns/activitystreams", - "type" => "Create", - "object" => %{ - "type" => "Note", - "content" => "AP C2S test, attachment", - "attachment" => [object], - "to" => "https://www.w3.org/ns/activitystreams#Public", - "cc" => [] - } - } - - activity_response = - conn - |> assign(:user, user) - |> post("/users/#{user.nickname}/outbox", activity_request) - |> json_response(:created) - - assert activity_response["id"] - assert activity_response["object"] - assert activity_response["actor"] == user.ap_id - - assert %Object{data: %{"attachment" => [attachment]}} = - Object.normalize(activity_response["object"], fetch: false) - - assert attachment["type"] == "Document" - assert attachment["name"] == desc - - assert [ - %{ - "href" => ^object_href, - "type" => "Link", - "mediaType" => ^object_mediatype - } - ] = attachment["url"] - - # Fails if unauthenticated - conn - |> post("/api/ap/upload_media", %{"file" => image, "description" => desc}) - |> json_response(403) - end - end - test "pinned collection", %{conn: conn} do clear_config([:instance, :max_pinned_statuses], 2) user = insert(:user) From 2c7e5b228756b3743e0f26d9dc91a2e1b985af84 Mon Sep 17 00:00:00 2001 From: Floatingghost Date: Tue, 16 Apr 2024 13:57:05 +0100 Subject: [PATCH 2/4] changelog entry --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e397a75d0..00c8a0a6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Fixed - Issue preventing fetching anything from IPv6-only instances -- Issue allowing post content to leak via opengraph tags despite :estrict\_unauthenticated being set +- Issue allowing post content to leak via opengraph tags despite :restrict\_unauthenticated being set + +## Removed +- ActivityPub Client-To-Server routing; `GET` routes are still there, but writes are out. ## 2024.03 From 1ed975636b6dedf2d153c3bebc1203286e12be17 Mon Sep 17 00:00:00 2001 From: Floatingghost Date: Fri, 19 Apr 2024 11:06:01 +0100 Subject: [PATCH 3/4] Keep READ endpoints, purge WRITE --- CHANGELOG.md | 5 +- .../activity_pub/activity_pub_controller.ex | 109 ++++++++++- lib/pleroma/web/router.ex | 11 +- .../activity_pub_controller_test.exs | 183 ++++++++++++++++++ 4 files changed, 294 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00c8a0a6c..e397a75d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Fixed - Issue preventing fetching anything from IPv6-only instances -- Issue allowing post content to leak via opengraph tags despite :restrict\_unauthenticated being set - -## Removed -- ActivityPub Client-To-Server routing; `GET` routes are still there, but writes are out. +- Issue allowing post content to leak via opengraph tags despite :estrict\_unauthenticated being set ## 2024.03 diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 331eb66f9..6642f7771 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -9,12 +9,14 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do alias Pleroma.Delivery alias Pleroma.Object alias Pleroma.User + alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.InternalFetchActor alias Pleroma.Web.ActivityPub.ObjectView alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.ActivityPub.UserView alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility + alias Pleroma.Web.ControllerHelper alias Pleroma.Web.Endpoint alias Pleroma.Web.Federator alias Pleroma.Web.Plugs.EnsureAuthenticatedPlug @@ -33,6 +35,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do [unless_func: &FederatingPlug.federating?/1] when action not in @federating_only_actions ) + # Note: :following and :followers must be served even without authentication (as via :api) + plug( + EnsureAuthenticatedPlug + when action in [:read_inbox] + ) + plug( Pleroma.Web.Plugs.Cache, [query_params: false, tracking_fun: &__MODULE__.track_object_fetch/2] @@ -148,7 +156,9 @@ def maybe_skip_cache(conn, user) do end end - # GET /relay/following + @doc """ + GET /relay/following + """ def relay_following(conn, _params) do with %{halted: false} = conn <- FederatingPlug.call(conn, []) do conn @@ -185,7 +195,9 @@ def following(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname}) d end end - # GET /relay/followers + @doc """ + GET /relay/followers + """ def relay_followers(conn, _params) do with %{halted: false} = conn <- FederatingPlug.call(conn, []) do conn @@ -222,9 +234,43 @@ def followers(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname}) d end end - @doc """ - POST /users/:nickname/inbox - """ + def outbox( + %{assigns: %{user: for_user}} = conn, + %{"nickname" => nickname, "page" => page?} = params + ) + when page? in [true, "true"] do + with %User{} = user <- User.get_cached_by_nickname(nickname) do + # "include_poll_votes" is a hack because postgres generates inefficient + # queries when filtering by 'Answer', poll votes will be hidden by the + # visibility filter in this case anyway + params = + params + |> Map.drop(["nickname", "page"]) + |> Map.put("include_poll_votes", true) + |> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end) + + activities = ActivityPub.fetch_user_activities(user, for_user, params) + + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("activity_collection_page.json", %{ + activities: activities, + pagination: ControllerHelper.get_pagination_fields(conn, activities), + iri: "#{user.ap_id}/outbox" + }) + end + end + + def outbox(conn, %{"nickname" => nickname}) do + with %User{} = user <- User.get_cached_by_nickname(nickname) do + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("activity_collection.json", %{iri: "#{user.ap_id}/outbox"}) + end + end + def inbox(%{assigns: %{valid_signature: true}} = conn, %{"nickname" => nickname} = params) do with %User{} = recipient <- User.get_cached_by_nickname(nickname), {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(params["actor"]), @@ -271,6 +317,56 @@ def internal_fetch(conn, _params) do |> represent_service_actor(conn) end + def read_inbox( + %{assigns: %{user: %User{nickname: nickname} = user}} = conn, + %{"nickname" => nickname, "page" => page?} = params + ) + when page? in [true, "true"] do + params = + params + |> Map.drop(["nickname", "page"]) + |> Map.put("blocking_user", user) + |> Map.put("user", user) + |> Map.new(fn {k, v} -> {String.to_existing_atom(k), v} end) + + activities = + [user.ap_id | User.following(user)] + |> ActivityPub.fetch_activities(params) + |> Enum.reverse() + + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("activity_collection_page.json", %{ + activities: activities, + pagination: ControllerHelper.get_pagination_fields(conn, activities), + iri: "#{user.ap_id}/inbox" + }) + end + + def read_inbox(%{assigns: %{user: %User{nickname: nickname} = user}} = conn, %{ + "nickname" => nickname + }) do + conn + |> put_resp_content_type("application/activity+json") + |> put_view(UserView) + |> render("activity_collection.json", %{iri: "#{user.ap_id}/inbox"}) + end + + def read_inbox(%{assigns: %{user: %User{nickname: as_nickname}}} = conn, %{ + "nickname" => nickname + }) do + err = + dgettext("errors", "can't read inbox of %{nickname} as %{as_nickname}", + nickname: nickname, + as_nickname: as_nickname + ) + + conn + |> put_status(:forbidden) + |> json(err) + end + defp errors(conn, {:error, :not_found}) do conn |> put_status(:not_found) @@ -292,9 +388,6 @@ defp set_requester_reachable(%Plug.Conn{} = conn, _) do conn end - @doc """ - GET /users/:nickname/collections/featured - """ def pinned(conn, %{"nickname" => nickname}) do with %User{} = user <- User.get_cached_by_nickname(nickname) do conn diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index af5701398..49ab3540b 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -797,15 +797,22 @@ defmodule Pleroma.Web.Router do plug(:after_auth) end + scope "/", Pleroma.Web.ActivityPub do + pipe_through([:activitypub_client]) + + get("/users/:nickname/inbox", ActivityPubController, :read_inbox) + + get("/users/:nickname/outbox", ActivityPubController, :outbox) + get("/users/:nickname/collections/featured", ActivityPubController, :pinned) + end scope "/", Pleroma.Web.ActivityPub do # Note: html format is supported only if static FE is enabled pipe_through([:accepts_html_json, :static_fe, :activitypub_client]) - # The following two are used in both staticFE and AP S2S as well, see `ActivityPub.fetch_follow_information_for_user/1`: + # 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 diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index faae04af6..b325bcb9a 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -1028,6 +1028,33 @@ test "it accepts messages from actors that are followed by the user", %{ assert Activity.get_by_ap_id(data["id"]) end + test "it rejects reads from other users", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + conn = + conn + |> assign(:user, other_user) + |> put_req_header("accept", "application/activity+json") + |> get("/users/#{user.nickname}/inbox") + + assert json_response(conn, 403) + end + + test "it returns a note activity in a collection", %{conn: conn} do + note_activity = insert(:direct_note_activity) + note_object = Object.normalize(note_activity, fetch: false) + user = User.get_cached_by_ap_id(hd(note_activity.data["to"])) + + conn = + conn + |> assign(:user, user) + |> put_req_header("accept", "application/activity+json") + |> get("/users/#{user.nickname}/inbox?page=true") + + assert response(conn, 200) =~ note_object.data["content"] + end + test "it clears `unreachable` federation status of the sender", %{conn: conn, data: data} do user = insert(:user) data = Map.put(data, "bcc", [user.ap_id]) @@ -1096,6 +1123,20 @@ test "it removes all follower collections but actor's", %{conn: conn} do refute recipient.follower_address in activity.data["to"] end + test "it requires authentication", %{conn: conn} do + user = insert(:user) + conn = put_req_header(conn, "accept", "application/activity+json") + + ret_conn = get(conn, "/users/#{user.nickname}/inbox") + assert json_response(ret_conn, 403) + + ret_conn = + conn + |> assign(:user, user) + |> get("/users/#{user.nickname}/inbox") + + assert json_response(ret_conn, 200) + end @tag capture_log: true test "forwarded report", %{conn: conn} do @@ -1235,6 +1276,148 @@ test "forwarded report from mastodon", %{conn: conn} do end end + describe "GET /users/:nickname/outbox" do + test "it paginates correctly", %{conn: conn} do + user = insert(:user) + conn = assign(conn, :user, user) + outbox_endpoint = user.ap_id <> "/outbox" + + _posts = + for i <- 0..25 do + {:ok, activity} = CommonAPI.post(user, %{status: "post #{i}"}) + activity + end + + result = + conn + |> put_req_header("accept", "application/activity+json") + |> get(outbox_endpoint <> "?page=true") + |> json_response(200) + + result_ids = Enum.map(result["orderedItems"], fn x -> x["id"] end) + assert length(result["orderedItems"]) == 20 + assert length(result_ids) == 20 + assert result["next"] + assert String.starts_with?(result["next"], outbox_endpoint) + + result_next = + conn + |> put_req_header("accept", "application/activity+json") + |> get(result["next"]) + |> json_response(200) + + result_next_ids = Enum.map(result_next["orderedItems"], fn x -> x["id"] end) + assert length(result_next["orderedItems"]) == 6 + assert length(result_next_ids) == 6 + refute Enum.find(result_next_ids, fn x -> x in result_ids end) + refute Enum.find(result_ids, fn x -> x in result_next_ids end) + assert String.starts_with?(result["id"], outbox_endpoint) + + result_next_again = + conn + |> put_req_header("accept", "application/activity+json") + |> get(result_next["id"]) + |> json_response(200) + + assert result_next == result_next_again + end + + test "it returns 200 even if there're no activities", %{conn: conn} do + user = insert(:user) + outbox_endpoint = user.ap_id <> "/outbox" + + conn = + conn + |> assign(:user, user) + |> put_req_header("accept", "application/activity+json") + |> get(outbox_endpoint) + + result = json_response(conn, 200) + assert outbox_endpoint == result["id"] + end + + test "it returns a local note activity when authenticated as local user", %{conn: conn} do + user = insert(:user) + reader = insert(:user) + {:ok, note_activity} = CommonAPI.post(user, %{status: "mew mew", visibility: "local"}) + ap_id = note_activity.data["id"] + + resp = + conn + |> assign(:user, reader) + |> put_req_header("accept", "application/activity+json") + |> get("/users/#{user.nickname}/outbox?page=true") + |> json_response(200) + + assert %{"orderedItems" => [%{"id" => ^ap_id}]} = resp + end + + test "it does not return a local note activity when unauthenticated", %{conn: conn} do + user = insert(:user) + {:ok, _note_activity} = CommonAPI.post(user, %{status: "mew mew", visibility: "local"}) + + resp = + conn + |> put_req_header("accept", "application/activity+json") + |> get("/users/#{user.nickname}/outbox?page=true") + |> json_response(200) + + assert %{"orderedItems" => []} = resp + end + + test "it returns a note activity in a collection", %{conn: conn} do + note_activity = insert(:note_activity) + note_object = Object.normalize(note_activity, fetch: false) + user = User.get_cached_by_ap_id(note_activity.data["actor"]) + + conn = + conn + |> assign(:user, user) + |> put_req_header("accept", "application/activity+json") + |> get("/users/#{user.nickname}/outbox?page=true") + + assert response(conn, 200) =~ note_object.data["content"] + end + + test "it returns an announce activity in a collection", %{conn: conn} do + announce_activity = insert(:announce_activity) + user = User.get_cached_by_ap_id(announce_activity.data["actor"]) + + conn = + conn + |> assign(:user, user) + |> put_req_header("accept", "application/activity+json") + |> get("/users/#{user.nickname}/outbox?page=true") + + assert response(conn, 200) =~ announce_activity.data["object"] + end + + test "It returns poll Answers when authenticated", %{conn: conn} do + poller = insert(:user) + voter = insert(:user) + + {:ok, activity} = + CommonAPI.post(poller, %{ + status: "suya...", + poll: %{options: ["suya", "suya.", "suya.."], expires_in: 10} + }) + + assert question = Object.normalize(activity, fetch: false) + + {:ok, [activity], _object} = CommonAPI.vote(voter, question, [1]) + + assert outbox_get = + conn + |> assign(:user, voter) + |> put_req_header("accept", "application/activity+json") + |> get(voter.ap_id <> "/outbox?page=true") + |> json_response(200) + + assert [answer_outbox] = outbox_get["orderedItems"] + assert answer_outbox["id"] == activity.data["id"] + end + end + describe "/relay/followers" do test "it returns relay followers", %{conn: conn} do relay_actor = Relay.get_actor() From 3e199242b06afcf78614dad14672bbc8f5b8ae8c Mon Sep 17 00:00:00 2001 From: Floatingghost Date: Tue, 23 Apr 2024 14:35:52 +0100 Subject: [PATCH 4/4] remove upload_media from AP representation --- lib/pleroma/web/activity_pub/views/user_view.ex | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index fe70022f1..47b8e37e5 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -26,8 +26,7 @@ def render("endpoints.json", %{user: %User{local: true} = _user}) do "oauthAuthorizationEndpoint" => url(~p"/oauth/authorize"), "oauthRegistrationEndpoint" => url(~p"/api/v1/apps"), "oauthTokenEndpoint" => url(~p"/oauth/token"), - "sharedInbox" => url(~p"/inbox"), - "uploadMedia" => url(~p"/api/ap/upload_media") + "sharedInbox" => url(~p"/inbox") } end