Compare commits
11 commits
54bc877f0a
...
87c5ce66cd
| Author | SHA1 | Date | |
|---|---|---|---|
| 87c5ce66cd | |||
| a4af27e5b7 | |||
| 3a9c23da95 | |||
| 7894049b6c | |||
| 9b313ecfdf | |||
| 2d2b72981e | |||
| 54fdaec2c9 | |||
| 7824edbec0 | |||
| 4bb55c08a5 | |||
| caf6e0b166 | |||
| 57088426f8 |
30 changed files with 632 additions and 51 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -28,6 +28,9 @@ postland-*.tar
|
|||
# Ignore assets that are produced by build tools.
|
||||
/priv/static/assets/
|
||||
|
||||
# Ignore temporary files
|
||||
/priv/tmp/
|
||||
|
||||
# Ignore digested assets cache.
|
||||
/priv/static/cache_manifest.json
|
||||
|
||||
|
|
|
|||
38
README.md
38
README.md
|
|
@ -1,8 +1,32 @@
|
|||
# Postland
|
||||
## Backend
|
||||
|
||||
- Posting
|
||||
- Deleting posts
|
||||
- Following
|
||||
- Unfollowing
|
||||
- Liking
|
||||
- Unliking
|
||||
- [ ] Posting
|
||||
- [x] Making posts locally
|
||||
- [ ] Figuring out follower list
|
||||
- [ ] Sending to followers
|
||||
- [ ] Timeline
|
||||
- [x] My posts
|
||||
- [ ] Posts from accounts you follow
|
||||
- [ ] Deleting posts
|
||||
- [x] Following
|
||||
- [ ] Unfollowing
|
||||
- [x] Being followed
|
||||
- [x] Accepting follows
|
||||
- [ ] Approving / declining follows and authorized instance list
|
||||
- [ ] Liking
|
||||
- [ ] Unliking
|
||||
|
||||
## UX
|
||||
|
||||
- [x] Posting
|
||||
- [ ] Timeline
|
||||
- [~] My posts
|
||||
- [ ] Posts from accounts you follow
|
||||
- [ ] Deleting posts
|
||||
- [~] Following
|
||||
- [ ] Unfollowing
|
||||
- [ ] Being followed
|
||||
- [ ] Accepting follows
|
||||
- Approving / declining follows and authorized instance list
|
||||
- [ ] Liking
|
||||
- [ ] Unliking
|
||||
|
|
|
|||
4
fly.toml
4
fly.toml
|
|
@ -21,9 +21,9 @@ kill_signal = 'SIGTERM'
|
|||
[http_service]
|
||||
internal_port = 8080
|
||||
force_https = true
|
||||
auto_stop_machines = 'stop'
|
||||
auto_stop_machines = false
|
||||
auto_start_machines = true
|
||||
min_machines_running = 0
|
||||
min_machines_running = 1
|
||||
processes = ['app']
|
||||
|
||||
[http_service.concurrency]
|
||||
|
|
|
|||
|
|
@ -1,15 +1,31 @@
|
|||
defmodule ActivityPub do
|
||||
def get(url, actor_url, private_key) do
|
||||
headers = ActivityPub.Headers.signing_headers("GET", url, "", actor_url, private_key)
|
||||
|
||||
Req.new(url: url)
|
||||
|> Req.Request.put_header("accept", "application/activity+json")
|
||||
|> Req.Request.put_headers(headers)
|
||||
|> Req.get()
|
||||
|> case do
|
||||
{:ok, response} ->
|
||||
{:ok, response.body}
|
||||
|
||||
other ->
|
||||
other
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_actor(actor_id) do
|
||||
request =
|
||||
Req.new(url: actor_id)
|
||||
|> Req.Request.put_header("accept", "application/json")
|
||||
request =
|
||||
Req.new(url: actor_id)
|
||||
|> Req.Request.put_header("accept", "application/json")
|
||||
|
||||
case Req.get(request) do
|
||||
{:ok, result} ->
|
||||
{:ok, result.body}
|
||||
case Req.get(request) do
|
||||
{:ok, result} ->
|
||||
{:ok, result.body}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -52,11 +52,9 @@ defmodule ActivityPub.Headers do
|
|||
end
|
||||
|
||||
def verify(method, path, headers, actor_fetcher \\ &fetch_actor_key/1) do
|
||||
dbg(headers)
|
||||
|
||||
{_key, signature_header} = Enum.find(headers, fn {key, _} -> key == "signature" end)
|
||||
|
||||
signature_kv = SignatureSplitter.split(signature_header) |> dbg()
|
||||
signature_kv = SignatureSplitter.split(signature_header)
|
||||
key_id = find_value(signature_kv, "keyId")
|
||||
signature = signature_kv |> find_value("signature") |> Base.decode64!()
|
||||
signing_text_headers = signature_kv |> find_value("headers") |> String.split(" ")
|
||||
|
|
@ -102,7 +100,7 @@ defmodule ActivityPub.Headers do
|
|||
key = String.downcase(key)
|
||||
|
||||
Enum.find_value(headers, fn {key_candidate, value} ->
|
||||
String.downcase(key_candidate) == key && value
|
||||
String.downcase(key_candidate) == String.downcase(key) && value
|
||||
end)
|
||||
end
|
||||
|
||||
|
|
|
|||
22
lib/activity_pub/webfinger.ex
Normal file
22
lib/activity_pub/webfinger.ex
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
defmodule ActivityPub.Webfinger do
|
||||
def lookup_resource(acct_handle) do
|
||||
[_handle, domain] = String.split(acct_handle, "@")
|
||||
|
||||
uri = %URI{
|
||||
scheme: "https",
|
||||
authority: domain,
|
||||
host: domain,
|
||||
port: 443,
|
||||
path: "/.well-known/webfinger",
|
||||
query: "resource=acct:#{acct_handle}"
|
||||
}
|
||||
|
||||
case Req.get(uri) do
|
||||
{:ok, response} ->
|
||||
{:ok, response.body}
|
||||
|
||||
other ->
|
||||
other
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -3,17 +3,83 @@ defmodule Postland.Activities do
|
|||
|
||||
require Logger
|
||||
|
||||
alias Ecto.Multi
|
||||
|
||||
alias Postland.Activity
|
||||
alias Postland.Repo
|
||||
alias Postland.Follows
|
||||
|
||||
def record_markdown_post(markdown) do
|
||||
id = Ecto.UUID.autogenerate()
|
||||
html = Earmark.as_html!(markdown)
|
||||
|
||||
%{
|
||||
"@context" => "https://www.w3.org/ns/activitystreams",
|
||||
"id" => url(~p"/activities/#{id}"),
|
||||
"type" => "Create",
|
||||
"actor" => Postland.my_actor_id(),
|
||||
"object" => [
|
||||
%{
|
||||
"id" => url(~p"/activities/#{id}"),
|
||||
"type" => "Note",
|
||||
"published" => DateTime.utc_now() |> DateTime.to_iso8601(),
|
||||
"attributedTo" => Postland.my_actor_id(),
|
||||
"mediaType" => "text/html",
|
||||
"content" => html,
|
||||
"to" => "https://www.w3.org/ns/activitystreams#Public"
|
||||
},
|
||||
%{
|
||||
"id" => url(~p"/activities/#{id}"),
|
||||
"type" => "Note",
|
||||
"published" => DateTime.utc_now() |> DateTime.to_iso8601(),
|
||||
"attributedTo" => Postland.my_actor_id(),
|
||||
"mediaType" => "text/markdown",
|
||||
"content" => markdown,
|
||||
"to" => "https://www.w3.org/ns/activitystreams#Public"
|
||||
}
|
||||
]
|
||||
}
|
||||
|> Postland.Activity.changeset()
|
||||
|> record_create_activity()
|
||||
|
||||
# TODO: ActivityPub out this activity (only for our own posts obviously)
|
||||
end
|
||||
|
||||
def record_create_activity(changeset) do
|
||||
Multi.new()
|
||||
|> Multi.insert(:activity, changeset)
|
||||
|> record_objects(changeset)
|
||||
|> Repo.transaction()
|
||||
end
|
||||
|
||||
def record_objects(multi, activity_changeset) do
|
||||
activity_changeset
|
||||
|> Ecto.Changeset.get_field(:data)
|
||||
|> Map.get("object")
|
||||
|> List.wrap()
|
||||
|> Enum.group_by(fn object ->
|
||||
{Map.get(object, "id"), Map.get(object, "type")}
|
||||
end)
|
||||
|> Enum.reduce(multi, fn {{id, type}, note_as_list_of_types}, multi ->
|
||||
Multi.insert(
|
||||
multi,
|
||||
"#{id}.#{type}",
|
||||
Postland.Object.changeset(%{
|
||||
"id" => id,
|
||||
"type" => type,
|
||||
"items" => note_as_list_of_types
|
||||
})
|
||||
)
|
||||
end)
|
||||
end
|
||||
|
||||
def process_activity(params) do
|
||||
case record_activity(params) do
|
||||
{:ok, activity} ->
|
||||
cause_effects(activity)
|
||||
|
||||
other ->
|
||||
other
|
||||
other ->
|
||||
other
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -23,11 +89,25 @@ defmodule Postland.Activities do
|
|||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def cause_effects(%Activity{actor_id: actor_id, type: "Accept", data: %{"object" => %{"type" => "Follow"}}} = activity) do
|
||||
# TODO: add effects for CRUD notes
|
||||
|
||||
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 ->
|
||||
# TODO: Need to handle the scenario where the we're following has an alias (/@foobar becomes /users/foobar by the time
|
||||
# they Accept)
|
||||
Logger.warning("Got accept for a follow we don't have in the db: #{actor_id}")
|
||||
|
||||
{:ok, activity}
|
||||
|
|
@ -36,14 +116,25 @@ defmodule Postland.Activities do
|
|||
Follows.confirm_request(request)
|
||||
end
|
||||
|
||||
{:ok, activity}
|
||||
{:ok, activity}
|
||||
end
|
||||
|
||||
def cause_effects(%Activity{actor_id: actor_id, type: "Follow", data: %{"object" => object}} = activity) do
|
||||
def cause_effects(%Activity{type: "Create"} = activity) do
|
||||
changeset = Activity.changeset(activity.data)
|
||||
|
||||
Multi.new()
|
||||
|> record_objects(changeset)
|
||||
|> Repo.transaction()
|
||||
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
|
||||
|
|
|
|||
|
|
@ -8,17 +8,21 @@ defmodule Postland.Activity do
|
|||
field :actor_id, :string
|
||||
field :type, :string
|
||||
field :data, :map
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -4,12 +4,82 @@ defmodule Postland.Follows do
|
|||
import Ecto.Query, warn: false
|
||||
|
||||
alias Postland.Accounts
|
||||
alias Postland.Activity
|
||||
alias Postland.Follow
|
||||
alias Postland.Repo
|
||||
alias ActivityPub.Headers
|
||||
|
||||
alias Ecto.Multi
|
||||
|
||||
def all_following() do
|
||||
my_actor_id = Postland.my_actor_id()
|
||||
|
||||
from(f in Follow, where: f.follower == ^my_actor_id, where: not is_nil(f.confirmed_at))
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def all_followers() do
|
||||
my_actor_id = Postland.my_actor_id()
|
||||
|
||||
from(f in Follow, where: f.followee == ^my_actor_id, where: not is_nil(f.confirmed_at))
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
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)
|
||||
|
|
@ -17,30 +87,53 @@ defmodule Postland.Follows do
|
|||
send_follow_request(to_actor_id)
|
||||
end)
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{follow_record: follow_record}} = result ->
|
||||
Phoenix.PubSub.broadcast(
|
||||
Postland.PubSub,
|
||||
"follows:#{to_actor_id}",
|
||||
{:update, follow_record}
|
||||
)
|
||||
|
||||
result
|
||||
|
||||
other ->
|
||||
other
|
||||
end
|
||||
end
|
||||
|
||||
def send_follow_request(to_actor_id) do
|
||||
encoded_followee = Base.url_encode64(to_actor_id)
|
||||
encoded_follower = Base.url_encode64(Postland.my_actor_id())
|
||||
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 = %{
|
||||
|
||||
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!()
|
||||
}
|
||||
|> Jason.encode!()
|
||||
|
||||
headers = Headers.signing_headers("POST", inbox, follow_request, Postland.my_actor_id(), Accounts.solo_user().private_key)
|
||||
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()
|
||||
from(f in Follow, where: f.followee == ^followee, where: f.follower == ^follower)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
def pending_inbound_requests() do
|
||||
|
|
@ -66,5 +159,9 @@ defmodule Postland.Follows do
|
|||
request
|
||||
|> Follow.confirm_changeset()
|
||||
|> Repo.update()
|
||||
|> case do
|
||||
{:ok, follow} ->
|
||||
Phoenix.PubSub.broadcast(Postland.PubSub, "follows:#{follow.followee}", {:update, follow})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
27
lib/postland/object.ex
Normal file
27
lib/postland/object.ex
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
defmodule Postland.Object do
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: false}
|
||||
schema "objects" do
|
||||
field :type, :string
|
||||
field :data, :map
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
def changeset(attrs) do
|
||||
attrs = %{
|
||||
"id" => Map.get(attrs, "id", Ecto.UUID.autogenerate()),
|
||||
"type" => Map.get(attrs, "type"),
|
||||
"data" => attrs
|
||||
}
|
||||
|
||||
%__MODULE__{}
|
||||
|> cast(attrs, [:id, :type, :data])
|
||||
|> validate_required(:id)
|
||||
|> validate_required(:data)
|
||||
|> validate_required(:type)
|
||||
end
|
||||
end
|
||||
38
lib/postland/timeline.ex
Normal file
38
lib/postland/timeline.ex
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
defmodule Postland.Timeline do
|
||||
alias Postland.Object
|
||||
alias Postland.Repo
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
def html_content(activity) do
|
||||
get_from_html_item(activity, "content")
|
||||
end
|
||||
|
||||
def attribution(activity) do
|
||||
get_from_html_item(activity, "attributedTo")
|
||||
end
|
||||
|
||||
defp get_from_html_item(activity, field_name) do
|
||||
case Map.get(activity.data, "items") do
|
||||
map when is_map(map) ->
|
||||
Map.get(map, field_name)
|
||||
|
||||
list when is_list(list) ->
|
||||
Enum.find_value(list, fn map ->
|
||||
Map.get(map, "mediaType", "text/html") == "text/html" && Map.get(map, field_name)
|
||||
end) || ""
|
||||
|
||||
nil ->
|
||||
""
|
||||
end
|
||||
end
|
||||
|
||||
def attribution(map) do
|
||||
end
|
||||
|
||||
def timeline do
|
||||
from(a in Object, where: a.type == "Note", order_by: [desc: a.inserted_at])
|
||||
# TODO: Only accounts I'm following + myself
|
||||
|> Repo.all()
|
||||
end
|
||||
end
|
||||
|
|
@ -19,6 +19,73 @@ defmodule PostlandWeb.CoreComponents do
|
|||
alias Phoenix.LiveView.JS
|
||||
use Gettext, backend: PostlandWeb.Gettext
|
||||
|
||||
def post_form(assigns) do
|
||||
~H"""
|
||||
<div class="relative">
|
||||
<.form class="py-5" phx-submit="create_post" phx-change="change_post">
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="flex-shrink-0">
|
||||
<img class="inline-block h-10 w-10 rounded-full" alt="" src="/images/avatar.png" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 relative">
|
||||
<div class="overflow-hidden bg-white relative rounded-lg shadow-sm ring-1 ring-inset ring-gray-300 focus-within:ring-2 focus-within:ring-indigo-600">
|
||||
<label for="post-body" class="sr-only">post body</label>
|
||||
<textarea
|
||||
rows="3"
|
||||
name="post"
|
||||
id="post-body"
|
||||
class="block w-full resize-none border-0 bg-transparent py-1.5 text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:text-sm sm:leading-6"
|
||||
placeholder="post body"
|
||||
value={@post_content}
|
||||
></textarea>
|
||||
<!-- Spacer element to match the height of the toolbar -->
|
||||
<div class="py-2" aria-hidden="true">
|
||||
<!-- Matches height of button in toolbar (1px border + 36px content height) -->
|
||||
<div class="py-px">
|
||||
<div class="h-9"></div>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 flex justify-between py-2 pl-3 pr-2">
|
||||
<div class="flex items-center space-x-5"></div>
|
||||
<div class="flex-shrink-0">
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def post_card(assigns) do
|
||||
~H"""
|
||||
<div class="flex items-start space-x-4 w-full">
|
||||
<div class="flex-shrink-0">
|
||||
<img class="inline-block h-10 w-10 rounded-full" alt="" src="/images/avatar.png" />
|
||||
</div>
|
||||
<div class="divide-y flex-grow divide-gray-200 overflow-hidden rounded-lg bg-white shadow">
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<%= Postland.Timeline.attribution(@post) %>
|
||||
</div>
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<%= {:safe, Postland.Timeline.html_content(@post)} %>
|
||||
</div>
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<!-- Content goes here -->
|
||||
<!-- We use less vertical padding on card footers at all sizes than on headers or body sections -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a modal.
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-white antialiased">
|
||||
<body class="bg-purple-50 antialiased">
|
||||
<header class="flex w-full bg-purple-950 text-purple-100 py-4">
|
||||
<div class="flex-1 px-4 uppercase font-bold ">
|
||||
Postland
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ defmodule PostlandWeb.ActorController do
|
|||
|
||||
def get(conn, _params) do
|
||||
conn
|
||||
|> Plug.Conn.put_resp_header("content-type", "application/activity+json")
|
||||
|> put_resp_content_type("application/activity+json")
|
||||
|> render(:actor, %{})
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ defmodule PostlandWeb.ActorJSON do
|
|||
"id" => url(~p"/actor#main-key"),
|
||||
"owner" => url(~p"/actor"),
|
||||
"publicKeyPem" => user.public_key
|
||||
},
|
||||
"icon" => %{
|
||||
"type" => "Image",
|
||||
"mediaType" => "image/png",
|
||||
"url" => url(~p"/images/avatar.png")
|
||||
}
|
||||
}
|
||||
end
|
||||
|
|
|
|||
16
lib/postland_web/controllers/export_controller.ex
Normal file
16
lib/postland_web/controllers/export_controller.ex
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
defmodule PostlandWeb.ExportController do
|
||||
use PostlandWeb, :controller
|
||||
|
||||
def export(conn, _params) do
|
||||
db_path = Postland.Repo.config() |> Keyword.get(:database)
|
||||
dirname = Path.dirname(db_path)
|
||||
filename = Path.basename(db_path)
|
||||
|
||||
files = [filename, "#{filename}-shm", "#{filename}-wal"] |> Enum.map(&String.to_charlist/1)
|
||||
zip_path = Path.join([File.cwd!(), "priv", "tmp", "db-export.zip"])
|
||||
|
||||
:zip.create(String.to_charlist(zip_path), files, cwd: dirname)
|
||||
|
||||
send_download(conn, {:file, zip_path}, filename: "db-export.zip")
|
||||
end
|
||||
end
|
||||
|
|
@ -10,13 +10,16 @@ defmodule PostlandWeb.InboxController do
|
|||
if Headers.verify(conn.method, conn.request_path, conn.req_headers) do
|
||||
case Activities.process_activity(params) do
|
||||
{:ok, _activity} ->
|
||||
Plug.Conn.send_resp(conn, 200, Jason.encode!(params))
|
||||
send_resp(conn, 200, Jason.encode!(params))
|
||||
|
||||
error ->
|
||||
Logger.error(error)
|
||||
Plug.Conn.send_resp(conn, 422, "unprocessable entity")
|
||||
send_resp(conn, 422, "unprocessable_entity")
|
||||
end
|
||||
else
|
||||
Plug.Conn.send_resp(conn, 403, "forbidden")
|
||||
Logger.info("Failed to verify type=#{Map.get(params, "type")}")
|
||||
|
||||
send_resp(conn, 403, "forbidden")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
defmodule PostlandWeb.OutboxController do
|
||||
use PostlandWeb, :controller
|
||||
use PostlandWeb, :controller
|
||||
|
||||
alias ActivityPub.Headers
|
||||
require Logger
|
||||
|
||||
alias ActivityPub.Headers
|
||||
|
||||
def get(conn, _params) do
|
||||
json = %{
|
||||
|
|
@ -13,9 +15,9 @@ 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
|
||||
render(conn, :forbidden)
|
||||
send_resp(conn, 403, "forbidden")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ defmodule PostlandWeb.WebfingerController do
|
|||
use PostlandWeb, :controller
|
||||
|
||||
def get(conn, _params) do
|
||||
render(conn, :webfinger, %{})
|
||||
conn
|
||||
|> put_resp_content_type("application/activity+json")
|
||||
|> render(:webfinger, %{})
|
||||
end
|
||||
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: [
|
||||
|
|
@ -14,6 +13,16 @@ defmodule PostlandWeb.WebfingerJSON do
|
|||
"rel" => "self",
|
||||
"type" => "application/activity+json",
|
||||
"href" => url(~p"/actor")
|
||||
},
|
||||
%{
|
||||
"rel" => "http://webfinger.net/rel/profile-page",
|
||||
"type" => "text/html",
|
||||
"href" => url(~p"/about")
|
||||
},
|
||||
%{
|
||||
"rel" => "http://webfinger.net/rel/avatar",
|
||||
"type" => "image/png",
|
||||
"href" => url(~p"/images/avatar.png")
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
77
lib/postland_web/live/other_profile_live.ex
Normal file
77
lib/postland_web/live/other_profile_live.ex
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
defmodule PostlandWeb.OtherProfileLive do
|
||||
use PostlandWeb, :live_view
|
||||
|
||||
alias Postland.Follows
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<h1><%= Map.get(@profile, "name") %> (<%= @acct %>)</h1>
|
||||
<%= case @follow do %>
|
||||
<% nil -> %>
|
||||
<.button phx-click="follow">Follow</.button>
|
||||
<% %{confirmed_at: nil} -> %>
|
||||
<.button disabled>Pending</.button>
|
||||
<% _ -> %>
|
||||
<.button phx-click="unfollow">Unfollow</.button>
|
||||
<% end %>
|
||||
<.list>
|
||||
<:item :for={attachment <- Map.get(@profile, "attachment")} title={attachment["name"]}>
|
||||
<%= attachment["value"] %>
|
||||
</:item>
|
||||
</.list>
|
||||
"""
|
||||
end
|
||||
|
||||
def mount(params, _session, socket) do
|
||||
{:ok, resource} = ActivityPub.Webfinger.lookup_resource(Map.get(params, "acct"))
|
||||
|
||||
%{"href" => json_profile} =
|
||||
resource
|
||||
|> Map.get("links")
|
||||
|> Enum.find(fn %{"rel" => rel} = link ->
|
||||
rel == "self" && String.contains?(Map.get(link, "type"), "json")
|
||||
end)
|
||||
|
||||
{:ok, profile} =
|
||||
ActivityPub.get(
|
||||
json_profile,
|
||||
Postland.my_actor_id(),
|
||||
Postland.Accounts.solo_user().private_key
|
||||
)
|
||||
|
||||
follow = Follows.get(Postland.my_actor_id(), json_profile)
|
||||
|
||||
if Phoenix.LiveView.connected?(socket) do
|
||||
Phoenix.PubSub.subscribe(Postland.PubSub, "follows:#{json_profile}")
|
||||
end
|
||||
|
||||
{:ok,
|
||||
assign(socket,
|
||||
acct: Map.get(params, "acct"),
|
||||
actor_id: json_profile,
|
||||
follow: follow,
|
||||
profile: profile
|
||||
)}
|
||||
end
|
||||
|
||||
def handle_event("follow", _unsigned_params, socket) do
|
||||
actor_id = socket.assigns.actor_id
|
||||
|
||||
case Follows.record_and_send_follow_request(actor_id) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:success, "Follow request sent.")
|
||||
|> assign(follow: Follows.get(Postland.my_actor_id(), actor_id))}
|
||||
|
||||
_other ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "An unexpected error has occurred.")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info({:update, follow}, socket) do
|
||||
{:noreply, assign(socket, follow: follow)}
|
||||
end
|
||||
end
|
||||
9
lib/postland_web/live/profile_live.ex
Normal file
9
lib/postland_web/live/profile_live.ex
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
defmodule PostlandWeb.ProfileLive do
|
||||
use PostlandWeb, :live_view
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>Nothing here yet.</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
43
lib/postland_web/live/timeline_live.ex
Normal file
43
lib/postland_web/live/timeline_live.ex
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
defmodule PostlandWeb.TimelineLive do
|
||||
use PostlandWeb, :live_view
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.post_form post_content={@post} />
|
||||
<div id="timeline-posts" phx-update="stream">
|
||||
<div :for={{id, post} <- @streams.posts} id={id} class="mt-10">
|
||||
<.post_card post={post} />
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, socket |> assign(:post, "") |> stream(:posts, Postland.Timeline.timeline())}
|
||||
end
|
||||
|
||||
def handle_event("create_post", %{"post" => post}, socket) do
|
||||
{:ok, results} = Postland.Activities.record_markdown_post(post)
|
||||
|
||||
new_posts =
|
||||
results
|
||||
|> Enum.filter(fn {_key, value} ->
|
||||
is_struct(value, Postland.Object)
|
||||
end)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:post, "")
|
||||
|> then(fn socket ->
|
||||
Enum.reduce(new_posts, socket, fn {_id, post}, socket ->
|
||||
stream_insert(socket, :posts, post, at: 0)
|
||||
end)
|
||||
end)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("change_post", %{"post" => post}, socket) do
|
||||
{:noreply, socket |> assign(:post, post)}
|
||||
end
|
||||
end
|
||||
|
|
@ -34,7 +34,7 @@ defmodule PostlandWeb.Router do
|
|||
scope "/", PostlandWeb do
|
||||
pipe_through [:browser, :redirect_if_not_set_up]
|
||||
|
||||
get "/", PageController, :home
|
||||
live "/about", ProfileLive, :show
|
||||
end
|
||||
|
||||
# Other scopes may use custom stacks.
|
||||
|
|
@ -74,9 +74,13 @@ defmodule PostlandWeb.Router do
|
|||
scope "/", PostlandWeb do
|
||||
pipe_through [:browser, :require_authenticated_user]
|
||||
|
||||
get "/system/export", ExportController, :export
|
||||
|
||||
live_session :require_authenticated_user,
|
||||
on_mount: [{PostlandWeb.UserAuth, :ensure_authenticated}] do
|
||||
live "/", TimelineLive, :show
|
||||
live "/users/settings", UserSettingsLive, :edit
|
||||
live "/@:acct", OtherProfileLive, :show
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
3
mix.exs
3
mix.exs
|
|
@ -59,7 +59,8 @@ defmodule Postland.MixProject do
|
|||
{:dns_cluster, "~> 0.1.1"},
|
||||
{:bandit, "~> 1.2"},
|
||||
{:req, "~> 0.5.6"},
|
||||
{:stream_data, "~> 1.1.1", only: [:test]}
|
||||
{:stream_data, "~> 1.1.1", only: [:test]},
|
||||
{:earmark, "~> 1.4.47"}
|
||||
]
|
||||
end
|
||||
|
||||
|
|
|
|||
3
mix.lock
3
mix.lock
|
|
@ -7,6 +7,7 @@
|
|||
"db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"},
|
||||
"decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
|
||||
"dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"},
|
||||
"earmark": {:hex, :earmark, "1.4.47", "7e7596b84fe4ebeb8751e14cbaeaf4d7a0237708f2ce43630cfd9065551f94ca", [:mix], [], "hexpm", "3e96bebea2c2d95f3b346a7ff22285bc68a99fbabdad9b655aa9c6be06c698f8"},
|
||||
"ecto": {:hex, :ecto, "3.12.3", "1a9111560731f6c3606924c81c870a68a34c819f6d4f03822f370ea31a582208", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9efd91506ae722f95e48dc49e70d0cb632ede3b7a23896252a60a14ac6d59165"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.12.0", "73cea17edfa54bde76ee8561b30d29ea08f630959685006d9c6e7d1e59113b7d", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dc9e4d206f274f3947e96142a8fdc5f69a2a6a9abb4649ef5c882323b6d512f0"},
|
||||
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.17.2", "200226e057f76c40be55fbac77771eb1a233260ab8ec7283f5da6d9402bde8de", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "a3838919c5a34c268c28cafab87b910bcda354a9a4e778658da46c149bb2c1da"},
|
||||
|
|
@ -18,7 +19,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", depth: 1]},
|
||||
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized"]},
|
||||
"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"},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
defmodule Postland.Repo.Migrations.AddTimestampsToActivities do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table("activities") do
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
end
|
||||
13
priv/repo/migrations/20241021231131_add_objects.exs
Normal file
13
priv/repo/migrations/20241021231131_add_objects.exs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
defmodule Postland.Repo.Migrations.AddObjects do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table("objects", primary_key: false) do
|
||||
add :id, :text, primary_key: true
|
||||
add :type, :text, null: false
|
||||
add :data, :map
|
||||
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
end
|
||||
BIN
priv/static/images/avatar.png
Normal file
BIN
priv/static/images/avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
0
priv/tmp/.keep
Normal file
0
priv/tmp/.keep
Normal file
Loading…
Reference in a new issue