diff --git a/lib/pleroma/web/akkoma_api/controllers/protocol_handler_controller.ex b/lib/pleroma/web/akkoma_api/controllers/protocol_handler_controller.ex
new file mode 100644
index 000000000..40e039e9a
--- /dev/null
+++ b/lib/pleroma/web/akkoma_api/controllers/protocol_handler_controller.ex
@@ -0,0 +1,69 @@
+# Akkoma: The cooler fediverse server
+# Copyright © 2022- Akkoma Authors
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.AkkomaAPI.ProtocolHandlerController do
+ @moduledoc """
+ Handles web-based protocol requests, in particular web+ap: which reference ActivityPub URIs.
+ see https://datatracker.ietf.org/doc/draft-soni-protocol-handler-well-known-uri/
+ A web+ap: URI should be handled like an https: URI, redirecting to the local representation of the remote or local object.
+ """
+ use Pleroma.Web, :controller
+
+ import Pleroma.Web.ControllerHelper, only: [json_response: 3]
+
+ alias Pleroma.Activity
+ alias Pleroma.Search.DatabaseSearch
+ alias Pleroma.User
+ alias Pleroma.Web.Plugs.OAuthScopesPlug
+
+ @oauth_search_actions [:handle]
+
+ # Note: (requires read:search)
+ plug(OAuthScopesPlug, %{scopes: ["read:search"], fallback: :proceed_unauthenticated} when action in @oauth_search_actions)
+
+ def reroute(conn, %{"target" => target_param}) do
+ conn |> redirect(to: "/api/v1/akkoma/protocol-handler?#{URI.encode_query([target: target_param])}")
+ end
+ def reroute(conn, _), do: conn |> json_response(:bad_request, "Missing `target` parameter")
+
+ def handle(%{assigns: %{user: user}} = conn, %{"target" => "web+ap:" <> identifier}) when is_nil(user) do
+ # Unauthenticated, only local records should be searched
+ cond do
+ URI.parse(identifier).host == Pleroma.Config.get([Pleroma.Web.Endpoint, :url, :host]) -> find_and_redirect(conn, identifier)
+ true -> conn |> json_response(:forbidden, "Invalid credentials.")
+ end
+ end
+
+ def handle(%{assigns: %{user: user}} = conn, %{"target" => "web+ap:" <> identifier}) when not is_nil(user) do
+ # Authenticated User
+ find_and_redirect(conn, identifier)
+ end
+
+ def handle(conn, _), do: conn |> json_response(:bad_request, "Could not handle protocol URL")
+
+ @spec find_and_redirect(Plug.Conn.t(), String.t()) :: Plug.Conn.t()
+ defp find_and_redirect(%{assigns: %{user: user}} = conn, identifier) do
+ # Remove userinfo if present (username:password@)
+ cleaned = URI.parse("https:" <> identifier) |> Map.merge(%{ userinfo: nil }) |> URI.to_string()
+ with {:error, _err} <- User.get_or_fetch(cleaned),
+ [] <- DatabaseSearch.maybe_fetch([], user, cleaned),
+ [] <- exact_user_search(cleaned, user) do
+ conn |> json_response(:not_found, "Not Found - #{cleaned}")
+ else
+ {:ok, %User{} = found_user} -> conn |> redirect(to: "/users/#{found_user.id}")
+
+ [%User{} = found_user] -> conn |> redirect(to: "/users/#{found_user.id}")
+
+ [%Activity{} = found_activity] -> conn |> redirect(to: "/notice/#{found_activity.id}")
+ end
+ end
+
+ defp exact_user_search(identifier, user) do
+ case User.search(identifier, limit: 1, for_user: user) do
+ [%User{:ap_id => ^identifier} = found_user] -> [found_user]
+ [%User{:uri => ^identifier} = found_user] -> [found_user]
+ _ -> []
+ end
+ end
+end
diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex
old mode 100644
new mode 100755
index ca4995281..6a09828ba
--- a/lib/pleroma/web/router.ex
+++ b/lib/pleroma/web/router.ex
@@ -475,6 +475,7 @@ defmodule Pleroma.Web.Router do
scope "/api/v1/akkoma", Pleroma.Web.AkkomaAPI do
pipe_through(:api)
+ get("/protocol-handler", ProtocolHandlerController, :handle)
get(
"/preferred_frontend/available",
@@ -852,6 +853,7 @@ defmodule Pleroma.Web.Router do
get("/host-meta", WebFinger.WebFingerController, :host_meta)
get("/webfinger", WebFinger.WebFingerController, :webfinger)
get("/nodeinfo", Nodeinfo.NodeinfoController, :schemas)
+ get("/protocol-handler", AkkomaAPI.ProtocolHandlerController, :reroute)
end
scope "/nodeinfo", Pleroma.Web do
diff --git a/priv/static/instance/web-protocol-register.css b/priv/static/instance/web-protocol-register.css
new file mode 100644
index 000000000..7f1008561
--- /dev/null
+++ b/priv/static/instance/web-protocol-register.css
@@ -0,0 +1,20 @@
+body {
+ line-height: 1.2em;
+ margin: 1rem;
+}
+main {
+ line-height: 1.5em;
+ margin: auto;
+ max-width: 50rem;
+}
+mark {
+ background-color: #ffff80;
+}
+section.no-js {
+ border: 2px solid red;
+ padding: 0.25rem;
+}
+code {
+ background-color: #eee;
+ padding: 0.1rem;
+}
\ No newline at end of file
diff --git a/priv/static/instance/web-protocol-register.html b/priv/static/instance/web-protocol-register.html
new file mode 100644
index 000000000..6f8dd59b2
--- /dev/null
+++ b/priv/static/instance/web-protocol-register.html
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+ Fedi Link Handler Registration (web+ap://)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Fedi Links handler registration
+
+ Fedi Links (web+ap://
) describe an ActivityPub resource address, so that you can open a user profile or a particular post from a remote server, but without visiting that server. They will open directly here on your home server instead.
+
+
+ For example, the URL web+ap://seafoam.space/users/m would open the profile of @m@seafoam.space, as it is represented locally on this server, without needing to copy, paste, or search.
+
+
+ Fedi Links use web-based protocols — a type of URL protocol that your browser can learn how to open. Like mailto:
or magnet:
links, they are opened by an application of your choice. However, they are not recognized by the browser by default, and need to be registered. See MDN for more information .
+
+
+ Support for Fedi Links is currently limited to desktop browsers Firefox, Chrome, Edge, and Opera. Please reach out to the developer of your favorite fedi native app to ask them to implement them!
+
+
+ You will not be able to proceed with registration without enabling JavaScript.
+
+
+ To register this server application to handle web+ap Fedi Link URLs, click the button below.
+ You will be prompted to approve - check for a dialog up near your browser URL bar.
+
+
+ Register Akkoma to handle fedi links
+
+
+
+
+
+
\ No newline at end of file
diff --git a/priv/static/instance/web-protocol-register.js b/priv/static/instance/web-protocol-register.js
new file mode 100644
index 000000000..94ffdc803
--- /dev/null
+++ b/priv/static/instance/web-protocol-register.js
@@ -0,0 +1,16 @@
+(function () {
+ function registerHandler() {
+ try {
+ navigator.registerProtocolHandler(
+ "web+ap",
+ `${window.origin}/.well-known/protocol-handler?target=%s`,
+ "Akkoma web+ap handler",
+ )
+ } catch (e) {
+ console.error("Could not register", e)
+ window.alert("Sorry, your browser does not support web-based protocol handler registration.")
+ }
+ }
+
+ document.getElementById("register").addEventListener("click", registerHandler);
+}());
\ No newline at end of file
diff --git a/test/pleroma/web/akkoma_api/protocol_handler_controller_test.exs b/test/pleroma/web/akkoma_api/protocol_handler_controller_test.exs
new file mode 100644
index 000000000..ca0466804
--- /dev/null
+++ b/test/pleroma/web/akkoma_api/protocol_handler_controller_test.exs
@@ -0,0 +1,209 @@
+defmodule Pleroma.Web.AkkomaAPI.ProtocolHandlerControllerTest do
+ use Pleroma.Web.ConnCase
+ use Oban.Testing, repo: Pleroma.Repo
+
+ alias Pleroma.Activity
+
+ import Pleroma.Factory
+
+ describe "GET /.well-known/protocol-handler" do
+ test "should return bad_request when missing `target`" do
+ %{conn: conn} = oauth_access([])
+
+ resp =
+ conn
+ |> get("/.well-known/protocol-handler")
+ |> json_response(400)
+
+ assert resp =~ "Missing `target` parameter"
+ end
+
+ test "should return redirect when target parameter is present" do
+ %{conn: conn} = oauth_access([])
+
+ resp =
+ conn
+ |> get("/.well-known/protocol-handler?target=web+ap://outerheaven.club/objects/337fca0c-6282-4142-9491-df51ac917504")
+ |> html_response(302)
+
+ assert resp =~ "You are being"
+ end
+ end
+
+ describe "GET /api/v1/akkoma/protocol-handler" do
+ setup do
+ clear_config([Pleroma.Web.Endpoint, :url, :host], "sub.example.com")
+ Tesla.Mock.mock(fn
+ %{method: :get, url: "https://mastodon.social/users/emelie/statuses/101849165031453009"} ->
+ %Tesla.Env{
+ status: 200,
+ headers: [{"content-type", "application/activity+json"}],
+ body: File.read!("test/fixtures/tesla_mock/status.emelie.json")
+ }
+ %{method: :get, url: "https://mastodon.social/users/emelie"} ->
+ %Tesla.Env{
+ status: 200,
+ headers: [{"content-type", "application/activity+json"}],
+ body: File.read!("test/fixtures/tesla_mock/emelie.json")
+ }
+ %{method: :get, url: "https://mastodon.social/@emelie"} ->
+ %Tesla.Env{
+ status: 200,
+ headers: [{"content-type", "application/activity+json"}],
+ body: File.read!("test/fixtures/tesla_mock/emelie.json")
+ }
+ %{method: :get, url: "https://mastodon.social/users/emelie/collections/featured"} ->
+ %Tesla.Env{
+ status: 200,
+ headers: [{"content-type", "application/activity+json"}],
+ body:
+ File.read!("test/fixtures/users_mock/masto_featured.json")
+ |> String.replace("{{domain}}", "mastodon.social")
+ |> String.replace("{{nickname}}", "emelie")
+ }
+ _ -> %Tesla.Env{
+ status: 404,
+ }
+ end)
+ end
+
+ test "should return bad_request when target prefix has unknown protocol" do
+ %{conn: conn} = oauth_access([])
+
+ resp =
+ conn
+ |> get("/api/v1/akkoma/protocol-handler?target=web%2Bfoo%3A%2F%2Fouterheaven.club/objects/337fca0c-6282-4142-9491-df51ac917504")
+ |> json_response(400)
+
+ assert resp =~ "Could not handle protocol URL"
+ end
+
+ test "should return forbidden for unauthed user when target is remote" do
+ %{conn: conn} = oauth_access([])
+
+ resp =
+ conn
+ |> get("/api/v1/akkoma/protocol-handler?target=web%2Bap%3A%2F%2Fouterheaven.club/objects/337fca0c-6282-4142-9491-df51ac917504")
+ |> json_response(403)
+
+ assert resp =~ "Invalid credentials."
+ end
+
+ test "should return redirect for unauthed user when target is local AP ID for user" do
+ %{conn: conn} = oauth_access([])
+ local_user = insert(:user, %{nickname: "akkoma@sub.example.com", local: true, ap_id: "https://sub.example.com/users/akkoma"})
+
+ resp =
+ conn
+ |> get("/api/v1/akkoma/protocol-handler?target=web%2Bap%3A%2F%2Fsub.example.com/users/akkoma")
+ |> html_response(302)
+
+ assert resp =~ "You are being"
+ assert resp =~ ""
+ end
+
+ test "should return not_found for unauthed user when target is local AP ID for DM note activity" do
+ %{conn: conn} = oauth_access([])
+ local_user = insert(:user, %{nickname: "akkoma@sub.example.com", local: true, ap_id: "https://sub.example.com/users/akkoma"})
+ note = insert(:note, %{
+ id: "AYAsX3ZRH6NJAzZmEa",
+ data: %{
+ "cc" => [],
+ "to" => [],
+ "actor" => local_user.ap_id,
+ "id" => "https://sub.example.com/notice/AYAsX3ZRH6NJAzZmEa",
+ "summary" => "",
+ "content" => "Pleroma's really cool!",
+ "directMessage" => true,
+ }
+ })
+ insert(:note_activity, note: note, user: local_user)
+
+ conn
+ |> get("/api/v1/akkoma/protocol-handler?target=web%2Bap%3A%2F%2Fsub.example.com/notice/AYAsX3ZRH6NJAzZmEa")
+ |> json_response(404)
+ end
+
+ test "should return not_found for unauthed user when target is local AP ID for public note activity" do
+ %{conn: conn} = oauth_access([])
+ local_user = insert(:user, %{nickname: "akkoma@sub.example.com", local: true, ap_id: "https://sub.example.com/users/akkoma"})
+ note = insert(:note, %{
+ id: "AYAsX3ZRH6NJAzZmPa",
+ data: %{
+ "cc" => [],
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "actor" => local_user.ap_id,
+ "id" => "https://sub.example.com/notice/AYAsX3ZRH6NJAzZmPa",
+ "summary" => "",
+ "content" => "Pleroma's really cool!",
+ }
+ })
+ activity = insert(:note_activity, note: note, user: local_user, visibility: "direct")
+
+ resp =
+ conn
+ |> get("/api/v1/akkoma/protocol-handler?target=web%2Bap%3A%2F%2Fsub.example.com/notice/AYAsX3ZRH6NJAzZmPa")
+ |> html_response(302)
+
+ assert resp =~ "You are being"
+ assert resp =~ " "
+ end
+
+ test "should return redirect for authed user when target is AP ID for remote user" do
+ %{conn: conn} = oauth_access(["read:search"])
+ remote_user = insert(:user, %{nickname: "akkoma@ihatebeinga.live", local: false, ap_id: "https://ihatebeinga.live/users/akkoma"})
+
+ resp =
+ conn
+ |> get("/api/v1/akkoma/protocol-handler?target=web%2Bap%3A%2F%2Fihatebeinga.live/users/akkoma")
+ |> html_response(302)
+
+ assert resp =~ "You are being"
+ assert resp =~ " "
+ end
+
+ test "should return redirect for authed user when target is URI for remote user" do
+ %{conn: conn} = oauth_access(["read:search"])
+ remote_user = insert(:user, %{
+ nickname: "emelie@mastodon.social",
+ local: false,
+ ap_id: "https://mastodon.social/users/emelie",
+ uri: "https://mastodon.social/@emelie",
+ })
+
+ resp =
+ conn
+ |> get("/api/v1/akkoma/protocol-handler?target=web%2Bap%3A%2F%2Fmastodon.social/%40emelie")
+ |> html_response(302)
+
+ assert resp =~ "You are being"
+ assert resp =~ " "
+ end
+
+ test "should return redirect for authed user when target is AP ID for user, stripping userinfo" do
+ %{conn: conn} = oauth_access(["read:search"])
+ remote_user = insert(:user, %{nickname: "akkoma@ihatebeinga.live", local: false, ap_id: "https://ihatebeinga.live/users/akkoma"})
+
+ resp =
+ conn
+ |> get("/api/v1/akkoma/protocol-handler?target=web%2Bap%3A%2F%2Fusername%3Apassword%40ihatebeinga.live/users/akkoma")
+ |> html_response(302)
+
+ assert resp =~ "You are being"
+ assert resp =~ " "
+ end
+
+ test "should return redirect for authed user when target is AP ID for remote note activity" do
+ %{conn: conn} = oauth_access(["read:search"])
+
+ resp =
+ conn
+ |> get("/api/v1/akkoma/protocol-handler?target=web%2Bap%3A%2F%2Fmastodon.social/users/emelie/statuses/101849165031453009")
+ |> html_response(302)
+
+ assert activity = Activity.get_by_object_ap_id_with_object("https://mastodon.social/users/emelie/statuses/101849165031453009")
+ assert resp =~ "You are being"
+ assert resp =~ " "
+ end
+ end
+end