Merge branch 'main' of gitlab.com:prehnRA/postland
This commit is contained in:
commit
2d2b72981e
15 changed files with 304 additions and 20 deletions
13
.vscode/extensions.json
vendored
Normal file
13
.vscode/extensions.json
vendored
Normal 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": [
|
||||
|
||||
]
|
||||
}
|
||||
30
README.md
30
README.md
|
|
@ -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
15
lib/activity_pub.ex
Normal 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
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
30
lib/postland/follow.ex
Normal 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
113
lib/postland/follows.ex
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
2
mix.lock
2
mix.lock
|
|
@ -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"},
|
||||
|
|
|
|||
11
priv/repo/migrations/20241009214840_add_follows.exs
Normal file
11
priv/repo/migrations/20241009214840_add_follows.exs
Normal 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
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
defmodule Postland.Repo.Migrations.AddTimestampsToActivities do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table("activities") do
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue