diff options
29 files changed, 1520 insertions, 135 deletions
diff --git a/docs/api/admin_api.md b/docs/api/admin_api.md index 86cacebb1..8befa8ea0 100644 --- a/docs/api/admin_api.md +++ b/docs/api/admin_api.md @@ -200,12 +200,65 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret  ## `/api/pleroma/admin/invite_token` -### Get a account registeration invite token +### Get an account registration invite token  - Methods: `GET` -- Params: none +- Params: +  - *optional* `invite` => [ +    - *optional* `max_use` (integer) +    - *optional* `expires_at` (date string e.g. "2019-04-07") +  ]  - Response: invite token (base64 string) +## `/api/pleroma/admin/invites` + +### Get a list of generated invites + +- Methods: `GET` +- Params: none +- Response: + +```JSON +{ + +  "invites": [ +    { +      "id": integer, +      "token": string, +      "used": boolean, +      "expires_at": date, +      "uses": integer, +      "max_use": integer, +      "invite_type": string (possible values: `one_time`, `reusable`, `date_limited`, `reusable_date_limited`) +    }, +    ... +  ] +} +``` + +## `/api/pleroma/admin/revoke_invite` + +### Revoke invite by token + +- Methods: `POST` +- Params: +  - `token` +- Response: + +```JSON +{ +  "id": integer, +  "token": string, +  "used": boolean, +  "expires_at": date, +  "uses": integer, +  "max_use": integer, +  "invite_type": string (possible values: `one_time`, `reusable`, `date_limited`, `reusable_date_limited`) + +} +``` + +  ## `/api/pleroma/admin/email_invite`  ### Sends registration invite via email @@ -213,7 +266,7 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret  - Methods: `POST`  - Params:    - `email` -  - `name`, optionnal +  - `name`, optional  ## `/api/pleroma/admin/password_reset` diff --git a/docs/api/pleroma_api.md b/docs/api/pleroma_api.md index 2e8fb04d2..dbe250300 100644 --- a/docs/api/pleroma_api.md +++ b/docs/api/pleroma_api.md @@ -10,7 +10,29 @@ Request parameters can be passed via [query strings](https://en.wikipedia.org/wi  * Authentication: not required  * Params: none  * Response: JSON -* Example response: `[{"kalsarikannit_f":{"tags":["Finmoji"],"image_url":"/finmoji/128px/kalsarikannit_f-128.png"}},{"perkele":{"tags":["Finmoji"],"image_url":"/finmoji/128px/perkele-128.png"}},{"blobdab":{"tags":["SomeTag"],"image_url":"/emoji/blobdab.png"}},"happiness":{"tags":["Finmoji"],"image_url":"/finmoji/128px/happiness-128.png"}}]` +* Example response: +```json +{ +  "girlpower": { +    "tags": [ +      "Finmoji" +    ], +    "image_url": "/finmoji/128px/girlpower-128.png" +  }, +  "education": { +    "tags": [ +      "Finmoji" +    ], +    "image_url": "/finmoji/128px/education-128.png" +  }, +  "finnishlove": { +    "tags": [ +      "Finmoji" +    ], +    "image_url": "/finmoji/128px/finnishlove-128.png" +  } +} +```  * Note: Same data as Mastodon API’s `/api/v1/custom_emojis` but in a different format  ## `/api/pleroma/follow_import` @@ -52,7 +74,7 @@ Request parameters can be passed via [query strings](https://en.wikipedia.org/wi      * `confirm`      * `captcha_solution`: optional, contains provider-specific captcha solution,      * `captcha_token`: optional, contains provider-specific captcha token -    * `token`: invite token required when the registerations aren't public. +    * `token`: invite token required when the registrations aren't public.  * Response: JSON. Returns a user object on success, otherwise returns `{"error": "error_msg"}`  * Example response:  ``` @@ -114,5 +136,64 @@ See [Admin-API](Admin-API.md)  * Method `POST`  * Authentication: required  * Params: -    * `id`: notifications's id +    * `id`: notification's id  * Response: JSON. Returns `{"status": "success"}` if the reading was successful, otherwise returns `{"error": "error_msg"}` + +## `/api/v1/pleroma/accounts/:id/subscribe` +### Subscribe to receive notifications for all statuses posted by a user +* Method `POST` +* Authentication: required +* Params: +    * `id`: account id to subscribe to +* Response: JSON, returns a mastodon relationship object on success, otherwise returns `{"error": "error_msg"}` +* Example response: +```json +{ +  "id": "abcdefg", +  "following": true, +  "followed_by": false, +  "blocking": false, +  "muting": false, +  "muting_notifications": false, +  "subscribing": true, +  "requested": false, +  "domain_blocking": false, +  "showing_reblogs": true, +  "endorsed": false +} +``` + +## `/api/v1/pleroma/accounts/:id/unsubscribe` +### Unsubscribe to stop receiving notifications from user statuses +* Method `POST` +* Authentication: required +* Params: +    * `id`: account id to unsubscribe from +* Response: JSON, returns a mastodon relationship object on success, otherwise returns `{"error": "error_msg"}` +* Example response: +```json +{ +  "id": "abcdefg", +  "following": true, +  "followed_by": false, +  "blocking": false, +  "muting": false, +  "muting_notifications": false, +  "subscribing": false, +  "requested": false, +  "domain_blocking": false, +  "showing_reblogs": true, +  "endorsed": false +} +``` + +## `/api/pleroma/notification_settings` +### Updates user notification settings +* Method `PUT` +* Authentication: required +* Params: +    * `followers`: BOOLEAN field, receives notifications from followers +    * `follows`: BOOLEAN field, receives notifications from people the user follows +    * `remote`: BOOLEAN field, receives notifications from people on remote instances +    * `local`: BOOLEAN field, receives notifications from people on the local instance +* Response: JSON. Returns `{"status": "success"}` if the update was successful, otherwise returns `{"error": "error_msg"}` diff --git a/lib/mix/tasks/pleroma/user.ex b/lib/mix/tasks/pleroma/user.ex index 0d0bea8c0..441168df2 100644 --- a/lib/mix/tasks/pleroma/user.ex +++ b/lib/mix/tasks/pleroma/user.ex @@ -7,6 +7,7 @@ defmodule Mix.Tasks.Pleroma.User do    import Ecto.Changeset    alias Mix.Tasks.Pleroma.Common    alias Pleroma.User +  alias Pleroma.UserInviteToken    @shortdoc "Manages Pleroma users"    @moduledoc """ @@ -26,7 +27,19 @@ defmodule Mix.Tasks.Pleroma.User do    ## Generate an invite link. -      mix pleroma.user invite +      mix pleroma.user invite [OPTION...] + +    Options: +    - `--expires_at DATE` - last day on which token is active (e.g. "2019-04-05") +    - `--max_use NUMBER` - maximum numbers of token uses + +  ## List generated invites + +      mix pleroma.user invites + +  ## Revoke invite + +      mix pleroma.user revoke_invite TOKEN OR TOKEN_ID    ## Delete the user's account. @@ -287,23 +300,79 @@ defmodule Mix.Tasks.Pleroma.User do      end    end -  def run(["invite"]) do +  def run(["invite" | rest]) do +    {options, [], []} = +      OptionParser.parse(rest, +        strict: [ +          expires_at: :string, +          max_use: :integer +        ] +      ) + +    options = +      options +      |> Keyword.update(:expires_at, {:ok, nil}, fn +        nil -> {:ok, nil} +        val -> Date.from_iso8601(val) +      end) +      |> Enum.into(%{}) +      Common.start_pleroma() -    with {:ok, token} <- Pleroma.UserInviteToken.create_token() do -      Mix.shell().info("Generated user invite token") +    with {:ok, val} <- options[:expires_at], +         options = Map.put(options, :expires_at, val), +         {:ok, invite} <- UserInviteToken.create_invite(options) do +      Mix.shell().info( +        "Generated user invite token " <> String.replace(invite.invite_type, "_", " ") +      )        url =          Pleroma.Web.Router.Helpers.redirect_url(            Pleroma.Web.Endpoint,            :registration_page, -          token.token +          invite.token          )        IO.puts(url)      else -      _ -> -        Mix.shell().error("Could not create invite token.") +      error -> +        Mix.shell().error("Could not create invite token: #{inspect(error)}") +    end +  end + +  def run(["invites"]) do +    Common.start_pleroma() + +    Mix.shell().info("Invites list:") + +    UserInviteToken.list_invites() +    |> Enum.each(fn invite -> +      expire_info = +        with expires_at when not is_nil(expires_at) <- invite.expires_at do +          " | Expires at: #{Date.to_string(expires_at)}" +        end + +      using_info = +        with max_use when not is_nil(max_use) <- invite.max_use do +          " | Max use: #{max_use}    Left use: #{max_use - invite.uses}" +        end + +      Mix.shell().info( +        "ID: #{invite.id} | Token: #{invite.token} | Token type: #{invite.invite_type} | Used: #{ +          invite.used +        }#{expire_info}#{using_info}" +      ) +    end) +  end + +  def run(["revoke_invite", token]) do +    Common.start_pleroma() + +    with {:ok, invite} <- UserInviteToken.find_by_token(token), +         {:ok, _} <- UserInviteToken.update_invite(invite, %{used: true}) do +      Mix.shell().info("Invite for token #{token} was revoked.") +    else +      _ -> Mix.shell().error("No invite found with token #{token}")      end    end diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index cac10f24a..15789907a 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -122,13 +122,7 @@ defmodule Pleroma.Notification do    # TODO move to sql, too.    def create_notification(%Activity{} = activity, %User{} = user) do -    unless User.blocks?(user, %{ap_id: activity.data["actor"]}) or -             CommonAPI.thread_muted?(user, activity) or user.ap_id == activity.data["actor"] or -             (activity.data["type"] == "Follow" and -                Enum.any?(Notification.for_user(user), fn notif -> -                  notif.activity.data["type"] == "Follow" and -                    notif.activity.data["actor"] == activity.data["actor"] -                end)) do +    unless skip?(activity, user) do        notification = %Notification{user_id: user.id, activity: activity}        {:ok, notification} = Repo.insert(notification)        Pleroma.Web.Streamer.stream("user", notification) @@ -148,10 +142,66 @@ defmodule Pleroma.Notification do        []        |> Utils.maybe_notify_to_recipients(activity)        |> Utils.maybe_notify_mentioned_recipients(activity) +      |> Utils.maybe_notify_subscribers(activity)        |> Enum.uniq()      User.get_users_from_set(recipients, local_only)    end    def get_notified_from_activity(_, _local_only), do: [] + +  def skip?(activity, user) do +    [:self, :blocked, :local, :muted, :followers, :follows, :recently_followed] +    |> Enum.any?(&skip?(&1, activity, user)) +  end + +  def skip?(:self, activity, user) do +    activity.data["actor"] == user.ap_id +  end + +  def skip?(:blocked, activity, user) do +    actor = activity.data["actor"] +    User.blocks?(user, %{ap_id: actor}) +  end + +  def skip?(:local, %{local: true}, %{info: %{notification_settings: %{"local" => false}}}), +    do: true + +  def skip?(:local, %{local: false}, %{info: %{notification_settings: %{"remote" => false}}}), +    do: true + +  def skip?(:muted, activity, user) do +    actor = activity.data["actor"] + +    User.mutes?(user, %{ap_id: actor}) or +      CommonAPI.thread_muted?(user, activity) +  end + +  def skip?( +        :followers, +        activity, +        %{info: %{notification_settings: %{"followers" => false}}} = user +      ) do +    actor = activity.data["actor"] +    follower = User.get_cached_by_ap_id(actor) +    User.following?(follower, user) +  end + +  def skip?(:follows, activity, %{info: %{notification_settings: %{"follows" => false}}} = user) do +    actor = activity.data["actor"] +    followed = User.get_by_ap_id(actor) +    User.following?(user, followed) +  end + +  def skip?(:recently_followed, %{data: %{"type" => "Follow"}} = activity, user) do +    actor = activity.data["actor"] + +    Notification.for_user(user) +    |> Enum.any?(fn +      %{activity: %{data: %{"type" => "Follow", "actor" => ^actor}}} -> true +      _ -> false +    end) +  end + +  def skip?(_, _, _), do: false  end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 4f579b597..6e2269aff 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -931,6 +931,38 @@ defmodule Pleroma.User do      update_and_set_cache(cng)    end +  def subscribe(subscriber, %{ap_id: ap_id}) do +    deny_follow_blocked = Pleroma.Config.get([:user, :deny_follow_blocked]) + +    with %User{} = subscribed <- get_cached_by_ap_id(ap_id) do +      blocked = blocks?(subscribed, subscriber) and deny_follow_blocked + +      if blocked do +        {:error, "Could not subscribe: #{subscribed.nickname} is blocking you"} +      else +        info_cng = +          subscribed.info +          |> User.Info.add_to_subscribers(subscriber.ap_id) + +        change(subscribed) +        |> put_embed(:info, info_cng) +        |> update_and_set_cache() +      end +    end +  end + +  def unsubscribe(unsubscriber, %{ap_id: ap_id}) do +    with %User{} = user <- get_cached_by_ap_id(ap_id) do +      info_cng = +        user.info +        |> User.Info.remove_from_subscribers(unsubscriber.ap_id) + +      change(user) +      |> put_embed(:info, info_cng) +      |> update_and_set_cache() +    end +  end +    def block(blocker, %User{ap_id: ap_id} = blocked) do      # sever any follow relationships to prevent leaks per activitypub (Pleroma issue #213)      blocker = @@ -941,6 +973,14 @@ defmodule Pleroma.User do          blocker        end +    blocker = +      if subscribed_to?(blocked, blocker) do +        {:ok, blocker} = unsubscribe(blocked, blocker) +        blocker +      else +        blocker +      end +      if following?(blocked, blocker) do        unfollow(blocked, blocker)      end @@ -989,12 +1029,21 @@ defmodule Pleroma.User do        end)    end +  def subscribed_to?(user, %{ap_id: ap_id}) do +    with %User{} = target <- User.get_by_ap_id(ap_id) do +      Enum.member?(target.info.subscribers, user.ap_id) +    end +  end +    def muted_users(user),      do: Repo.all(from(u in User, where: u.ap_id in ^user.info.mutes))    def blocked_users(user),      do: Repo.all(from(u in User, where: u.ap_id in ^user.info.blocks)) +  def subscribers(user), +    do: Repo.all(from(u in User, where: u.ap_id in ^user.info.subscribers)) +    def block_domain(user, domain) do      info_cng =        user.info @@ -1092,6 +1141,14 @@ defmodule Pleroma.User do      update_and_set_cache(cng)    end +  def update_notification_settings(%User{} = user, settings \\ %{}) do +    info_changeset = User.Info.update_notification_settings(user.info, settings) + +    change(user) +    |> put_embed(:info, info_changeset) +    |> update_and_set_cache() +  end +    def delete(%User{} = user) do      {:ok, user} = User.deactivate(user) diff --git a/lib/pleroma/user/info.ex b/lib/pleroma/user/info.ex index 740a46727..5afa7988c 100644 --- a/lib/pleroma/user/info.ex +++ b/lib/pleroma/user/info.ex @@ -22,6 +22,7 @@ defmodule Pleroma.User.Info do      field(:domain_blocks, {:array, :string}, default: [])      field(:mutes, {:array, :string}, default: [])      field(:muted_reblogs, {:array, :string}, default: []) +    field(:subscribers, {:array, :string}, default: [])      field(:deactivated, :boolean, default: false)      field(:no_rich_text, :boolean, default: false)      field(:ap_enabled, :boolean, default: false) @@ -40,6 +41,10 @@ defmodule Pleroma.User.Info do      field(:pinned_activities, {:array, :string}, default: [])      field(:flavour, :string, default: nil) +    field(:notification_settings, :map, +      default: %{"remote" => true, "local" => true, "followers" => true, "follows" => true} +    ) +      # Found in the wild      # ap_id -> Where is this used?      # bio -> Where is this used? @@ -57,6 +62,19 @@ defmodule Pleroma.User.Info do      |> validate_required([:deactivated])    end +  def update_notification_settings(info, settings) do +    notification_settings = +      info.notification_settings +      |> Map.merge(settings) +      |> Map.take(["remote", "local", "followers", "follows"]) + +    params = %{notification_settings: notification_settings} + +    info +    |> cast(params, [:notification_settings]) +    |> validate_required([:notification_settings]) +  end +    def add_to_note_count(info, number) do      set_note_count(info, info.note_count + number)    end @@ -93,6 +111,14 @@ defmodule Pleroma.User.Info do      |> validate_required([:blocks])    end +  def set_subscribers(info, subscribers) do +    params = %{subscribers: subscribers} + +    info +    |> cast(params, [:subscribers]) +    |> validate_required([:subscribers]) +  end +    def add_to_mutes(info, muted) do      set_mutes(info, Enum.uniq([muted | info.mutes]))    end @@ -109,6 +135,14 @@ defmodule Pleroma.User.Info do      set_blocks(info, List.delete(info.blocks, blocked))    end +  def add_to_subscribers(info, subscribed) do +    set_subscribers(info, Enum.uniq([subscribed | info.subscribers])) +  end + +  def remove_from_subscribers(info, subscribed) do +    set_subscribers(info, List.delete(info.subscribers, subscribed)) +  end +    def set_domain_blocks(info, domain_blocks) do      params = %{domain_blocks: domain_blocks} diff --git a/lib/pleroma/user_invite_token.ex b/lib/pleroma/user_invite_token.ex index 9c5579934..86f0a5486 100644 --- a/lib/pleroma/user_invite_token.ex +++ b/lib/pleroma/user_invite_token.ex @@ -6,40 +6,119 @@ defmodule Pleroma.UserInviteToken do    use Ecto.Schema    import Ecto.Changeset - +  import Ecto.Query    alias Pleroma.Repo    alias Pleroma.UserInviteToken +  @type t :: %__MODULE__{} +  @type token :: String.t() +    schema "user_invite_tokens" do      field(:token, :string)      field(:used, :boolean, default: false) +    field(:max_use, :integer) +    field(:expires_at, :date) +    field(:uses, :integer, default: 0) +    field(:invite_type, :string)      timestamps()    end -  def create_token do +  @spec create_invite(map()) :: UserInviteToken.t() +  def create_invite(params \\ %{}) do +    %UserInviteToken{} +    |> cast(params, [:max_use, :expires_at]) +    |> add_token() +    |> assign_type() +    |> Repo.insert() +  end + +  defp add_token(changeset) do      token = :crypto.strong_rand_bytes(32) |> Base.url_encode64() +    put_change(changeset, :token, token) +  end -    token = %UserInviteToken{ -      used: false, -      token: token -    } +  defp assign_type(%{changes: %{max_use: _max_use, expires_at: _expires_at}} = changeset) do +    put_change(changeset, :invite_type, "reusable_date_limited") +  end + +  defp assign_type(%{changes: %{expires_at: _expires_at}} = changeset) do +    put_change(changeset, :invite_type, "date_limited") +  end + +  defp assign_type(%{changes: %{max_use: _max_use}} = changeset) do +    put_change(changeset, :invite_type, "reusable") +  end + +  defp assign_type(changeset), do: put_change(changeset, :invite_type, "one_time") -    Repo.insert(token) +  @spec list_invites() :: [UserInviteToken.t()] +  def list_invites do +    query = from(u in UserInviteToken, order_by: u.id) +    Repo.all(query)    end -  def used_changeset(struct) do -    struct -    |> cast(%{}, []) -    |> put_change(:used, true) +  @spec update_invite!(UserInviteToken.t(), map()) :: UserInviteToken.t() | no_return() +  def update_invite!(invite, changes) do +    change(invite, changes) |> Repo.update!()    end -  def mark_as_used(token) do -    with %{used: false} = token <- Repo.get_by(UserInviteToken, %{token: token}), -         {:ok, token} <- Repo.update(used_changeset(token)) do -      {:ok, token} -    else -      _e -> {:error, token} +  @spec update_invite(UserInviteToken.t(), map()) :: +          {:ok, UserInviteToken.t()} | {:error, Changeset.t()} +  def update_invite(invite, changes) do +    change(invite, changes) |> Repo.update() +  end + +  @spec find_by_token!(token()) :: UserInviteToken.t() | no_return() +  def find_by_token!(token), do: Repo.get_by!(UserInviteToken, token: token) + +  @spec find_by_token(token()) :: {:ok, UserInviteToken.t()} | nil +  def find_by_token(token) do +    with invite <- Repo.get_by(UserInviteToken, token: token) do +      {:ok, invite}      end    end + +  @spec valid_invite?(UserInviteToken.t()) :: boolean() +  def valid_invite?(%{invite_type: "one_time"} = invite) do +    not invite.used +  end + +  def valid_invite?(%{invite_type: "date_limited"} = invite) do +    not_overdue_date?(invite) and not invite.used +  end + +  def valid_invite?(%{invite_type: "reusable"} = invite) do +    invite.uses < invite.max_use and not invite.used +  end + +  def valid_invite?(%{invite_type: "reusable_date_limited"} = invite) do +    not_overdue_date?(invite) and invite.uses < invite.max_use and not invite.used +  end + +  defp not_overdue_date?(%{expires_at: expires_at}) do +    Date.compare(Date.utc_today(), expires_at) in [:lt, :eq] +  end + +  @spec update_usage!(UserInviteToken.t()) :: nil | UserInviteToken.t() | no_return() +  def update_usage!(%{invite_type: "date_limited"}), do: nil + +  def update_usage!(%{invite_type: "one_time"} = invite), +    do: update_invite!(invite, %{used: true}) + +  def update_usage!(%{invite_type: invite_type} = invite) +      when invite_type == "reusable" or invite_type == "reusable_date_limited" do +    changes = %{ +      uses: invite.uses + 1 +    } + +    changes = +      if changes.uses >= invite.max_use do +        Map.put(changes, :used, true) +      else +        changes +      end + +    update_invite!(invite, changes) +  end  end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 593ae3188..49ea73204 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -83,6 +83,22 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do      |> fix_content_map      |> fix_likes      |> fix_addressing +    |> fix_summary +  end + +  def fix_summary(%{"summary" => nil} = object) do +    object +    |> Map.put("summary", "") +  end + +  def fix_summary(%{"summary" => _} = object) do +    # summary is present, nothing to do +    object +  end + +  def fix_summary(object) do +    object +    |> Map.put("summary", "")    end    def fix_addressing_list(map, field) do diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 78bf31893..70a5b5c5d 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -5,6 +5,7 @@  defmodule Pleroma.Web.AdminAPI.AdminAPIController do    use Pleroma.Web, :controller    alias Pleroma.User +  alias Pleroma.UserInviteToken    alias Pleroma.Web.ActivityPub.Relay    alias Pleroma.Web.AdminAPI.AccountView    alias Pleroma.Web.AdminAPI.Search @@ -235,7 +236,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do      with true <-             Pleroma.Config.get([:instance, :invites_enabled]) &&               !Pleroma.Config.get([:instance, :registrations_open]), -         {:ok, invite_token} <- Pleroma.UserInviteToken.create_token(), +         {:ok, invite_token} <- UserInviteToken.create_invite(),           email <-             Pleroma.UserEmail.user_invitation_email(user, invite_token, email, params["name"]),           {:ok, _} <- Pleroma.Mailer.deliver(email) do @@ -244,11 +245,29 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do    end    @doc "Get a account registeration invite token (base64 string)" -  def get_invite_token(conn, _params) do -    {:ok, token} = Pleroma.UserInviteToken.create_token() +  def get_invite_token(conn, params) do +    options = params["invite"] || %{} +    {:ok, invite} = UserInviteToken.create_invite(options)      conn -    |> json(token.token) +    |> json(invite.token) +  end + +  @doc "Get list of created invites" +  def invites(conn, _params) do +    invites = UserInviteToken.list_invites() + +    conn +    |> json(AccountView.render("invites.json", %{invites: invites})) +  end + +  @doc "Revokes invite by token" +  def revoke_invite(conn, %{"token" => token}) do +    invite = UserInviteToken.find_by_token!(token) +    {:ok, updated_invite} = UserInviteToken.update_invite(invite, %{used: true}) + +    conn +    |> json(AccountView.render("invite.json", %{invite: updated_invite}))    end    @doc "Get a password reset token (base64 string) for given nickname" diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex index 4d6f921ef..28bb667d8 100644 --- a/lib/pleroma/web/admin_api/views/account_view.ex +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -26,4 +26,22 @@ defmodule Pleroma.Web.AdminAPI.AccountView do        "tags" => user.tags || []      }    end + +  def render("invite.json", %{invite: invite}) do +    %{ +      "id" => invite.id, +      "token" => invite.token, +      "used" => invite.used, +      "expires_at" => invite.expires_at, +      "uses" => invite.uses, +      "max_use" => invite.max_use, +      "invite_type" => invite.invite_type +    } +  end + +  def render("invites.json", %{invites: invites}) do +    %{ +      invites: render_many(invites, AccountView, "invite.json", as: :invite) +    } +  end  end diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 051db6c79..7b9f0ea06 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -12,6 +12,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do    alias Pleroma.Repo    alias Pleroma.User    alias Pleroma.Web.ActivityPub.Utils +  alias Pleroma.Web.ActivityPub.Visibility    alias Pleroma.Web.Endpoint    alias Pleroma.Web.MediaProxy @@ -335,6 +336,24 @@ defmodule Pleroma.Web.CommonAPI.Utils do    def maybe_notify_mentioned_recipients(recipients, _), do: recipients +  def maybe_notify_subscribers( +        recipients, +        %Activity{data: %{"actor" => actor, "type" => type}} = activity +      ) +      when type == "Create" do +    with %User{} = user <- User.get_cached_by_ap_id(actor) do +      subscriber_ids = +        user +        |> User.subscribers() +        |> Enum.filter(&Visibility.visible_for_user?(activity, &1)) +        |> Enum.map(& &1.ap_id) + +      recipients ++ subscriber_ids +    end +  end + +  def maybe_notify_subscribers(recipients, _), do: recipients +    def maybe_extract_mentions(%{"tag" => tag}) do      tag      |> Enum.filter(fn x -> is_map(x) end) diff --git a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex index 5462ce8be..ed082abdf 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex @@ -931,6 +931,34 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do      json(conn, %{})    end +  def subscribe(%{assigns: %{user: user}} = conn, %{"id" => id}) do +    with %User{} = subscription_target <- User.get_cached_by_id(id), +         {:ok, subscription_target} = User.subscribe(user, subscription_target) do +      conn +      |> put_view(AccountView) +      |> render("relationship.json", %{user: user, target: subscription_target}) +    else +      {:error, message} -> +        conn +        |> put_resp_content_type("application/json") +        |> send_resp(403, Jason.encode!(%{"error" => message})) +    end +  end + +  def unsubscribe(%{assigns: %{user: user}} = conn, %{"id" => id}) do +    with %User{} = subscription_target <- User.get_cached_by_id(id), +         {:ok, subscription_target} = User.unsubscribe(user, subscription_target) do +      conn +      |> put_view(AccountView) +      |> render("relationship.json", %{user: user, target: subscription_target}) +    else +      {:error, message} -> +        conn +        |> put_resp_content_type("application/json") +        |> send_resp(403, Jason.encode!(%{"error" => message})) +    end +  end +    def status_search(user, query) do      fetched =        if Regex.match?(~r/https?:/, query) do diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index b5f3bbb9d..af56c4149 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -53,6 +53,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do        blocking: User.blocks?(user, target),        muting: User.mutes?(user, target),        muting_notifications: false, +      subscribing: User.subscribed_to?(user, target),        requested: requested,        domain_blocking: false,        showing_reblogs: User.showing_reblogs?(user, target), @@ -117,13 +118,15 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do        },        # Pleroma extension -      pleroma: %{ -        confirmation_pending: user_info.confirmation_pending, -        tags: user.tags, -        is_moderator: user.info.is_moderator, -        is_admin: user.info.is_admin, -        relationship: relationship -      } +      pleroma: +        %{ +          confirmation_pending: user_info.confirmation_pending, +          tags: user.tags, +          is_moderator: user.info.is_moderator, +          is_admin: user.info.is_admin, +          relationship: relationship +        } +        |> with_notification_settings(user, opts[:for])      }    end @@ -132,4 +135,10 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do    end    defp username_from_nickname(_), do: nil + +  defp with_notification_settings(data, %User{id: user_id} = user, %User{id: user_id}) do +    Map.put(data, :notification_settings, user.info.notification_settings) +  end + +  defp with_notification_settings(data, _, _), do: data  end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index ef38fc34d..172f337db 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -168,6 +168,8 @@ defmodule Pleroma.Web.Router do      delete("/relay", AdminAPIController, :relay_unfollow)      get("/invite_token", AdminAPIController, :get_invite_token) +    get("/invites", AdminAPIController, :invites) +    post("/revoke_invite", AdminAPIController, :revoke_invite)      post("/email_invite", AdminAPIController, :email_invite)      get("/password_reset", AdminAPIController, :get_password_reset) @@ -193,6 +195,7 @@ defmodule Pleroma.Web.Router do        post("/change_password", UtilController, :change_password)        post("/delete_account", UtilController, :delete_account) +      put("/notification_settings", UtilController, :update_notificaton_settings)      end      scope [] do @@ -336,6 +339,9 @@ defmodule Pleroma.Web.Router do        post("/domain_blocks", MastodonAPIController, :block_domain)        delete("/domain_blocks", MastodonAPIController, :unblock_domain) + +      post("/pleroma/accounts/:id/subscribe", MastodonAPIController, :subscribe) +      post("/pleroma/accounts/:id/unsubscribe", MastodonAPIController, :unsubscribe)      end      scope [] do diff --git a/lib/pleroma/web/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex index 26407aebd..d066d35f5 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -286,12 +286,19 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do      emoji =        Emoji.get_all()        |> Enum.map(fn {short_code, path, tags} -> -        %{short_code => %{image_url: path, tags: String.split(tags, ",")}} +        {short_code, %{image_url: path, tags: String.split(tags, ",")}}        end) +      |> Enum.into(%{})      json(conn, emoji)    end +  def update_notificaton_settings(%{assigns: %{user: user}} = conn, params) do +    with {:ok, _} <- User.update_notification_settings(user, params) do +      json(conn, %{status: "success"}) +    end +  end +    def follow_import(conn, %{"list" => %Plug.Upload{} = listfile}) do      follow_import(conn, %{"list" => File.read!(listfile.path)})    end diff --git a/lib/pleroma/web/twitter_api/twitter_api.ex b/lib/pleroma/web/twitter_api/twitter_api.ex index 9b081a316..9e9a46cf1 100644 --- a/lib/pleroma/web/twitter_api/twitter_api.ex +++ b/lib/pleroma/web/twitter_api/twitter_api.ex @@ -129,7 +129,7 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do    end    def register_user(params) do -    token_string = params["token"] +    token = params["token"]      params = %{        nickname: params["nickname"], @@ -163,36 +163,49 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do        {:error, %{error: Jason.encode!(%{captcha: [error]})}}      else        registrations_open = Pleroma.Config.get([:instance, :registrations_open]) +      registration_process(registrations_open, params, token) +    end +  end -      # no need to query DB if registration is open -      token = -        unless registrations_open || is_nil(token_string) do -          Repo.get_by(UserInviteToken, %{token: token_string}) -        end +  defp registration_process(registration_open, params, token) +       when registration_open == false or is_nil(registration_open) do +    invite = +      unless is_nil(token) do +        Repo.get_by(UserInviteToken, %{token: token}) +      end -      cond do -        registrations_open || (!is_nil(token) && !token.used) -> -          changeset = User.register_changeset(%User{}, params) +    valid_invite? = invite && UserInviteToken.valid_invite?(invite) -          with {:ok, user} <- User.register(changeset) do -            !registrations_open && UserInviteToken.mark_as_used(token.token) +    case invite do +      nil -> +        {:error, "Invalid token"} -            {:ok, user} -          else -            {:error, changeset} -> -              errors = -                Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end) -                |> Jason.encode!() +      invite when valid_invite? -> +        UserInviteToken.update_usage!(invite) +        create_user(params) -              {:error, %{error: errors}} -          end +      _ -> +        {:error, "Expired token"} +    end +  end -        !registrations_open && is_nil(token) -> -          {:error, "Invalid token"} +  defp registration_process(true, params, _token) do +    create_user(params) +  end -        !registrations_open && token.used -> -          {:error, "Expired token"} -      end +  defp create_user(params) do +    changeset = User.register_changeset(%User{}, params) + +    case User.register(changeset) do +      {:ok, user} -> +        {:ok, user} + +      {:error, changeset} -> +        errors = +          Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end) +          |> Jason.encode!() + +        {:error, %{error: errors}}      end    end diff --git a/priv/repo/migrations/20190404050946_add_fields_to_user_invite_tokens.exs b/priv/repo/migrations/20190404050946_add_fields_to_user_invite_tokens.exs new file mode 100644 index 000000000..211a14135 --- /dev/null +++ b/priv/repo/migrations/20190404050946_add_fields_to_user_invite_tokens.exs @@ -0,0 +1,12 @@ +defmodule Pleroma.Repo.Migrations.AddFieldsToUserInviteTokens do +  use Ecto.Migration + +  def change do +    alter table(:user_invite_tokens) do +      add(:expires_at, :date) +      add(:uses, :integer, default: 0) +      add(:max_use, :integer) +      add(:invite_type, :string, default: "one_time") +    end +  end +end diff --git a/priv/repo/migrations/20190405160700_add_index_on_subscribers.exs b/priv/repo/migrations/20190405160700_add_index_on_subscribers.exs new file mode 100644 index 000000000..232f75c92 --- /dev/null +++ b/priv/repo/migrations/20190405160700_add_index_on_subscribers.exs @@ -0,0 +1,8 @@ +defmodule Pleroma.Repo.Migrations.AddIndexOnSubscribers do +  use Ecto.Migration +   +  @disable_ddl_transaction true +  def change do +    create index(:users, ["(info->'subscribers')"], name: :users_subscribers_index, using: :gin, concurrently: true) +  end +end diff --git a/test/fixtures/lambadalambda.json b/test/fixtures/lambadalambda.json new file mode 100644 index 000000000..1f09fb591 --- /dev/null +++ b/test/fixtures/lambadalambda.json @@ -0,0 +1,64 @@ +{ +  "@context": [ +    "https://www.w3.org/ns/activitystreams", +    "https://w3id.org/security/v1", +    { +      "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", +      "toot": "http://joinmastodon.org/ns#", +      "featured": { +        "@id": "toot:featured", +        "@type": "@id" +      }, +      "alsoKnownAs": { +        "@id": "as:alsoKnownAs", +        "@type": "@id" +      }, +      "movedTo": { +        "@id": "as:movedTo", +        "@type": "@id" +      }, +      "schema": "http://schema.org#", +      "PropertyValue": "schema:PropertyValue", +      "value": "schema:value", +      "Hashtag": "as:Hashtag", +      "Emoji": "toot:Emoji", +      "IdentityProof": "toot:IdentityProof", +      "focalPoint": { +        "@container": "@list", +        "@id": "toot:focalPoint" +      } +    } +  ], +  "id": "https://mastodon.social/users/lambadalambda", +  "type": "Person", +  "following": "https://mastodon.social/users/lambadalambda/following", +  "followers": "https://mastodon.social/users/lambadalambda/followers", +  "inbox": "https://mastodon.social/users/lambadalambda/inbox", +  "outbox": "https://mastodon.social/users/lambadalambda/outbox", +  "featured": "https://mastodon.social/users/lambadalambda/collections/featured", +  "preferredUsername": "lambadalambda", +  "name": "Critical Value", +  "summary": "\u003cp\u003e\u003c/p\u003e", +  "url": "https://mastodon.social/@lambadalambda", +  "manuallyApprovesFollowers": false, +  "publicKey": { +    "id": "https://mastodon.social/users/lambadalambda#main-key", +    "owner": "https://mastodon.social/users/lambadalambda", +    "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw0P/Tq4gb4G/QVuMGbJo\nC/AfMNcv+m7NfrlOwkVzcU47jgESuYI4UtJayissCdBycHUnfVUd9qol+eznSODz\nCJhfJloqEIC+aSnuEPGA0POtWad6DU0E6/Ho5zQn5WAWUwbRQqowbrsm/GHo2+3v\neR5jGenwA6sYhINg/c3QQbksyV0uJ20Umyx88w8+TJuv53twOfmyDWuYNoQ3y5cc\nHKOZcLHxYOhvwg3PFaGfFHMFiNmF40dTXt9K96r7sbzc44iLD+VphbMPJEjkMuf8\nPGEFOBzy8pm3wJZw2v32RNW2VESwMYyqDzwHXGSq1a73cS7hEnc79gXlELsK04L9\nQQIDAQAB\n-----END PUBLIC KEY-----\n" +  }, +  "tag": [], +  "attachment": [], +  "endpoints": { +    "sharedInbox": "https://mastodon.social/inbox" +  }, +  "icon": { +    "type": "Image", +    "mediaType": "image/gif", +    "url": "https://files.mastodon.social/accounts/avatars/000/000/264/original/1429214160519.gif" +  }, +  "image": { +    "type": "Image", +    "mediaType": "image/gif", +    "url": "https://files.mastodon.social/accounts/headers/000/000/264/original/28b26104f83747d2.gif" +  } +} diff --git a/test/notification_test.exs b/test/notification_test.exs index 12b4292aa..c3db77b6c 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -29,6 +29,18 @@ defmodule Pleroma.NotificationTest do        assert notification.activity_id == activity.id        assert other_notification.activity_id == activity.id      end + +    test "it creates a notification for subscribed users" do +      user = insert(:user) +      subscriber = insert(:user) + +      User.subscribe(subscriber, user) + +      {:ok, status} = TwitterAPI.create_status(user, %{"status" => "Akariiiin"}) +      {:ok, [notification]} = Notification.create_notifications(status) + +      assert notification.user_id == subscriber.id +    end    end    describe "create_notification" do @@ -41,6 +53,75 @@ defmodule Pleroma.NotificationTest do        assert nil == Notification.create_notification(activity, user)      end +    test "it doesn't create a notificatin for the user if the user mutes the activity author" do +      muter = insert(:user) +      muted = insert(:user) +      {:ok, _} = User.mute(muter, muted) +      muter = Repo.get(User, muter.id) +      {:ok, activity} = CommonAPI.post(muted, %{"status" => "Hi @#{muter.nickname}"}) + +      assert nil == Notification.create_notification(activity, muter) +    end + +    test "it doesn't create a notification for an activity from a muted thread" do +      muter = insert(:user) +      other_user = insert(:user) +      {:ok, activity} = CommonAPI.post(muter, %{"status" => "hey"}) +      CommonAPI.add_mute(muter, activity) + +      {:ok, activity} = +        CommonAPI.post(other_user, %{ +          "status" => "Hi @#{muter.nickname}", +          "in_reply_to_status_id" => activity.id +        }) + +      assert nil == Notification.create_notification(activity, muter) +    end + +    test "it disables notifications from people on remote instances" do +      user = insert(:user, info: %{notification_settings: %{"remote" => false}}) +      other_user = insert(:user) + +      create_activity = %{ +        "@context" => "https://www.w3.org/ns/activitystreams", +        "type" => "Create", +        "to" => ["https://www.w3.org/ns/activitystreams#Public"], +        "actor" => other_user.ap_id, +        "object" => %{ +          "type" => "Note", +          "content" => "Hi @#{user.nickname}", +          "attributedTo" => other_user.ap_id +        } +      } + +      {:ok, %{local: false} = activity} = Transmogrifier.handle_incoming(create_activity) +      assert nil == Notification.create_notification(activity, user) +    end + +    test "it disables notifications from people on the local instance" do +      user = insert(:user, info: %{notification_settings: %{"local" => false}}) +      other_user = insert(:user) +      {:ok, activity} = CommonAPI.post(other_user, %{"status" => "hey @#{user.nickname}"}) +      assert nil == Notification.create_notification(activity, user) +    end + +    test "it disables notifications from followers" do +      follower = insert(:user) +      followed = insert(:user, info: %{notification_settings: %{"followers" => false}}) +      User.follow(follower, followed) +      {:ok, activity} = CommonAPI.post(follower, %{"status" => "hey @#{followed.nickname}"}) +      assert nil == Notification.create_notification(activity, followed) +    end + +    test "it disables notifications from people the user follows" do +      follower = insert(:user, info: %{notification_settings: %{"follows" => false}}) +      followed = insert(:user) +      User.follow(follower, followed) +      follower = Repo.get(User, follower.id) +      {:ok, activity} = CommonAPI.post(followed, %{"status" => "hey @#{follower.nickname}"}) +      assert nil == Notification.create_notification(activity, follower) +    end +      test "it doesn't create a notification for user if he is the activity author" do        activity = insert(:note_activity)        author = User.get_by_ap_id(activity.data["actor"]) @@ -84,6 +165,28 @@ defmodule Pleroma.NotificationTest do        {:ok, dupe} = TwitterAPI.repeat(user, status.id)        assert nil == Notification.create_notification(dupe, retweeted_user)      end + +    test "it doesn't create duplicate notifications for follow+subscribed users" do +      user = insert(:user) +      subscriber = insert(:user) + +      {:ok, _, _, _} = TwitterAPI.follow(subscriber, %{"user_id" => user.id}) +      User.subscribe(subscriber, user) +      {:ok, status} = TwitterAPI.create_status(user, %{"status" => "Akariiiin"}) +      {:ok, [_notif]} = Notification.create_notifications(status) +    end + +    test "it doesn't create subscription notifications if the recipient cannot see the status" do +      user = insert(:user) +      subscriber = insert(:user) + +      User.subscribe(subscriber, user) + +      {:ok, status} = +        TwitterAPI.create_status(user, %{"status" => "inwisible", "visibility" => "direct"}) + +      assert {:ok, []} == Notification.create_notifications(status) +    end    end    describe "get notification" do diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex index d3b547d91..5b355bfe6 100644 --- a/test/support/http_request_mock.ex +++ b/test/support/http_request_mock.ex @@ -716,6 +716,10 @@ defmodule HttpRequestMock do      {:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/lambadalambda.atom")}}    end +  def get("https://mastodon.social/users/lambadalambda", _, _, _) do +    {:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/lambadalambda.json")}} +  end +    def get("https://social.heldscal.la/user/23211", _, _, Accept: "application/activity+json") do      {:ok, Tesla.Mock.json(%{"id" => "https://social.heldscal.la/user/23211"}, status: 200)}    end diff --git a/test/tasks/user_test.exs b/test/tasks/user_test.exs index 1030bd555..242265da5 100644 --- a/test/tasks/user_test.exs +++ b/test/tasks/user_test.exs @@ -245,7 +245,87 @@ defmodule Mix.Tasks.Pleroma.UserTest do               end) =~ "http"        assert_received {:mix_shell, :info, [message]} -      assert message =~ "Generated" +      assert message =~ "Generated user invite token one time" +    end + +    test "token is generated with expires_at" do +      assert capture_io(fn -> +               Mix.Tasks.Pleroma.User.run([ +                 "invite", +                 "--expires-at", +                 Date.to_string(Date.utc_today()) +               ]) +             end) + +      assert_received {:mix_shell, :info, [message]} +      assert message =~ "Generated user invite token date limited" +    end + +    test "token is generated with max use" do +      assert capture_io(fn -> +               Mix.Tasks.Pleroma.User.run([ +                 "invite", +                 "--max-use", +                 "5" +               ]) +             end) + +      assert_received {:mix_shell, :info, [message]} +      assert message =~ "Generated user invite token reusable" +    end + +    test "token is generated with max use and expires date" do +      assert capture_io(fn -> +               Mix.Tasks.Pleroma.User.run([ +                 "invite", +                 "--max-use", +                 "5", +                 "--expires-at", +                 Date.to_string(Date.utc_today()) +               ]) +             end) + +      assert_received {:mix_shell, :info, [message]} +      assert message =~ "Generated user invite token reusable date limited" +    end +  end + +  describe "running invites" do +    test "invites are listed" do +      {:ok, invite} = Pleroma.UserInviteToken.create_invite() + +      {:ok, invite2} = +        Pleroma.UserInviteToken.create_invite(%{expires_at: Date.utc_today(), max_use: 15}) + +      # assert capture_io(fn -> +      Mix.Tasks.Pleroma.User.run([ +        "invites" +      ]) + +      #  end) + +      assert_received {:mix_shell, :info, [message]} +      assert_received {:mix_shell, :info, [message2]} +      assert_received {:mix_shell, :info, [message3]} +      assert message =~ "Invites list:" +      assert message2 =~ invite.invite_type +      assert message3 =~ invite2.invite_type +    end +  end + +  describe "running revoke_invite" do +    test "invite is revoked" do +      {:ok, invite} = Pleroma.UserInviteToken.create_invite(%{expires_at: Date.utc_today()}) + +      assert capture_io(fn -> +               Mix.Tasks.Pleroma.User.run([ +                 "revoke_invite", +                 invite.token +               ]) +             end) + +      assert_received {:mix_shell, :info, [message]} +      assert message =~ "Invite for token #{invite.token} was revoked."      end    end diff --git a/test/user_invite_token_test.exs b/test/user_invite_token_test.exs new file mode 100644 index 000000000..276788254 --- /dev/null +++ b/test/user_invite_token_test.exs @@ -0,0 +1,96 @@ +defmodule Pleroma.UserInviteTokenTest do +  use ExUnit.Case, async: true +  use Pleroma.DataCase +  alias Pleroma.UserInviteToken + +  describe "valid_invite?/1 one time invites" do +    setup do +      invite = %UserInviteToken{invite_type: "one_time"} + +      {:ok, invite: invite} +    end + +    test "not used returns true", %{invite: invite} do +      invite = %{invite | used: false} +      assert UserInviteToken.valid_invite?(invite) +    end + +    test "used  returns false", %{invite: invite} do +      invite = %{invite | used: true} +      refute UserInviteToken.valid_invite?(invite) +    end +  end + +  describe "valid_invite?/1 reusable invites" do +    setup do +      invite = %UserInviteToken{ +        invite_type: "reusable", +        max_use: 5 +      } + +      {:ok, invite: invite} +    end + +    test "with less uses then max use returns true", %{invite: invite} do +      invite = %{invite | uses: 4} +      assert UserInviteToken.valid_invite?(invite) +    end + +    test "with equal or more uses then max use returns false", %{invite: invite} do +      invite = %{invite | uses: 5} + +      refute UserInviteToken.valid_invite?(invite) + +      invite = %{invite | uses: 6} + +      refute UserInviteToken.valid_invite?(invite) +    end +  end + +  describe "valid_token?/1 date limited invites" do +    setup do +      invite = %UserInviteToken{invite_type: "date_limited"} +      {:ok, invite: invite} +    end + +    test "expires today returns true", %{invite: invite} do +      invite = %{invite | expires_at: Date.utc_today()} +      assert UserInviteToken.valid_invite?(invite) +    end + +    test "expires yesterday returns false", %{invite: invite} do +      invite = %{invite | expires_at: Date.add(Date.utc_today(), -1)} +      invite = Repo.insert!(invite) +      refute UserInviteToken.valid_invite?(invite) +    end +  end + +  describe "valid_token?/1 reusable date limited invites" do +    setup do +      invite = %UserInviteToken{invite_type: "reusable_date_limited", max_use: 5} +      {:ok, invite: invite} +    end + +    test "not overdue date and less uses returns true", %{invite: invite} do +      invite = %{invite | expires_at: Date.utc_today(), uses: 4} +      assert UserInviteToken.valid_invite?(invite) +    end + +    test "overdue date and less uses returns false", %{invite: invite} do +      invite = %{invite | expires_at: Date.add(Date.utc_today(), -1)} +      invite = Repo.insert!(invite) +      refute UserInviteToken.valid_invite?(invite) +    end + +    test "not overdue date with more uses returns false", %{invite: invite} do +      invite = %{invite | expires_at: Date.utc_today(), uses: 5} +      refute UserInviteToken.valid_invite?(invite) +    end + +    test "overdue date with more uses returns false", %{invite: invite} do +      invite = %{invite | expires_at: Date.add(Date.utc_today(), -1), uses: 5} +      invite = Repo.insert!(invite) +      refute UserInviteToken.valid_invite?(invite) +    end +  end +end diff --git a/test/user_test.exs b/test/user_test.exs index 4f98af683..d2167a970 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -146,6 +146,15 @@ defmodule Pleroma.UserTest do      {:error, _} = User.follow(blockee, blocker)    end +  test "can't subscribe to a user who blocked us" do +    blocker = insert(:user) +    blocked = insert(:user) + +    {:ok, blocker} = User.block(blocker, blocked) + +    {:error, _} = User.subscribe(blocked, blocker) +  end +    test "local users do not automatically follow local locked accounts" do      follower = insert(:user, info: %{locked: true})      followed = insert(:user, info: %{locked: true}) @@ -729,6 +738,22 @@ defmodule Pleroma.UserTest do        refute User.following?(blocker, blocked)        refute User.following?(blocked, blocker)      end + +    test "blocks tear down blocked->blocker subscription relationships" do +      blocker = insert(:user) +      blocked = insert(:user) + +      {:ok, blocker} = User.subscribe(blocked, blocker) + +      assert User.subscribed_to?(blocked, blocker) +      refute User.subscribed_to?(blocker, blocked) + +      {:ok, blocker} = User.block(blocker, blocked) + +      assert User.blocks?(blocker, blocked) +      refute User.subscribed_to?(blocker, blocked) +      refute User.subscribed_to?(blocked, blocker) +    end    end    describe "domain blocking" do diff --git a/test/web/admin_api/admin_api_controller_test.exs b/test/web/admin_api/admin_api_controller_test.exs index 7b1f6d53a..ca7794d70 100644 --- a/test/web/admin_api/admin_api_controller_test.exs +++ b/test/web/admin_api/admin_api_controller_test.exs @@ -6,6 +6,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do    use Pleroma.Web.ConnCase    alias Pleroma.User +  alias Pleroma.UserInviteToken    import Pleroma.Factory    describe "/api/pleroma/admin/user" do @@ -648,4 +649,136 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do                 "tags" => []               }    end + +  describe "GET /api/pleroma/admin/invite_token" do +    test "without options" do +      admin = insert(:user, info: %{is_admin: true}) + +      conn = +        build_conn() +        |> assign(:user, admin) +        |> get("/api/pleroma/admin/invite_token") + +      token = json_response(conn, 200) +      invite = UserInviteToken.find_by_token!(token) +      refute invite.used +      refute invite.expires_at +      refute invite.max_use +      assert invite.invite_type == "one_time" +    end + +    test "with expires_at" do +      admin = insert(:user, info: %{is_admin: true}) + +      conn = +        build_conn() +        |> assign(:user, admin) +        |> get("/api/pleroma/admin/invite_token", %{ +          "invite" => %{"expires_at" => Date.to_string(Date.utc_today())} +        }) + +      token = json_response(conn, 200) +      invite = UserInviteToken.find_by_token!(token) + +      refute invite.used +      assert invite.expires_at == Date.utc_today() +      refute invite.max_use +      assert invite.invite_type == "date_limited" +    end + +    test "with max_use" do +      admin = insert(:user, info: %{is_admin: true}) + +      conn = +        build_conn() +        |> assign(:user, admin) +        |> get("/api/pleroma/admin/invite_token", %{ +          "invite" => %{"max_use" => 150} +        }) + +      token = json_response(conn, 200) +      invite = UserInviteToken.find_by_token!(token) +      refute invite.used +      refute invite.expires_at +      assert invite.max_use == 150 +      assert invite.invite_type == "reusable" +    end + +    test "with max use and expires_at" do +      admin = insert(:user, info: %{is_admin: true}) + +      conn = +        build_conn() +        |> assign(:user, admin) +        |> get("/api/pleroma/admin/invite_token", %{ +          "invite" => %{"max_use" => 150, "expires_at" => Date.to_string(Date.utc_today())} +        }) + +      token = json_response(conn, 200) +      invite = UserInviteToken.find_by_token!(token) +      refute invite.used +      assert invite.expires_at == Date.utc_today() +      assert invite.max_use == 150 +      assert invite.invite_type == "reusable_date_limited" +    end +  end + +  describe "GET /api/pleroma/admin/invites" do +    test "no invites" do +      admin = insert(:user, info: %{is_admin: true}) + +      conn = +        build_conn() +        |> assign(:user, admin) +        |> get("/api/pleroma/admin/invites") + +      assert json_response(conn, 200) == %{"invites" => []} +    end + +    test "with invite" do +      admin = insert(:user, info: %{is_admin: true}) +      {:ok, invite} = UserInviteToken.create_invite() + +      conn = +        build_conn() +        |> assign(:user, admin) +        |> get("/api/pleroma/admin/invites") + +      assert json_response(conn, 200) == %{ +               "invites" => [ +                 %{ +                   "expires_at" => nil, +                   "id" => invite.id, +                   "invite_type" => "one_time", +                   "max_use" => nil, +                   "token" => invite.token, +                   "used" => false, +                   "uses" => 0 +                 } +               ] +             } +    end +  end + +  describe "POST /api/pleroma/admin/revoke_invite" do +    test "with token" do +      admin = insert(:user, info: %{is_admin: true}) +      {:ok, invite} = UserInviteToken.create_invite() + +      conn = +        build_conn() +        |> assign(:user, admin) +        |> post("/api/pleroma/admin/revoke_invite", %{"token" => invite.token}) + +      assert json_response(conn, 200) == %{ +               "expires_at" => nil, +               "id" => invite.id, +               "invite_type" => "one_time", +               "max_use" => nil, +               "token" => invite.token, +               "used" => true, +               "uses" => 0 +             } +    end +  end  end diff --git a/test/web/mastodon_api/account_view_test.exs b/test/web/mastodon_api/account_view_test.exs index 6dc60afe9..d7487bed9 100644 --- a/test/web/mastodon_api/account_view_test.exs +++ b/test/web/mastodon_api/account_view_test.exs @@ -71,6 +71,20 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do      assert expected == AccountView.render("account.json", %{user: user})    end +  test "Represent the user account for the account owner" do +    user = insert(:user) + +    notification_settings = %{ +      "remote" => true, +      "local" => true, +      "followers" => true, +      "follows" => true +    } + +    assert %{pleroma: %{notification_settings: ^notification_settings}} = +             AccountView.render("account.json", %{user: user, for: user}) +  end +    test "Represent a Service(bot) account" do      user =        insert(:user, %{ @@ -142,6 +156,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do        blocking: true,        muting: false,        muting_notifications: false, +      subscribing: false,        requested: false,        domain_blocking: false,        showing_reblogs: true, @@ -198,6 +213,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do            following: false,            followed_by: false,            blocking: true, +          subscribing: false,            muting: false,            muting_notifications: false,            requested: false, diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index 24e258d66..614b950ba 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -1556,6 +1556,25 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIControllerTest do      assert %{"id" => _id, "muting" => false} = json_response(conn, 200)    end +  test "subscribing / unsubscribing to a user", %{conn: conn} do +    user = insert(:user) +    subscription_target = insert(:user) + +    conn = +      conn +      |> assign(:user, user) +      |> post("/api/v1/pleroma/accounts/#{subscription_target.id}/subscribe") + +    assert %{"id" => _id, "subscribing" => true} = json_response(conn, 200) + +    conn = +      build_conn() +      |> assign(:user, user) +      |> post("/api/v1/pleroma/accounts/#{subscription_target.id}/unsubscribe") + +    assert %{"id" => _id, "subscribing" => false} = json_response(conn, 200) +  end +    test "getting a list of mutes", %{conn: conn} do      user = insert(:user)      other_user = insert(:user) diff --git a/test/web/twitter_api/twitter_api_test.exs b/test/web/twitter_api/twitter_api_test.exs index 24e46408c..b61e2a24c 100644 --- a/test/web/twitter_api/twitter_api_test.exs +++ b/test/web/twitter_api/twitter_api_test.exs @@ -16,6 +16,11 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do    import Pleroma.Factory +  setup_all do +    Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) +    :ok +  end +    test "create a status" do      user = insert(:user)      mentioned_user = insert(:user, %{nickname: "shp", ap_id: "shp"}) @@ -299,7 +304,6 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do               UserView.render("show.json", %{user: fetched_user})    end -  @moduletag skip: "needs 'account_activation_required: true' in config"    test "it sends confirmation email if :account_activation_required is specified in instance config" do      setting = Pleroma.Config.get([:instance, :account_activation_required]) @@ -362,68 +366,313 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do      assert user2.bio == expected_text    end -  @moduletag skip: "needs 'registrations_open: false' in config" -  test "it registers a new user via invite token and returns the user." do -    {:ok, token} = UserInviteToken.create_token() +  describe "register with one time token" do +    setup do +      setting = Pleroma.Config.get([:instance, :registrations_open]) -    data = %{ -      "nickname" => "vinny", -      "email" => "pasta@pizza.vs", -      "fullname" => "Vinny Vinesauce", -      "bio" => "streamer", -      "password" => "hiptofbees", -      "confirm" => "hiptofbees", -      "token" => token.token -    } +      if setting do +        Pleroma.Config.put([:instance, :registrations_open], false) +        on_exit(fn -> Pleroma.Config.put([:instance, :registrations_open], setting) end) +      end -    {:ok, user} = TwitterAPI.register_user(data) +      :ok +    end -    fetched_user = User.get_by_nickname("vinny") -    token = Repo.get_by(UserInviteToken, token: token.token) +    test "returns user on success" do +      {:ok, invite} = UserInviteToken.create_invite() -    assert token.used == true +      data = %{ +        "nickname" => "vinny", +        "email" => "pasta@pizza.vs", +        "fullname" => "Vinny Vinesauce", +        "bio" => "streamer", +        "password" => "hiptofbees", +        "confirm" => "hiptofbees", +        "token" => invite.token +      } -    assert UserView.render("show.json", %{user: user}) == -             UserView.render("show.json", %{user: fetched_user}) +      {:ok, user} = TwitterAPI.register_user(data) + +      fetched_user = User.get_by_nickname("vinny") +      invite = Repo.get_by(UserInviteToken, token: invite.token) + +      assert invite.used == true + +      assert UserView.render("show.json", %{user: user}) == +               UserView.render("show.json", %{user: fetched_user}) +    end + +    test "returns error on invalid token" do +      data = %{ +        "nickname" => "GrimReaper", +        "email" => "death@reapers.afterlife", +        "fullname" => "Reaper Grim", +        "bio" => "Your time has come", +        "password" => "scythe", +        "confirm" => "scythe", +        "token" => "DudeLetMeInImAFairy" +      } + +      {:error, msg} = TwitterAPI.register_user(data) + +      assert msg == "Invalid token" +      refute User.get_by_nickname("GrimReaper") +    end + +    test "returns error on expired token" do +      {:ok, invite} = UserInviteToken.create_invite() +      UserInviteToken.update_invite!(invite, used: true) + +      data = %{ +        "nickname" => "GrimReaper", +        "email" => "death@reapers.afterlife", +        "fullname" => "Reaper Grim", +        "bio" => "Your time has come", +        "password" => "scythe", +        "confirm" => "scythe", +        "token" => invite.token +      } + +      {:error, msg} = TwitterAPI.register_user(data) + +      assert msg == "Expired token" +      refute User.get_by_nickname("GrimReaper") +    end    end -  @moduletag skip: "needs 'registrations_open: false' in config" -  test "it returns an error if invalid token submitted" do -    data = %{ -      "nickname" => "GrimReaper", -      "email" => "death@reapers.afterlife", -      "fullname" => "Reaper Grim", -      "bio" => "Your time has come", -      "password" => "scythe", -      "confirm" => "scythe", -      "token" => "DudeLetMeInImAFairy" -    } +  describe "registers with date limited token" do +    setup do +      setting = Pleroma.Config.get([:instance, :registrations_open]) + +      if setting do +        Pleroma.Config.put([:instance, :registrations_open], false) +        on_exit(fn -> Pleroma.Config.put([:instance, :registrations_open], setting) end) +      end + +      data = %{ +        "nickname" => "vinny", +        "email" => "pasta@pizza.vs", +        "fullname" => "Vinny Vinesauce", +        "bio" => "streamer", +        "password" => "hiptofbees", +        "confirm" => "hiptofbees" +      } + +      check_fn = fn invite -> +        data = Map.put(data, "token", invite.token) +        {:ok, user} = TwitterAPI.register_user(data) +        fetched_user = User.get_by_nickname("vinny") + +        assert UserView.render("show.json", %{user: user}) == +                 UserView.render("show.json", %{user: fetched_user}) +      end + +      {:ok, data: data, check_fn: check_fn} +    end + +    test "returns user on success", %{check_fn: check_fn} do +      {:ok, invite} = UserInviteToken.create_invite(%{expires_at: Date.utc_today()}) + +      check_fn.(invite) + +      invite = Repo.get_by(UserInviteToken, token: invite.token) + +      refute invite.used +    end -    {:error, msg} = TwitterAPI.register_user(data) +    test "returns user on token which expired tomorrow", %{check_fn: check_fn} do +      {:ok, invite} = UserInviteToken.create_invite(%{expires_at: Date.add(Date.utc_today(), 1)}) -    assert msg == "Invalid token" -    refute User.get_by_nickname("GrimReaper") +      check_fn.(invite) + +      invite = Repo.get_by(UserInviteToken, token: invite.token) + +      refute invite.used +    end + +    test "returns an error on overdue date", %{data: data} do +      {:ok, invite} = UserInviteToken.create_invite(%{expires_at: Date.add(Date.utc_today(), -1)}) + +      data = Map.put(data, "token", invite.token) + +      {:error, msg} = TwitterAPI.register_user(data) + +      assert msg == "Expired token" +      refute User.get_by_nickname("vinny") +      invite = Repo.get_by(UserInviteToken, token: invite.token) + +      refute invite.used +    end    end -  @moduletag skip: "needs 'registrations_open: false' in config" -  test "it returns an error if expired token submitted" do -    {:ok, token} = UserInviteToken.create_token() -    UserInviteToken.mark_as_used(token.token) +  describe "registers with reusable token" do +    setup do +      setting = Pleroma.Config.get([:instance, :registrations_open]) -    data = %{ -      "nickname" => "GrimReaper", -      "email" => "death@reapers.afterlife", -      "fullname" => "Reaper Grim", -      "bio" => "Your time has come", -      "password" => "scythe", -      "confirm" => "scythe", -      "token" => token.token -    } +      if setting do +        Pleroma.Config.put([:instance, :registrations_open], false) +        on_exit(fn -> Pleroma.Config.put([:instance, :registrations_open], setting) end) +      end + +      :ok +    end + +    test "returns user on success, after him registration fails" do +      {:ok, invite} = UserInviteToken.create_invite(%{max_use: 100}) + +      UserInviteToken.update_invite!(invite, uses: 99) + +      data = %{ +        "nickname" => "vinny", +        "email" => "pasta@pizza.vs", +        "fullname" => "Vinny Vinesauce", +        "bio" => "streamer", +        "password" => "hiptofbees", +        "confirm" => "hiptofbees", +        "token" => invite.token +      } + +      {:ok, user} = TwitterAPI.register_user(data) +      fetched_user = User.get_by_nickname("vinny") +      invite = Repo.get_by(UserInviteToken, token: invite.token) + +      assert invite.used == true + +      assert UserView.render("show.json", %{user: user}) == +               UserView.render("show.json", %{user: fetched_user}) + +      data = %{ +        "nickname" => "GrimReaper", +        "email" => "death@reapers.afterlife", +        "fullname" => "Reaper Grim", +        "bio" => "Your time has come", +        "password" => "scythe", +        "confirm" => "scythe", +        "token" => invite.token +      } + +      {:error, msg} = TwitterAPI.register_user(data) + +      assert msg == "Expired token" +      refute User.get_by_nickname("GrimReaper") +    end +  end + +  describe "registers with reusable date limited token" do +    setup do +      setting = Pleroma.Config.get([:instance, :registrations_open]) + +      if setting do +        Pleroma.Config.put([:instance, :registrations_open], false) +        on_exit(fn -> Pleroma.Config.put([:instance, :registrations_open], setting) end) +      end + +      :ok +    end + +    test "returns user on success" do +      {:ok, invite} = UserInviteToken.create_invite(%{expires_at: Date.utc_today(), max_use: 100}) + +      data = %{ +        "nickname" => "vinny", +        "email" => "pasta@pizza.vs", +        "fullname" => "Vinny Vinesauce", +        "bio" => "streamer", +        "password" => "hiptofbees", +        "confirm" => "hiptofbees", +        "token" => invite.token +      } + +      {:ok, user} = TwitterAPI.register_user(data) +      fetched_user = User.get_by_nickname("vinny") +      invite = Repo.get_by(UserInviteToken, token: invite.token) + +      refute invite.used + +      assert UserView.render("show.json", %{user: user}) == +               UserView.render("show.json", %{user: fetched_user}) +    end + +    test "error after max uses" do +      {:ok, invite} = UserInviteToken.create_invite(%{expires_at: Date.utc_today(), max_use: 100}) + +      UserInviteToken.update_invite!(invite, uses: 99) + +      data = %{ +        "nickname" => "vinny", +        "email" => "pasta@pizza.vs", +        "fullname" => "Vinny Vinesauce", +        "bio" => "streamer", +        "password" => "hiptofbees", +        "confirm" => "hiptofbees", +        "token" => invite.token +      } + +      {:ok, user} = TwitterAPI.register_user(data) +      fetched_user = User.get_by_nickname("vinny") +      invite = Repo.get_by(UserInviteToken, token: invite.token) +      assert invite.used == true + +      assert UserView.render("show.json", %{user: user}) == +               UserView.render("show.json", %{user: fetched_user}) + +      data = %{ +        "nickname" => "GrimReaper", +        "email" => "death@reapers.afterlife", +        "fullname" => "Reaper Grim", +        "bio" => "Your time has come", +        "password" => "scythe", +        "confirm" => "scythe", +        "token" => invite.token +      } + +      {:error, msg} = TwitterAPI.register_user(data) + +      assert msg == "Expired token" +      refute User.get_by_nickname("GrimReaper") +    end -    {:error, msg} = TwitterAPI.register_user(data) +    test "returns error on overdue date" do +      {:ok, invite} = +        UserInviteToken.create_invite(%{expires_at: Date.add(Date.utc_today(), -1), max_use: 100}) -    assert msg == "Expired token" -    refute User.get_by_nickname("GrimReaper") +      data = %{ +        "nickname" => "GrimReaper", +        "email" => "death@reapers.afterlife", +        "fullname" => "Reaper Grim", +        "bio" => "Your time has come", +        "password" => "scythe", +        "confirm" => "scythe", +        "token" => invite.token +      } + +      {:error, msg} = TwitterAPI.register_user(data) + +      assert msg == "Expired token" +      refute User.get_by_nickname("GrimReaper") +    end + +    test "returns error on with overdue date and after max" do +      {:ok, invite} = +        UserInviteToken.create_invite(%{expires_at: Date.add(Date.utc_today(), -1), max_use: 100}) + +      UserInviteToken.update_invite!(invite, uses: 100) + +      data = %{ +        "nickname" => "GrimReaper", +        "email" => "death@reapers.afterlife", +        "fullname" => "Reaper Grim", +        "bio" => "Your time has come", +        "password" => "scythe", +        "confirm" => "scythe", +        "token" => invite.token +      } + +      {:error, msg} = TwitterAPI.register_user(data) + +      assert msg == "Expired token" +      refute User.get_by_nickname("GrimReaper") +    end    end    test "it returns the error on registration problems" do diff --git a/test/web/twitter_api/util_controller_test.exs b/test/web/twitter_api/util_controller_test.exs index 410f20f87..a4b3d651a 100644 --- a/test/web/twitter_api/util_controller_test.exs +++ b/test/web/twitter_api/util_controller_test.exs @@ -3,6 +3,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do    alias Pleroma.Notification    alias Pleroma.Repo +  alias Pleroma.User    alias Pleroma.Web.CommonAPI    import Pleroma.Factory @@ -79,6 +80,26 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do      end    end +  describe "PUT /api/pleroma/notification_settings" do +    test "it updates notification settings", %{conn: conn} do +      user = insert(:user) + +      conn +      |> assign(:user, user) +      |> put("/api/pleroma/notification_settings", %{ +        "remote" => false, +        "followers" => false, +        "bar" => 1 +      }) +      |> json_response(:ok) + +      user = Repo.get(User, user.id) + +      assert %{"remote" => false, "local" => true, "followers" => false, "follows" => true} == +               user.info.notification_settings +    end +  end +    describe "GET /api/statusnet/config.json" do      test "returns the state of safe_dm_mentions flag", %{conn: conn} do        option = Pleroma.Config.get([:instance, :safe_dm_mentions]) @@ -172,22 +193,19 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do    describe "/api/pleroma/emoji" do      test "returns json with custom emoji with tags", %{conn: conn} do -      [emoji | _body] = +      emoji =          conn          |> get("/api/pleroma/emoji")          |> json_response(200) -      [key] = Map.keys(emoji) - -      %{ -        ^key => %{ -          "image_url" => url, -          "tags" => tags -        } -      } = emoji - -      assert is_binary(url) -      assert is_list(tags) +      assert Enum.all?(emoji, fn +               {_key, +                %{ +                  "image_url" => url, +                  "tags" => tags +                }} -> +                 is_binary(url) and is_list(tags) +             end)      end    end  | 
