diff --git a/.gitignore b/.gitignore index a3fa6d5..fc490ee 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,5 @@ npm-debug.log *.db *.db-* +# Uploads +/uploads/ diff --git a/README.md b/README.md index 68f6653..5fb40ad 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,12 @@ - [x] Making posts locally - [x] Figuring out follower list - [x] Sending to followers - - [ ] Post formatting + - [x] Post formatting - [ ] Sending posts w/ images / videos - [ ] Receiving posts w/ images / videos - [ ] Timeline - [x] My posts - - [ ] Posts from accounts you follow + - [x] Posts from accounts you follow - [ ] Show the actor avatar and display name - [ ] Deleting posts - [ ] Profile @@ -30,17 +30,22 @@ ## UX - [x] Posting -- [ ] Timeline - - [~] My posts - - [ ] Posts from accounts you follow +- [x] Timeline + - [x] My posts + - [x] Posts from accounts you follow - [ ] Deleting posts - [~] Following - [ ] Unfollowing - [ ] Being followed - [ ] Accepting follows -- Approving / declining follows and authorized instance list +- [ ] Approving / declining follows and authorized instance list - [ ] Liking - [ ] Unliking - [ ] CW posts - [ ] Polls - [ ] DMs + +## Testing + +- [ ] Measure test coverage +- [ ] Add tests diff --git a/config/config.exs b/config/config.exs index cf79f35..ea65ce7 100644 --- a/config/config.exs +++ b/config/config.exs @@ -11,6 +11,16 @@ config :postland, ecto_repos: [Postland.Repo], 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 config :postland, PostlandWeb.Endpoint, url: [host: "localhost"], diff --git a/lib/postland.ex b/lib/postland.ex index 56a62fd..c3e7d76 100644 --- a/lib/postland.ex +++ b/lib/postland.ex @@ -9,6 +9,8 @@ defmodule Postland do use Phoenix.VerifiedRoutes, endpoint: PostlandWeb.Endpoint, router: PostlandWeb.Router + require Logger + alias Postland.Accounts def temporary_password() do @@ -31,6 +33,9 @@ defmodule Postland do end def on_boot_setup() do + File.mkdir_p!(Postland.Uploads.upload_path()) + Logger.info("File upload path: #{Postland.Uploads.upload_path()}") + if !setup?() do # TODO: Require the DB have restrictive permissions %{public: public_key, private: private_key} = generate_keys() diff --git a/lib/postland/accounts/user.ex b/lib/postland/accounts/user.ex index 3409aa6..96bf26f 100644 --- a/lib/postland/accounts/user.ex +++ b/lib/postland/accounts/user.ex @@ -5,6 +5,7 @@ defmodule Postland.Accounts.User do schema "users" do field :name, :string field :summary, :string + field :icon, :string field :username, :string field :password, :string, virtual: true, redact: true field :hashed_password, :string, redact: true @@ -119,6 +120,11 @@ defmodule Postland.Accounts.User do |> cast(attrs, [:name, :summary]) end + def icon_changeset(user, path) do + user + |> cast(%{icon: path}, [:icon]) + end + @doc """ Verifies the password. diff --git a/lib/postland/uploads.ex b/lib/postland/uploads.ex new file mode 100644 index 0000000..7c6aa15 --- /dev/null +++ b/lib/postland/uploads.ex @@ -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 diff --git a/lib/postland_web/components/core_components.ex b/lib/postland_web/components/core_components.ex index cfce2b6..221b547 100644 --- a/lib/postland_web/components/core_components.ex +++ b/lib/postland_web/components/core_components.ex @@ -15,17 +15,29 @@ defmodule PostlandWeb.CoreComponents do Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage. """ use Phoenix.Component + use Phoenix.VerifiedRoutes, endpoint: PostlandWeb.Endpoint, router: PostlandWeb.Router alias Phoenix.LiveView.JS use Gettext, backend: PostlandWeb.Gettext def post_form(assigns) do + user = Postland.Accounts.solo_user() + ~H"""
<.form class="py-5" phx-submit="create_post" phx-change="change_post">
- +
@@ -65,14 +77,14 @@ defmodule PostlandWeb.CoreComponents do end def post_card(assigns) do - author = Postland.Timeline.attribution(assigns.post) + author = Postland.Timeline.attribution(assigns.post) || %{} %{host: host} = case author do %{"id" => id} -> URI.parse(id) - nil -> + _ -> %{host: "<>"} end @@ -84,7 +96,11 @@ defmodule PostlandWeb.CoreComponents do ~H"""
- +
diff --git a/lib/postland_web/controllers/actor_json.ex b/lib/postland_web/controllers/actor_json.ex index e6f60c9..10df425 100644 --- a/lib/postland_web/controllers/actor_json.ex +++ b/lib/postland_web/controllers/actor_json.ex @@ -26,7 +26,11 @@ defmodule PostlandWeb.ActorJSON do "icon" => %{ "type" => "Image", "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 diff --git a/lib/postland_web/controllers/webfinger_json.ex b/lib/postland_web/controllers/webfinger_json.ex index 6e55d8d..a2134e7 100644 --- a/lib/postland_web/controllers/webfinger_json.ex +++ b/lib/postland_web/controllers/webfinger_json.ex @@ -22,7 +22,11 @@ defmodule PostlandWeb.WebfingerJSON do %{ "rel" => "http://webfinger.net/rel/avatar", "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") + ) } ] } diff --git a/lib/postland_web/endpoint.ex b/lib/postland_web/endpoint.ex index 1003cbf..231b2c1 100644 --- a/lib/postland_web/endpoint.ex +++ b/lib/postland_web/endpoint.ex @@ -1,6 +1,8 @@ defmodule PostlandWeb.Endpoint do use Phoenix.Endpoint, otp_app: :postland + require Logger + # The session will be stored in the cookie and signed, # this means its contents can be read but not tampered with. # Set :encryption_salt if you would also like to encrypt it. @@ -25,6 +27,13 @@ defmodule PostlandWeb.Endpoint do gzip: false, 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_reloader configuration of your endpoint. if code_reloading? do diff --git a/lib/postland_web/live/profile_live.ex b/lib/postland_web/live/profile_live.ex index f617d4d..76ae346 100644 --- a/lib/postland_web/live/profile_live.ex +++ b/lib/postland_web/live/profile_live.ex @@ -7,7 +7,16 @@ defmodule PostlandWeb.ProfileLive do def render(assigns) do ~H"""
- +
+ + <.form for={@form} phx-submit="avatar" phx-change="avatar"> + <.live_file_input :if={@editing} upload={@uploads.avatar} /> + +
<.form for={@form} id="profile-form" phx-change="validate" phx-submit="submit">
@@ -39,7 +48,17 @@ defmodule PostlandWeb.ProfileLive do %{host: host} = URI.parse(Postland.my_actor_id()) account = Postland.Accounts.solo_user() 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 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)} 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 diff --git a/mix.exs b/mix.exs index fa15c0f..09dfd6e 100644 --- a/mix.exs +++ b/mix.exs @@ -61,7 +61,8 @@ defmodule Postland.MixProject do {:req, "~> 0.5.6"}, {:stream_data, "~> 1.1.1", only: [:test]}, {:earmark, "~> 1.4.47"}, - {:cachex, "~> 4.0"} + {:cachex, "~> 4.0"}, + {:mime, "~> 2.0.6"} ] end diff --git a/priv/repo/migrations/20241026192805_add_icon_to_users.exs b/priv/repo/migrations/20241026192805_add_icon_to_users.exs new file mode 100644 index 0000000..7777607 --- /dev/null +++ b/priv/repo/migrations/20241026192805_add_icon_to_users.exs @@ -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