Merge branch 'main' of gitlab.com:prehnRA/postland

This commit is contained in:
Ro 2024-10-13 18:42:22 -05:00
commit 2d2b72981e
15 changed files with 304 additions and 20 deletions

13
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,13 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
// List of extensions which should be recommended for users of this workspace.
"recommendations": [
],
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
"unwantedRecommendations": [
]
}

View file

@ -1,9 +1,25 @@
# Postland
## Backend
- Posting
- Deleting posts
- [ ] Posting
- [ ] Timeline
- [ ] Deleting posts
- [x] Following
- [ ] Unfollowing
- [x] Being followed
- [x] Accepting follows
- Approving / declining follows and authorized instance list
- Following
- Unfollowing
- Liking
- Unliking
- [ ] Liking
- [ ] Unliking
## UX
- [ ] Posting
- [ ] Timeline
- [ ] Deleting posts
- [ ] Following
- [ ] Unfollowing
- [ ] Being followed
- [ ] Accepting follows
- Approving / declining follows and authorized instance list
- [ ] Liking
- [ ] Unliking

15
lib/activity_pub.ex Normal file
View file

@ -0,0 +1,15 @@
defmodule ActivityPub do
def fetch_actor(actor_id) do
request =
Req.new(url: actor_id)
|> Req.Request.put_header("accept", "application/json")
case Req.get(request) do
{:ok, result} ->
{:ok, result.body}
error ->
error
end
end
end

View file

@ -10,6 +10,23 @@ defmodule ActivityPub.Headers do
]
def signing_headers(method, url, body, actor_url, private_key) do
url = case url do
url when is_struct(url, URI) ->
url
url when is_binary(url) ->
URI.parse(url)
end
private_key = case private_key do
"-----BEGIN" <> _ = key_pem ->
key_pem
|> :public_key.pem_decode()
|> hd()
|> :public_key.pem_entry_decode()
private_key ->
private_key
end
method = String.downcase("#{method}")
date = DateTime.utc_now() |> Calendar.strftime(@http_date_format)
host = url.host
@ -65,6 +82,7 @@ defmodule ActivityPub.Headers do
value = find_value(header_pairs, header_name)
"#{header_name}: #{value}\n"
end)
|> String.trim()
end
def split_signature_header(signature_header) do
@ -79,19 +97,17 @@ defmodule ActivityPub.Headers do
end
defp find_value(headers, key) do
key = String.downcase(key)
Enum.find_value(headers, fn {key_candidate, value} ->
String.downcase(key_candidate) == String.downcase(key) && value
end)
end
def fetch_actor_key(key_id) do
request =
Req.new(url: key_id)
|> Req.Request.put_header("accept", "application/json")
case Req.get(request) do
{:ok, result} ->
key_map = result.body["publicKey"]
case ActivityPub.fetch_actor(key_id) do
{:ok, body} ->
key_map = body["publicKey"]
if key_map["id"] == key_id do
public_key =

View file

