feat: Upload an avatar

This commit is contained in:
Ro 2024-10-26 16:51:05 -05:00
parent f52402767b
commit 3959deb503
Signed by: ro
GPG key ID: 5B5AD5A568CDABF9
13 changed files with 158 additions and 15 deletions

2
.gitignore vendored
View file

@ -42,3 +42,5 @@ npm-debug.log
*.db *.db
*.db-* *.db-*
# Uploads
/uploads/

View file

@ -4,12 +4,12 @@
- [x] Making posts locally - [x] Making posts locally
- [x] Figuring out follower list - [x] Figuring out follower list
- [x] Sending to followers - [x] Sending to followers
- [ ] Post formatting - [x] Post formatting
- [ ] Sending posts w/ images / videos - [ ] Sending posts w/ images / videos
- [ ] Receiving posts w/ images / videos - [ ] Receiving posts w/ images / videos
- [ ] Timeline - [ ] Timeline
- [x] My posts - [x] My posts
- [ ] Posts from accounts you follow - [x] Posts from accounts you follow
- [ ] Show the actor avatar and display name - [ ] Show the actor avatar and display name
- [ ] Deleting posts - [ ] Deleting posts
- [ ] Profile - [ ] Profile
@ -30,17 +30,22 @@
## UX ## UX
- [x] Posting - [x] Posting
- [ ] Timeline - [x] Timeline
- [~] My posts - [x] My posts
- [ ] Posts from accounts you follow - [x] Posts from accounts you follow
- [ ] Deleting posts - [ ] Deleting posts
- [~] Following - [~] Following
- [ ] Unfollowing - [ ] Unfollowing
- [ ] Being followed - [ ] Being followed
- [ ] Accepting follows - [ ] Accepting follows
- Approving / declining follows and authorized instance list - [ ] Approving / declining follows and authorized instance list
- [ ] Liking - [ ] Liking
- [ ] Unliking - [ ] Unliking
- [ ] CW posts - [ ] CW posts
- [ ] Polls - [ ] Polls
- [ ] DMs - [ ] DMs
## Testing
- [ ] Measure test coverage
- [ ] Add tests

View file

@ -11,6 +11,16 @@ config :postland,
ecto_repos: [Postland.Repo], ecto_repos: [Postland.Repo],
generators: [timestamp_type: :utc_datetime] generators: [timestamp_type: :utc_datetime]
upload_path =
if config_env() == :prod do
System.get_env("DATABASE_PATH") |> Path.dirname() |> Path.join("uploads/")
else
File.cwd!() |> Path.join("uploads/")
end
config :postland,
upload_path: upload_path
# Configures the endpoint # Configures the endpoint
config :postland, PostlandWeb.Endpoint, config :postland, PostlandWeb.Endpoint,
url: [host: "localhost"], url: [host: "localhost"],

View file

@ -9,6 +9,8 @@ defmodule Postland do
use Phoenix.VerifiedRoutes, endpoint: PostlandWeb.Endpoint, router: PostlandWeb.Router use Phoenix.VerifiedRoutes, endpoint: PostlandWeb.Endpoint, router: PostlandWeb.Router
require Logger
alias Postland.Accounts alias Postland.Accounts
def temporary_password() do def temporary_password() do
@ -31,6 +33,9 @@ defmodule Postland do
end end
def on_boot_setup() do def on_boot_setup() do
File.mkdir_p!(Postland.Uploads.upload_path())
Logger.info("File upload path: #{Postland.Uploads.upload_path()}")
if !setup?() do if !setup?() do
# TODO: Require the DB have restrictive permissions # TODO: Require the DB have restrictive permissions
%{public: public_key, private: private_key} = generate_keys() %{public: public_key, private: private_key} = generate_keys()

View file

@ -5,6 +5,7 @@ defmodule Postland.Accounts.User do
schema "users" do schema "users" do
field :name, :string field :name, :string
field :summary, :string field :summary, :string
field :icon, :string
field :username, :string field :username, :string
field :password, :string, virtual: true, redact: true field :password, :string, virtual: true, redact: true
field :hashed_password, :string, redact: true field :hashed_password, :string, redact: true
@ -119,6 +120,11 @@ defmodule Postland.Accounts.User do
|> cast(attrs, [:name, :summary]) |> cast(attrs, [:name, :summary])
end end
def icon_changeset(user, path) do
user
|> cast(%{icon: path}, [:icon])
end
@doc """ @doc """
Verifies the password. Verifies the password.

