From e6a78c6ed0925c27ea4d194c0e52ab07542c444e Mon Sep 17 00:00:00 2001 From: eal Date: Fri, 10 Nov 2017 15:24:39 +0200 Subject: [PATCH 1/4] MastoAPI: Add notification get, clear and dismiss. --- lib/pleroma/notification.ex | 31 ++++++++ .../mastodon_api/mastodon_api_controller.ex | 65 ++++++++++++----- lib/pleroma/web/router.ex | 3 + test/notification_test.exs | 61 ++++++++++++++++ .../mastodon_api_controller_test.exs | 71 ++++++++++++++++++- 5 files changed, 213 insertions(+), 18 deletions(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 00a382f31..039cc7312 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -36,6 +36,37 @@ def for_user(user, opts \\ %{}) do Repo.all(query) end + def get(%{id: user_id} = _user, id) do + query = from n in Notification, + where: n.id == ^id, + preload: [:activity] + + notification = Repo.one(query) + case notification do + %{user_id: ^user_id} -> + {:ok, notification} + _ -> + {:error, "Cannot get notification"} + end + end + + def clear(user) do + query = from n in Notification, + where: n.user_id == ^user.id + + Repo.delete_all(query) + end + + def dismiss(%{id: user_id} = _user, id) do + notification = Repo.get(Notification, id) + case notification do + %{user_id: ^user_id} -> + Repo.delete(notification) + _ -> + {:error, "Cannot dismiss notification"} + end + end + def create_notifications(%Activity{id: id, data: %{"to" => to, "type" => type}} = activity) when type in ["Create", "Like", "Announce", "Follow"] do users = User.get_notified_from_activity(activity) diff --git a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex index feaf9a900..d95b18315 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex @@ -193,23 +193,8 @@ def unfav_status(%{assigns: %{user: user}} = conn, %{"id" => ap_id_or_id}) do def notifications(%{assigns: %{user: user}} = conn, params) do notifications = Notification.for_user(user, params) - result = Enum.map(notifications, fn (%{id: id, activity: activity, inserted_at: created_at}) -> - actor = User.get_cached_by_ap_id(activity.data["actor"]) - created_at = NaiveDateTime.to_iso8601(created_at) - |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false) - case activity.data["type"] do - "Create" -> - %{id: id, type: "mention", created_at: created_at, account: AccountView.render("account.json", %{user: actor}), status: StatusView.render("status.json", %{activity: activity, for: user})} - "Like" -> - liked_activity = Activity.get_create_activity_by_object_ap_id(activity.data["object"]) - %{id: id, type: "favourite", created_at: created_at, account: AccountView.render("account.json", %{user: actor}), status: StatusView.render("status.json", %{activity: liked_activity, for: user})} - "Announce" -> - announced_activity = Activity.get_create_activity_by_object_ap_id(activity.data["object"]) - %{id: id, type: "reblog", created_at: created_at, account: AccountView.render("account.json", %{user: actor}), status: StatusView.render("status.json", %{activity: announced_activity, for: user})} - "Follow" -> - %{id: id, type: "follow", created_at: created_at, account: AccountView.render("account.json", %{user: actor})} - _ -> nil - end + result = Enum.map(notifications, fn x -> + render_notification(user, x) end) |> Enum.filter(&(&1)) @@ -218,6 +203,33 @@ def notifications(%{assigns: %{user: user}} = conn, params) do |> json(result) end + def get_notification(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do + with {:ok, notification} <- Notification.get(user, id) do + json(conn, render_notification(user, notification)) + else + {:error, reason} -> + conn + |> put_resp_content_type("application/json") + |> send_resp(403, Poison.encode!(%{"error" => reason})) + end + end + + def clear_notifications(%{assigns: %{user: user}} = conn, _params) do + Notification.clear(user) + json(conn, %{}) + end + + def dismiss_notification(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do + with {:ok, _notif} <- Notification.dismiss(user, id) do + json(conn, %{}) + else + {:error, reason} -> + conn + |> put_resp_content_type("application/json") + |> send_resp(403, Poison.encode!(%{"error" => reason})) + end + end + def relationships(%{assigns: %{user: user}} = conn, %{"id" => id}) do id = List.wrap(id) q = from u in User, @@ -408,4 +420,23 @@ def empty_array(conn, _) do Logger.debug("Unimplemented, returning an empty array") json(conn, []) end + + defp render_notification(user, %{id: id, activity: activity, inserted_at: created_at} = _params) do + actor = User.get_cached_by_ap_id(activity.data["actor"]) + created_at = NaiveDateTime.to_iso8601(created_at) + |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false) + case activity.data["type"] do + "Create" -> + %{id: id, type: "mention", created_at: created_at, account: AccountView.render("account.json", %{user: actor}), status: StatusView.render("status.json", %{activity: activity, for: user})} + "Like" -> + liked_activity = Activity.get_create_activity_by_object_ap_id(activity.data["object"]) + %{id: id, type: "favourite", created_at: created_at, account: AccountView.render("account.json", %{user: actor}), status: StatusView.render("status.json", %{activity: liked_activity, for: user})} + "Announce" -> + announced_activity = Activity.get_create_activity_by_object_ap_id(activity.data["object"]) + %{id: id, type: "reblog", created_at: created_at, account: AccountView.render("account.json", %{user: actor}), status: StatusView.render("status.json", %{activity: announced_activity, for: user})} + "Follow" -> + %{id: id, type: "follow", created_at: created_at, account: AccountView.render("account.json", %{user: actor})} + _ -> nil + end + end end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 0a0aea966..efd37ede2 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -82,7 +82,10 @@ def user_fetcher(username) do post "/statuses/:id/favourite", MastodonAPIController, :fav_status post "/statuses/:id/unfavourite", MastodonAPIController, :unfav_status + post "/notifications/clear", MastodonAPIController, :clear_notifications + post "/notifications/dismiss", MastodonAPIController, :dismiss_notification get "/notifications", MastodonAPIController, :notifications + get "/notifications/:id", MastodonAPIController, :get_notification post "/media", MastodonAPIController, :upload end diff --git a/test/notification_test.exs b/test/notification_test.exs index 77fdb532f..eee1c9fa3 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -31,4 +31,65 @@ test "it doesn't create a notification for user if the user blocks the activity assert nil == Notification.create_notification(activity, user) end end + + describe "get notification" do + test "it gets a notification that belongs to the user" do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = TwitterAPI.create_status(user, %{"status" => "hey @#{other_user.nickname}"}) + {:ok, [notification]} = Notification.create_notifications(activity) + {:ok, notification} = Notification.get(other_user, notification.id) + + assert notification.user_id == other_user.id + end + + test "it returns error if the notification doesn't belong to the user" do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = TwitterAPI.create_status(user, %{"status" => "hey @#{other_user.nickname}"}) + {:ok, [notification]} = Notification.create_notifications(activity) + {:error, notification} = Notification.get(user, notification.id) + end + end + + describe "dismiss notification" do + test "it dismisses a notification that belongs to the user" do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = TwitterAPI.create_status(user, %{"status" => "hey @#{other_user.nickname}"}) + {:ok, [notification]} = Notification.create_notifications(activity) + {:ok, notification} = Notification.dismiss(other_user, notification.id) + + assert notification.user_id == other_user.id + end + + test "it returns error if the notification doesn't belong to the user" do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = TwitterAPI.create_status(user, %{"status" => "hey @#{other_user.nickname}"}) + {:ok, [notification]} = Notification.create_notifications(activity) + {:error, notification} = Notification.dismiss(user, notification.id) + end + end + + describe "clear notification" do + test "it clears all notifications belonging to the user" do + user = insert(:user) + other_user = insert(:user) + third_user = insert(:user) + + {:ok, activity} = TwitterAPI.create_status(user, %{"status" => "hey @#{other_user.nickname} and @#{third_user.nickname} !"}) + {:ok, _notifs} = Notification.create_notifications(activity) + {:ok, activity} = TwitterAPI.create_status(user, %{"status" => "hey again @#{other_user.nickname} and @#{third_user.nickname} !"}) + {:ok, _notifs} = Notification.create_notifications(activity) + Notification.clear(other_user) + + assert Notification.for_user(other_user) == [] + assert Notification.for_user(third_user) != [] + end + end end diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index d118026eb..e876b0af4 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -2,7 +2,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do use Pleroma.Web.ConnCase alias Pleroma.Web.TwitterAPI.TwitterAPI - alias Pleroma.{Repo, User, Activity} + alias Pleroma.{Repo, User, Activity, Notification} alias Pleroma.Web.{OStatus, CommonAPI} import Pleroma.Factory @@ -122,6 +122,75 @@ test "when you didn't create it", %{conn: conn} do end end + describe "notifications" do + test "list of notifications", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = TwitterAPI.create_status(other_user, %{"status" => "hi @#{user.nickname}"}) + {:ok, [notification]} = Notification.create_notifications(activity) + + conn = conn + |> assign(:user, user) + |> get("/api/v1/notifications") + + expected_response = "hi @#{user.nickname}" + assert [%{"status" => %{"content" => response}} | _rest] = json_response(conn, 200) + assert response == expected_response + end + + test "getting a single notification", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = TwitterAPI.create_status(other_user, %{"status" => "hi @#{user.nickname}"}) + {:ok, [notification]} = Notification.create_notifications(activity) + + conn = conn + |> assign(:user, user) + |> get("/api/v1/notifications/#{notification.id}") + + expected_response = "hi @#{user.nickname}" + assert %{"status" => %{"content" => response}} = json_response(conn, 200) + assert response == expected_response + end + + test "dismissing a single notification", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = TwitterAPI.create_status(other_user, %{"status" => "hi @#{user.nickname}"}) + {:ok, [notification]} = Notification.create_notifications(activity) + + conn = conn + |> assign(:user, user) + |> post("/api/v1/notifications/dismiss", %{"id" => notification.id}) + + assert %{} = json_response(conn, 200) + end + + test "clearing all notifications", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + {:ok, activity} = TwitterAPI.create_status(other_user, %{"status" => "hi @#{user.nickname}"}) + {:ok, [notification]} = Notification.create_notifications(activity) + + conn = conn + |> assign(:user, user) + |> post("/api/v1/notifications/clear") + + assert %{} = json_response(conn, 200) + + conn = build_conn() + |> assign(:user, user) + |> get("/api/v1/notifications") + + assert all = json_response(conn, 200) + assert all == [] + end + end + describe "reblogging" do test "reblogs and returns the reblogged status", %{conn: conn} do activity = insert(:note_activity) From 260b148b920df030331b007d057fdfa35fa12839 Mon Sep 17 00:00:00 2001 From: eal Date: Sat, 11 Nov 2017 23:09:33 +0200 Subject: [PATCH 2/4] Use headers for file content type recognition. --- lib/pleroma/upload.ex | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/upload.ex b/lib/pleroma/upload.ex index d5723f5de..ce6e23bae 100644 --- a/lib/pleroma/upload.ex +++ b/lib/pleroma/upload.ex @@ -9,9 +9,8 @@ def store(%Plug.Upload{} = file) do File.cp!(file.path, result_file) # fix content type on some image uploads - matches = Regex.named_captures(~r/\.(?(jpg|jpeg|png|gif))$/i, file.filename) - content_type = if file.content_type == "application/octet-stream" and matches do - if matches["ext"] == "jpg", do: "image/jpeg", else: "image/#{matches["ext"]}" + content_type = if file.content_type == "application/octet-stream" do + get_content_type(file.path) else file.content_type end @@ -61,4 +60,30 @@ defp upload_path do defp url_for(file) do "#{Web.base_url()}/media/#{file}" end + + def get_content_type(file) do + # PNG: 89 50 4E 47 0D 0A 1A 0A + # GIF: 47 49 46 38 37 61 + # GIF: 47 49 46 38 39 61 + # JPEG: FF D8 FF DB + # JPEG: FF D8 FF E0 ?? ?? 4A 46 49 46 00 01 + # JPEG: FF D8 FF E1 ?? ?? 45 78 69 66 00 00 + match = File.open(file, [:read], fn(f) -> + case IO.binread(f, 8) do + <<0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a>> -> + "image/png" + <<0x47, 0x49, 0x46, 0x38, _, 0x61, _, _>> -> + "image/gif" + <<0xff, 0xd8, 0xff, _, _, _, _, _>> -> + "image/jpeg" + _ -> + "application/octet-stream" + end + end) + + case match do + {:ok, type} -> type + _e -> "application/octet-stream" + end + end end From fc7483cb3c679040d40ea86f90384b097dcda2ca Mon Sep 17 00:00:00 2001 From: eal Date: Sun, 12 Nov 2017 00:27:09 +0200 Subject: [PATCH 3/4] MastoAPI: Add update credentials endpoint. --- .../mastodon_api/mastodon_api_controller.ex | 51 +++++++++++++++++++ lib/pleroma/web/router.ex | 1 + .../mastodon_api_controller_test.exs | 50 ++++++++++++++++++ 3 files changed, 102 insertions(+) diff --git a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex index c28e20ed1..fb06d55c1 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex @@ -23,6 +23,57 @@ def create_app(conn, params) do end end + def update_credentials(%{assigns: %{user: user}} = conn, params) do + params = if bio = params["note"] do + Map.put(params, "bio", bio) + else + params + end + + params = if name = params["display_name"] do + Map.put(params, "name", name) + else + params + end + + user = if avatar = params["avatar"] do + with %Plug.Upload{} <- avatar, + {:ok, object} <- ActivityPub.upload(avatar), + change = Ecto.Changeset.change(user, %{avatar: object.data}), + {:ok, user} = Repo.update(change) do + user + else + _e -> user + end + else + user + end + + user = if banner = params["header"] do + with %Plug.Upload{} <- banner, + {:ok, object} <- ActivityPub.upload(banner), + new_info <- Map.put(user.info, "banner", object.data), + change <- User.info_changeset(user, %{info: new_info}), + {:ok, user} <- Repo.update(change) do + user + else + _e -> user + end + else + user + end + + with changeset <- User.update_changeset(user, params), + {:ok, user} <- Repo.update(changeset) do + json conn, AccountView.render("account.json", %{user: user}) + else + _e -> + conn + |> put_status(403) + |> json(%{error: "Invalid request"}) + end + end + def verify_credentials(%{assigns: %{user: user}} = conn, params) do account = AccountView.render("account.json", %{user: user}) json(conn, account) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 0a0aea966..4c74736e2 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -53,6 +53,7 @@ def user_fetcher(username) do scope "/api/v1", Pleroma.Web.MastodonAPI do pipe_through :authenticated_api + patch "/accounts/update_credentials", MastodonAPIController, :update_credentials get "/accounts/verify_credentials", MastodonAPIController, :verify_credentials get "/accounts/relationships", MastodonAPIController, :relationships get "/accounts/search", MastodonAPIController, :account_search diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index 47a613837..cf60b4a51 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -420,4 +420,54 @@ test "returns the favorites of a user", %{conn: conn} do assert [status] = json_response(conn, 200) assert status["id"] == to_string(activity.id) end + + describe "updating credentials" do + test "updates the user's bio" do + user = insert(:user) + + conn = conn + |> assign(:user, user) + |> patch("/api/v1/accounts/update_credentials", %{"note" => "I drink #cofe"}) + + assert user = json_response(conn, 200) + assert user["note"] == "I drink #cofe" + end + + test "updates the user's name" do + user = insert(:user) + + conn = conn + |> assign(:user, user) + |> patch("/api/v1/accounts/update_credentials", %{"display_name" => "markorepairs"}) + + assert user = json_response(conn, 200) + assert user["display_name"] == "markorepairs" + end + + test "updates the user's avatar" do + user = insert(:user) + + new_avatar = %Plug.Upload{content_type: "image/jpg", path: Path.absname("test/fixtures/image.jpg"), filename: "an_image.jpg"} + + conn = conn + |> assign(:user, user) + |> patch("/api/v1/accounts/update_credentials", %{"avatar" => new_avatar}) + + assert user = json_response(conn, 200) + assert user["avatar"] != "https://placehold.it/48x48" + end + + test "updates the user's banner" do + user = insert(:user) + + new_header = %Plug.Upload{content_type: "image/jpg", path: Path.absname("test/fixtures/image.jpg"), filename: "an_image.jpg"} + + conn = conn + |> assign(:user, user) + |> patch("/api/v1/accounts/update_credentials", %{"header" => new_header}) + + assert user = json_response(conn, 200) + assert user["header"] != "https://placehold.it/700x335" + end + end end From 18a95cd3757a9b2f9f468f408da48bbd84a4d031 Mon Sep 17 00:00:00 2001 From: eal Date: Sun, 12 Nov 2017 01:16:46 +0200 Subject: [PATCH 4/4] Add common video and audio types. --- lib/pleroma/upload.ex | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/upload.ex b/lib/pleroma/upload.ex index ce6e23bae..3567c6c88 100644 --- a/lib/pleroma/upload.ex +++ b/lib/pleroma/upload.ex @@ -62,12 +62,6 @@ defp url_for(file) do end def get_content_type(file) do - # PNG: 89 50 4E 47 0D 0A 1A 0A - # GIF: 47 49 46 38 37 61 - # GIF: 47 49 46 38 39 61 - # JPEG: FF D8 FF DB - # JPEG: FF D8 FF E0 ?? ?? 4A 46 49 46 00 01 - # JPEG: FF D8 FF E1 ?? ?? 45 78 69 66 00 00 match = File.open(file, [:read], fn(f) -> case IO.binread(f, 8) do <<0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a>> -> @@ -76,6 +70,16 @@ def get_content_type(file) do "image/gif" <<0xff, 0xd8, 0xff, _, _, _, _, _>> -> "image/jpeg" + <<0x1a, 0x45, 0xdf, 0xa3, _, _, _, _>> -> + "video/webm" + <<0x00, 0x00, 0x00, _, 0x66, 0x74, 0x79, 0x70>> -> + "video/mp4" + <<0x49, 0x44, 0x33, _, _, _, _, _>> -> + "audio/mpeg" + <<0x4f, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00>> -> + "audio/ogg" + <<0x52, 0x49, 0x46, 0x46, _, _, _, _>> -> + "audio/wav" _ -> "application/octet-stream" end