@ -7,6 +7,8 @@ defmodule Postland do
if it comes from the database, an external API or others.
"""
use Phoenix.VerifiedRoutes, endpoint: PostlandWeb.Endpoint, router: PostlandWeb.Router
alias Postland.Accounts
def temporary_password() do
@ -51,4 +53,8 @@ defmodule Postland do
def setup?() do
Accounts.solo_user() != nil
end
def my_actor_id do
url(~p"/actor")
end
end

View file

@ -1,10 +1,64 @@
defmodule Postland.Activities do
use Phoenix.VerifiedRoutes, endpoint: PostlandWeb.Endpoint, router: PostlandWeb.Router
require Logger
alias Postland.Activity
alias Postland.Repo
alias Postland.Follows
def process_activity(params) do
case record_activity(params) do
{:ok, activity} ->
cause_effects(activity)
other ->
other
end
end
def record_activity(params) do
params
|> Activity.changeset()
|> Repo.insert()
end
def cause_effects(%Activity{type: "Accept", data: %{"object" => %{"type" => "Follow", "id" => follow_id}}} = activity) do
pattern = ~r|/follows/([^/]+)|
actor_id = case Regex.run(pattern, follow_id) do
[_, encoded_actor_id] ->
Base.url_decode64!(encoded_actor_id, padding: false)
_other ->
nil
end
case Follows.get(Postland.my_actor_id(), actor_id) do
nil ->
Logger.warning("Got accept for a follow we don't have in the db: #{actor_id}")
{:ok, activity}
request ->
Follows.confirm_request(request)
end
{:ok, activity}
end
def cause_effects(%Activity{actor_id: actor_id, type: "Follow", data: %{"object" => object}} = activity) do
if object == Postland.my_actor_id() do
case Postland.Follows.record_inbound_request(actor_id) do
{:ok, _follow} ->
{:ok, activity}
other ->
other
end
else
{:ok, activity}
end
end
def cause_effects(activity), do: {:ok, activity}
end

View file

@ -12,13 +12,15 @@ defmodule Postland.Activity do
def changeset(attrs) do
attrs = %{
"id" => Map.get(attrs, "id", Ecto.UUID.autogenerate()),
"actor_id" => Map.get(attrs, "actor"),
"type" => Map.get(attrs, "type"),
"data" => attrs
}
%__MODULE__{}
|> cast(attrs, [:actor_id, :type, :data])
|> cast(attrs, [:id, :actor_id, :type, :data])
|> validate_required(:id)
|> validate_required(:data)
|> validate_required(:type)
end

30
lib/postland/follow.ex Normal file
View file

@ -0,0 +1,30 @@
defmodule Postland.Follow do
use Ecto.Schema
import Ecto.Changeset
@primary_key false
schema "follows" do
field :follower, :string, primary_key: true
field :followee, :string, primary_key: true
field :confirmed_at, :naive_datetime
end
def changeset(follower, followee, confirmed \\ false) do
attrs = %{
follower: follower,
followee: followee,
confirmed_at: (if confirmed, do: NaiveDateTime.utc_now())
}
%__MODULE__{}
|> cast(attrs, [:follower, :followee, :confirmed_at])
|> validate_required(:followee)
|> validate_required(:follower)
end
def confirm_changeset(request) do
request
|> cast(%{confirmed_at: NaiveDateTime.utc_now()}, [:confirmed_at])
end
end

113
lib/postland/follows.ex Normal file
View file

@ -0,0 +1,113 @@
defmodule Postland.Follows do
use Phoenix.VerifiedRoutes, endpoint: PostlandWeb.Endpoint, router: PostlandWeb.Router
import Ecto.Query, warn: false
alias Postland.Accounts
alias Postland.Activity
alias Postland.Follow
alias Postland.Repo
alias ActivityPub.Headers
alias Ecto.Multi
def record_and_send_acceptance(request) do
Multi.new()
|> Multi.run(:confirm_timestamp, fn _data, _repo -> confirm_request(request) end)
|> Multi.run(:send_acceptance, fn _data, _repo ->
send_acceptance(request)
end)
|> Repo.transaction()
end
def send_acceptance(request) do
{:ok, actor} = ActivityPub.fetch_actor(request.follower)
inbox = Map.get(actor, "inbox")
case get_follow_activity(request.follower) do
nil ->
{:error, "could not find Follow activity to Accept"}
follow_activity ->
body =
%{
"@context" => "https://www.w3.org/ns/activitystreams",
"type" => "Accept",
"actor" => Postland.my_actor_id(),
"object" => %{
"actor" => request.follower,
"id" => Map.get(follow_activity.data, "id"),
"object" => Postland.my_actor_id(),
"type" => "Follow"
}
}
|> Jason.encode!()
headers = Headers.signing_headers("POST", inbox, body, Postland.my_actor_id(), Accounts.solo_user().private_key)
Req.post(inbox, headers: headers, body: body)
end
end
def get_follow_activity(follower) do
from(a in Activity, where: a.type == "Follow", where: a.actor_id == ^follower, limit: 1, order_by: [desc: :inserted_at]) |> Repo.one()
end
def record_and_send_follow_request(to_actor_id) do
Multi.new()
|> Multi.run(:follow_record, fn _data, _repo -> record_outbound_request(to_actor_id) end)
|> Multi.run(:send_request, fn _data, _repo ->
send_follow_request(to_actor_id)
end)
|> Repo.transaction()
end
def send_follow_request(to_actor_id) do
encoded_followee = Base.url_encode64(to_actor_id, padding: false)
encoded_follower = Base.url_encode64(Postland.my_actor_id(), padding: false)
{:ok, actor} = ActivityPub.fetch_actor(to_actor_id)
inbox = Map.get(actor, "inbox")
follow_request = %{
"@context" => "https://www.w3.org/ns/activitystreams",
"id" => url(~p"/follows/#{encoded_followee}/#{encoded_follower}"),
"type" => "Follow",
"actor" => Postland.my_actor_id(),
"object" => to_actor_id
}
|> Jason.encode!()
headers = Headers.signing_headers("POST", inbox, follow_request, Postland.my_actor_id(), Accounts.solo_user().private_key)
Req.post(inbox, headers: headers, body: follow_request)
end
def get(follower, followee) do
from(f in Follow, where: f.followee == ^followee, where: f.follower == ^follower) |> Repo.one()
end
def pending_inbound_requests() do
my_actor_id = Postland.my_actor_id()
from(f in Follow, where: f.followee == ^my_actor_id, where: is_nil(f.confirmed_at))
|> Repo.all()
end
def record_outbound_request(to_actor_id) do
Postland.my_actor_id()
|> Follow.changeset(to_actor_id)
|> Repo.insert(conflict_target: [:followee, :follower], on_conflict: :nothing)
end
def record_inbound_request(from_actor_id) do
from_actor_id
|> Follow.changeset(Postland.my_actor_id())
|> Repo.insert(conflict_target: [:followee, :follower], on_conflict: :nothing)
end
def confirm_request(request) do
request
|> Follow.confirm_changeset()
|> Repo.update()
end
end

View file

@ -8,9 +8,9 @@ defmodule PostlandWeb.InboxController do
def post(conn, params) do
if Headers.verify(conn.method, conn.request_path, conn.req_headers) do
case Activities.record_activity(params) do
case Activities.process_activity(params) do
{:ok, _activity} ->
send_resp(conn, 200, "ok")
send_resp(conn, 200, Jason.encode!(params))
error ->
Logger.error(error)

View file

@ -15,7 +15,7 @@ defmodule PostlandWeb.OutboxController do
}
if Headers.verify(conn.method, conn.request_path, conn.req_headers) do
render(conn, json)
Plug.Conn.send_resp(conn, 200, Jason.encode!(json))
else
send_resp(conn, 403, "forbidden")
end

View file

@ -6,7 +6,6 @@ defmodule PostlandWeb.WebfingerJSON do
def render("webfinger.json", _assigns) do
user = Accounts.solo_user()
# TODO: Check that the host here is correct after deploy
%{
subject: "acct:#{user.username}@#{PostlandWeb.Endpoint.host()}",
links: [

View file

@ -18,7 +18,7 @@
"finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"},
"floki": {:hex, :floki, "0.36.2", "a7da0193538c93f937714a6704369711998a51a6164a222d710ebd54020aa7a3", [:mix], [], "hexpm", "a8766c0bc92f074e5cb36c4f9961982eda84c5d2b8e979ca67f5c268ec8ed580"},
"gettext": {:hex, :gettext, "0.26.1", "38e14ea5dcf962d1fc9f361b63ea07c0ce715a8ef1f9e82d3dfb8e67e0416715", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "01ce56f188b9dc28780a52783d6529ad2bc7124f9744e571e1ee4ea88bf08734"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized"]},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"},

View file

@ -0,0 +1,11 @@
defmodule Postland.Repo.Migrations.AddActors do
use Ecto.Migration
def change do
create table("follows", primary_key: false) do
add :follower, :string, primary_key: true
add :followee, :string, primary_key: true
add :confirmed_at, :naive_datetime
end
end
end

View file

@ -0,0 +1,9 @@
defmodule Postland.Repo.Migrations.AddTimestampsToActivities do
use Ecto.Migration
def change do
alter table("activities") do
timestamps()
end
end
end