27
lib/postland/uploads.ex Normal file
View file

@ -0,0 +1,27 @@
defmodule Postland.Uploads do
use Phoenix.VerifiedRoutes,
endpoint: PostlandWeb.Endpoint,
router: PostlandWeb.Router,
statics: ["uploads"]
def upload_path() do
Application.get_env(:postland, :upload_path)
end
def handle_upload(%{client_type: mime_type, uuid: uuid}, %{path: path} = meta) do
extension = extension_from_mime(mime_type)
dest = Path.join(upload_path(), "#{uuid}#{extension}")
File.cp!(path, dest)
{:ok, ~p"/uploads/#{Path.basename(dest)}"}
end
def extension_from_mime(mime_type) do
case MIME.extensions(mime_type) do
[] ->
""
[ext | _rest] ->
".#{ext}"
end
end
end

View file

@ -15,17 +15,29 @@ defmodule PostlandWeb.CoreComponents do
Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage. Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
""" """
use Phoenix.Component use Phoenix.Component
use Phoenix.VerifiedRoutes, endpoint: PostlandWeb.Endpoint, router: PostlandWeb.Router
alias Phoenix.LiveView.JS alias Phoenix.LiveView.JS
use Gettext, backend: PostlandWeb.Gettext use Gettext, backend: PostlandWeb.Gettext
def post_form(assigns) do def post_form(assigns) do
user = Postland.Accounts.solo_user()
~H""" ~H"""
<div class="relative"> <div class="relative">
<.form class="py-5" phx-submit="create_post" phx-change="change_post"> <.form class="py-5" phx-submit="create_post" phx-change="change_post">
<div class="flex items-start space-x-4"> <div class="flex items-start space-x-4">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<img class="inline-block h-16 w-16 rounded-full" alt="" src="/images/avatar.png" /> <img
class="inline-block h-16 w-16 rounded-full drop-shadow-lg"
alt=""
src={
if(user.icon,
do: unverified_url(PostlandWeb.Endpoint, user.icon),
else: url(~p"/images/avatar.png")
)
}
/>
</div> </div>
<div class="min-w-0 flex-1 relative"> <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-violet-600"> <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-violet-600">
@ -65,14 +77,14 @@ defmodule PostlandWeb.CoreComponents do
end end
def post_card(assigns) do def post_card(assigns) do
author = Postland.Timeline.attribution(assigns.post) author = Postland.Timeline.attribution(assigns.post) || %{}
%{host: host} = %{host: host} =
case author do case author do
%{"id" => id} -> %{"id" => id} ->
URI.parse(id) URI.parse(id)
nil -> _ ->
%{host: "<<nil>>"} %{host: "<<nil>>"}
end end
@ -84,7 +96,11 @@ defmodule PostlandWeb.CoreComponents do
~H""" ~H"""
<div class="flex items-start space-x-4 w-full"> <div class="flex items-start space-x-4 w-full">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<img class="inline-block h-16 w-16 rounded-full" alt="" src="/images/avatar.png" /> <img
class="inline-block h-16 w-16 rounded-full drop-shadow-lg"
alt=""
src={get_in(@author, ["icon", "url"]) || "/images/avatar.png"}
/>
</div> </div>
<div class="divide-y flex-grow divide-gray-200 overflow-hidden rounded-lg bg-white shadow"> <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"> <div class="px-4 py-4 sm:px-6">

View file

@ -26,7 +26,11 @@ defmodule PostlandWeb.ActorJSON do
"icon" => %{ "icon" => %{
"type" => "Image", "type" => "Image",
"mediaType" => "image/png", "mediaType" => "image/png",
"url" => url(~p"/images/avatar.png") "url" =>
if(user.icon,
do: unverified_url(PostlandWeb.Endpoint, user.icon),
else: url(~p"/images/avatar.png")
)
} }
} }
end end

View file

@ -22,7 +22,11 @@ defmodule PostlandWeb.WebfingerJSON do
%{ %{
"rel" => "http://webfinger.net/rel/avatar", "rel" => "http://webfinger.net/rel/avatar",
"type" => "image/png", "type" => "image/png",
"href" => url(~p"/images/avatar.png") "href" =>
if(user.icon,
do: unverified_url(PostlandWeb.Endpoint, user.icon),
else: url(~p"/images/avatar.png")
)
} }
] ]
} }

