web+ap protocol handler #589
6 changed files with 367 additions and 0 deletions
|
@ -0,0 +1,69 @@
|
||||||
|
# Akkoma: The cooler fediverse server
|
||||||
|
# Copyright © 2022- Akkoma Authors <https://akkoma.dev/>
|
||||||
|
# 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
|
2
lib/pleroma/web/router.ex
Normal file → Executable file
2
lib/pleroma/web/router.ex
Normal file → Executable file
|
@ -475,6 +475,7 @@ defmodule Pleroma.Web.Router do
|
||||||
|
|
||||||
scope "/api/v1/akkoma", Pleroma.Web.AkkomaAPI do
|
scope "/api/v1/akkoma", Pleroma.Web.AkkomaAPI do
|
||||||
pipe_through(:api)
|
pipe_through(:api)
|
||||||
|
get("/protocol-handler", ProtocolHandlerController, :handle)
|
||||||
|
|
||||||
get(
|
get(
|
||||||
"/preferred_frontend/available",
|
"/preferred_frontend/available",
|
||||||
|
@ -852,6 +853,7 @@ defmodule Pleroma.Web.Router do
|
||||||
get("/host-meta", WebFinger.WebFingerController, :host_meta)
|
get("/host-meta", WebFinger.WebFingerController, :host_meta)
|
||||||
get("/webfinger", WebFinger.WebFingerController, :webfinger)
|
get("/webfinger", WebFinger.WebFingerController, :webfinger)
|
||||||
get("/nodeinfo", Nodeinfo.NodeinfoController, :schemas)
|
get("/nodeinfo", Nodeinfo.NodeinfoController, :schemas)
|
||||||
|
get("/protocol-handler", AkkomaAPI.ProtocolHandlerController, :reroute)
|
||||||
end
|
end
|
||||||
|
|
||||||
scope "/nodeinfo", Pleroma.Web do
|
scope "/nodeinfo", Pleroma.Web do
|
||||||
|
|
20
priv/static/instance/web-protocol-register.css
Normal file
20
priv/static/instance/web-protocol-register.css
Normal file
|
@ -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;
|
||||||
|
}
|
51
priv/static/instance/web-protocol-register.html
Normal file
51
priv/static/instance/web-protocol-register.html
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
<!doctype html>
|
||||||
|
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
|
<title>Fedi Link Handler Registration (web+ap://)</title>
|
||||||
|
<meta name="description" content="Register a web-based protocol to open fediverse links (web+ap://)">
|
||||||
|
<meta name="author" content="Akkoma">
|
||||||
|
|
||||||
|
<meta property="og:title" content="Fedi Link Handler Registration (web+ap://)">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:description" content="Register a web-based protocol to open fediverse links (web+ap://)">
|
||||||
|
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/web-protocol-register.css" />
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Fedi Links handler registration</h1>
|
||||||
|
<p>
|
||||||
|
Fedi Links (<code>web+ap://</code>) 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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
For example, the URL <a href="web+ap://seafoam.space/users/m" target="_blank" rel="noreferrer noopener">web+ap://seafoam.space/users/m</a> would open the profile of @m@seafoam.space, as it is represented locally on this server, without needing to copy, paste, or search.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Fedi Links use web-based protocols — a type of URL protocol that your browser can learn how to open. Like <code>mailto:</code> or <code>magnet:</code> 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. <a href="https://developer.mozilla.org/en-US/docs/Web/API/Navigator/registerProtocolHandler/Web-based_protocol_handlers" target="_blank" rel="noreferrer noopener">See MDN for more information</a>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<mark>Support for Fedi Links is currently <a href="https://caniuse.com/registerprotocolhandler" target="_blank" rel="noreferrer noopener">limited to desktop browsers</a> Firefox, Chrome, Edge, and Opera. Please reach out to the developer of your favorite fedi native app to ask them to implement them!</mark>
|
||||||
|
</p>
|
||||||
|
<noscript>
|
||||||
|
<section class="no-js">You will not be able to proceed with registration without enabling JavaScript.</section>
|
||||||
|
</noscript>
|
||||||
|
<p>
|
||||||
|
To register this server application to handle web+ap Fedi Link URLs, click the button below.
|
||||||
|
<br />You will be prompted to approve - check for a dialog up near your browser URL bar.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<button id="register">Register Akkoma to handle fedi links</button>
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/static/web-protocol-register.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
16
priv/static/instance/web-protocol-register.js
Normal file
16
priv/static/instance/web-protocol-register.js
Normal file
|
@ -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);
|
||||||
|
}());
|
209
test/pleroma/web/akkoma_api/protocol_handler_controller_test.exs
Normal file
209
test/pleroma/web/akkoma_api/protocol_handler_controller_test.exs
Normal file
|
@ -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 =~ "<a href=\"/users/#{local_user.id}\">"
|
||||||
|
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 =~ "<a href=\"/notice/#{activity.id}\">"
|
||||||
|
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 =~ "<a href=\"/users/#{remote_user.id}\">"
|
||||||
|
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 =~ "<a href=\"/users/#{remote_user.id}\">"
|
||||||
|
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 =~ "<a href=\"/users/#{remote_user.id}\">"
|
||||||
|
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 =~ "<a href=\"/notice/#{activity.id}\">"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in a new issue