Compare commits

...

11 commits

Author SHA1 Message Date
Ro
87c5ce66cd feat: Ingest posts from followers 2024-10-22 10:50:26 -05:00
Ro
a4af27e5b7 feat: Show timeline 2024-10-21 17:46:42 -05:00
Ro
3a9c23da95 feat: Minimal posting form 2024-10-16 10:30:23 -05:00
Ro
7894049b6c feat: Add rudimentary follow UI 2024-10-13 20:02:59 -05:00
Ro
9b313ecfdf Merge origin/main 2024-10-13 18:42:39 -05:00
Ro
2d2b72981e Merge branch 'main' of gitlab.com:prehnRA/postland 2024-10-13 18:42:22 -05:00
Ro
54fdaec2c9 fix: Missing template error in OutboxController 2024-10-11 02:48:53 +00:00
Ro
7824edbec0 feat: Accept follow requests 2024-10-11 02:44:55 +00:00
Ro
4bb55c08a5 chore: Update TODOs 2024-10-11 01:19:29 +00:00
Ro
caf6e0b166 fix: Process Accept activities 2024-10-11 01:17:08 +00:00
Ro
57088426f8 feat: Export dbs 2024-09-29 19:53:00 -05:00
30 changed files with 632 additions and 51 deletions

3
.gitignore vendored
View file

@ -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

View file

@ -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

View file

@ -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]

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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
View 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
View 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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

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: [
@ -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")
}
]
}

View 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

View 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

View 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

View file

@ -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

View file

@ -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

View file

@ -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"},

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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

0
priv/tmp/.keep Normal file
View file