View file

@ -1,6 +1,8 @@
defmodule PostlandWeb.Endpoint do defmodule PostlandWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :postland use Phoenix.Endpoint, otp_app: :postland
require Logger
# The session will be stored in the cookie and signed, # The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with. # this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it. # Set :encryption_salt if you would also like to encrypt it.
@ -25,6 +27,13 @@ defmodule PostlandWeb.Endpoint do
gzip: false, gzip: false,
only: PostlandWeb.static_paths() only: PostlandWeb.static_paths()
Logger.info("File upload path: #{Postland.Uploads.upload_path()}")
plug Plug.Static,
at: "/uploads",
from: Postland.Uploads.upload_path(),
gzip: false
# Code reloading can be explicitly enabled under the # Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint. # :code_reloader configuration of your endpoint.
if code_reloading? do if code_reloading? do

View file

@ -7,7 +7,16 @@ defmodule PostlandWeb.ProfileLive do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="flex items-start space-x-4"> <div class="flex items-start space-x-4">
<img class="h-16 w-16 rounded-full" src={url(~p"/images/avatar.png")} alt="" /> <div>
<img
class="h-16 w-16 rounded-full drop-shadow-lg"
src={@account.icon || url(~p"/images/avatar.png")}
alt=""
/>
<.form for={@form} phx-submit="avatar" phx-change="avatar">
<.live_file_input :if={@editing} upload={@uploads.avatar} />
</.form>
</div>
<div class="flex flex-col bg-white rounded-lg shadow container"> <div class="flex flex-col bg-white rounded-lg shadow container">
<.form for={@form} id="profile-form" phx-change="validate" phx-submit="submit"> <.form for={@form} id="profile-form" phx-change="validate" phx-submit="submit">
<div class="p-4"> <div class="p-4">
@ -39,7 +48,17 @@ defmodule PostlandWeb.ProfileLive do
%{host: host} = URI.parse(Postland.my_actor_id()) %{host: host} = URI.parse(Postland.my_actor_id())
account = Postland.Accounts.solo_user() account = Postland.Accounts.solo_user()
form = account |> User.profile_changeset(%{}) |> to_form() form = account |> User.profile_changeset(%{}) |> to_form()
{:ok, assign(socket, account: account, host: host, editing: false, form: form)}
socket =
socket
|> assign(account: account, host: host, editing: false, form: form)
|> allow_upload(:avatar,
accept: ~w(.jpg .jpeg .png),
progress: &handle_progress/3,
auto_upload: true
)
{:ok, socket}
end end
def handle_event("edit", _, socket) do def handle_event("edit", _, socket) do
@ -66,4 +85,30 @@ defmodule PostlandWeb.ProfileLive do
{:noreply, socket |> put_flash(:error, "An error occurred.") |> assign(form: form)} {:noreply, socket |> put_flash(:error, "An error occurred.") |> assign(form: form)}
end end
end end
def handle_event("avatar", _, socket) do
# The actual upload is handled by auto-upload and the handle_progress callback
{:noreply, socket}
end
def handle_progress(:avatar, entry, socket) do
if entry.done? do
uploaded_file =
consume_uploaded_entry(socket, entry, fn meta ->
Postland.Uploads.handle_upload(entry, meta)
end)
changeset = User.icon_changeset(socket.assigns.account, uploaded_file)
case Repo.update(changeset) do
{:ok, account} ->
{:noreply, assign(socket, account: account)}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "An error occurred while finishing upload.")}
end
else
{:noreply, socket}
end
end
end end

View file

@ -61,7 +61,8 @@ defmodule Postland.MixProject do
{:req, "~> 0.5.6"}, {:req, "~> 0.5.6"},
{:stream_data, "~> 1.1.1", only: [:test]}, {:stream_data, "~> 1.1.1", only: [:test]},
{:earmark, "~> 1.4.47"}, {:earmark, "~> 1.4.47"},
{:cachex, "~> 4.0"} {:cachex, "~> 4.0"},
{:mime, "~> 2.0.6"}
] ]
end end

View file

@ -0,0 +1,9 @@
defmodule Postland.Repo.Migrations.AddIconToUsers do
use Ecto.Migration
def change do
alter table("users") do
add :icon, :string
end
end
end