From 371a4aed2ca9f6926e49f6791c8b4d14292d20e5 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 13 Apr 2019 17:40:42 +0700 Subject: [PATCH 01/56] Add User.Info.email_notifications --- lib/pleroma/user/info.ex | 27 +++++++++++++++++++ .../20190412052952_add_user_info_fields.exs | 20 ++++++++++++++ test/user_info_test.exs | 24 +++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 priv/repo/migrations/20190412052952_add_user_info_fields.exs create mode 100644 test/user_info_test.exs diff --git a/lib/pleroma/user/info.ex b/lib/pleroma/user/info.ex index 5afa7988c..194dd5581 100644 --- a/lib/pleroma/user/info.ex +++ b/lib/pleroma/user/info.ex @@ -8,6 +8,8 @@ defmodule Pleroma.User.Info do alias Pleroma.User.Info + @type t :: %__MODULE__{} + embedded_schema do field(:banner, :map, default: %{}) field(:background, :map, default: %{}) @@ -40,6 +42,7 @@ defmodule Pleroma.User.Info do field(:hide_follows, :boolean, default: false) field(:pinned_activities, {:array, :string}, default: []) field(:flavour, :string, default: nil) + field(:email_notifications, :map, default: %{"digest" => true}) field(:notification_settings, :map, default: %{"remote" => true, "local" => true, "followers" => true, "follows" => true} @@ -75,6 +78,30 @@ def update_notification_settings(info, settings) do |> validate_required([:notification_settings]) end + @doc """ + Update email notifications in the given User.Info struct. + + Examples: + + iex> update_email_notifications(%Pleroma.User.Info{email_notifications: %{"digest" => false}}, %{"digest" => true}) + %Pleroma.User.Info{email_notifications: %{"digest" => true}} + + """ + @spec update_email_notifications(t(), map()) :: Ecto.Changeset.t() + def update_email_notifications(info, settings) do + email_notifications = + info.email_notifications + |> Map.merge(settings) + |> Map.take(["digest"]) + + params = %{email_notifications: email_notifications} + fields = [:email_notifications] + + info + |> cast(params, fields) + |> validate_required(fields) + end + def add_to_note_count(info, number) do set_note_count(info, info.note_count + number) end diff --git a/priv/repo/migrations/20190412052952_add_user_info_fields.exs b/priv/repo/migrations/20190412052952_add_user_info_fields.exs new file mode 100644 index 000000000..203d0fc3b --- /dev/null +++ b/priv/repo/migrations/20190412052952_add_user_info_fields.exs @@ -0,0 +1,20 @@ +defmodule Pleroma.Repo.Migrations.AddEmailNotificationsToUserInfo do + use Ecto.Migration + + def up do + execute(" + UPDATE users + SET info = info || '{ + \"email_notifications\": { + \"digest\": true + } + }'") + end + + def down do + execute(" + UPDATE users + SET info = info - 'email_notifications' + ") + end +end diff --git a/test/user_info_test.exs b/test/user_info_test.exs new file mode 100644 index 000000000..2d795594e --- /dev/null +++ b/test/user_info_test.exs @@ -0,0 +1,24 @@ +defmodule Pleroma.UserInfoTest do + alias Pleroma.Repo + alias Pleroma.User.Info + + use Pleroma.DataCase + + import Pleroma.Factory + + describe "update_email_notifications/2" do + setup do + user = insert(:user, %{info: %{email_notifications: %{"digest" => true}}}) + + {:ok, user: user} + end + + test "Notifications are updated", %{user: user} do + true = user.info.email_notifications["digest"] + changeset = Info.update_email_notifications(user.info, %{"digest" => false}) + assert changeset.valid? + {:ok, result} = Ecto.Changeset.apply_action(changeset, :insert) + assert result.email_notifications["digest"] == false + end + end +end From dc21181f6504b55afa68de63f170fcb0f1084a6b Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sun, 14 Apr 2019 22:29:05 +0700 Subject: [PATCH 02/56] Update updated_at field on notification read --- lib/pleroma/notification.ex | 5 ++++- test/notification_test.exs | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index b357d5399..29845b9da 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -58,7 +58,10 @@ def set_read_up_to(%{id: user_id} = _user, id) do where: n.user_id == ^user_id, where: n.id <= ^id, update: [ - set: [seen: true] + set: [ + seen: true, + updated_at: ^NaiveDateTime.utc_now() + ] ] ) diff --git a/test/notification_test.exs b/test/notification_test.exs index c3db77b6c..907b9e669 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -300,6 +300,29 @@ test "it sets all notifications as read up to a specified notification ID" do assert n2.seen == true assert n3.seen == false end + + test "Updates `updated_at` field" do + user1 = insert(:user) + user2 = insert(:user) + + Enum.each(0..10, fn i -> + {:ok, _activity} = + TwitterAPI.create_status(user1, %{ + "status" => "#{i} hi @#{user2.nickname}" + }) + end) + + Process.sleep(1000) + + [notification | _] = Notification.for_user(user2) + + Notification.set_read_up_to(user2, notification.id) + + Notification.for_user(user2) + |> Enum.each(fn notification -> + assert notification.updated_at > notification.inserted_at + end) + end end describe "notification target determination" do From 2f0203a4a1c7a507aa5cf50be2fd372536ebfc81 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Wed, 17 Apr 2019 16:59:05 +0700 Subject: [PATCH 03/56] Resolve conflicts --- config/config.exs | 10 ++++++++ lib/pleroma/user.ex | 2 ++ mix.exs | 5 ++-- mix.lock | 51 +++++++++++++++++++++----------------- test/notification_test.exs | 22 ++++++++++------ 5 files changed, 57 insertions(+), 33 deletions(-) diff --git a/config/config.exs b/config/config.exs index 595e3505c..747d33884 100644 --- a/config/config.exs +++ b/config/config.exs @@ -464,6 +464,16 @@ total_user_limit: 300, enabled: true +config :pleroma, :email_notifications, + digest: %{ + # When to send digest email, in crontab format (https://en.wikipedia.org/wiki/Cron) + schedule: "0 0 * * 0", + # Minimum interval between digest emails to one user + interval: 7, + # Minimum user inactivity threshold + inactivity_threshold: 7 + } + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 78eb29ddd..0982f6ed8 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -55,6 +55,8 @@ defmodule Pleroma.User do field(:tags, {:array, :string}, default: []) field(:bookmarks, {:array, :string}, default: []) field(:last_refreshed_at, :naive_datetime_usec) + field(:current_sign_in_at, :naive_datetime) + field(:last_digest_emailed_at, :naive_datetime) has_many(:notifications, Notification) has_many(:registrations, Registration) embeds_one(:info, Pleroma.User.Info) diff --git a/mix.exs b/mix.exs index 15e182239..da2e284f8 100644 --- a/mix.exs +++ b/mix.exs @@ -8,7 +8,7 @@ def project do elixir: "~> 1.7", elixirc_paths: elixirc_paths(Mix.env()), compilers: [:phoenix, :gettext] ++ Mix.compilers(), - elixirc_options: [warnings_as_errors: true], + # elixirc_options: [warnings_as_errors: true], xref: [exclude: [:eldap]], start_permanent: Mix.env() == :prod, aliases: aliases(), @@ -110,7 +110,8 @@ defp deps do {:prometheus_ecto, "~> 1.4"}, {:prometheus_process_collector, "~> 1.4"}, {:recon, github: "ferd/recon", tag: "2.4.0"}, - {:quack, "~> 0.1.1"} + {:quack, "~> 0.1.1"}, + {:quantum, "~> 2.3"} ] ++ oauth_deps end diff --git a/mix.lock b/mix.lock index d494cc82d..6e322240a 100644 --- a/mix.lock +++ b/mix.lock @@ -3,23 +3,24 @@ "auto_linker": {:git, "https://git.pleroma.social/pleroma/auto_linker.git", "90613b4bae875a3610c275b7056b61ffdd53210d", [ref: "90613b4bae875a3610c275b7056b61ffdd53210d"]}, "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, - "cachex": {:hex, :cachex, "3.0.2", "1351caa4e26e29f7d7ec1d29b53d6013f0447630bbf382b4fb5d5bad0209f203", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm"}, - "calendar": {:hex, :calendar, "0.17.4", "22c5e8d98a4db9494396e5727108dffb820ee0d18fed4b0aa8ab76e4f5bc32f1", [:mix], [{:tzdata, "~> 0.5.8 or ~> 0.1.201603", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"}, + "cachex": {:hex, :cachex, "3.0.3", "4e2d3e05814a5738f5ff3903151d5c25636d72a3527251b753f501ad9c657967", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm"}, + "calendar": {:hex, :calendar, "0.17.5", "0ff5b09a60b9677683aa2a6fee948558660501c74a289103ea099806bc41a352", [:mix], [{:tzdata, "~> 0.5.20 or ~> 0.1.201603", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"}, "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"}, - "comeonin": {:hex, :comeonin, "4.1.1", "c7304fc29b45b897b34142a91122bc72757bc0c295e9e824999d5179ffc08416", [:mix], [{:argon2_elixir, "~> 1.2", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:bcrypt_elixir, "~> 0.12.1 or ~> 1.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: true]}, {:pbkdf2_elixir, "~> 0.12", [hex: :pbkdf2_elixir, repo: "hexpm", optional: true]}], "hexpm"}, + "comeonin": {:hex, :comeonin, "4.1.2", "3eb5620fd8e35508991664b4c2b04dd41e52f1620b36957be837c1d7784b7592", [:mix], [{:argon2_elixir, "~> 1.2", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:bcrypt_elixir, "~> 0.12.1 or ~> 1.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: true]}, {:pbkdf2_elixir, "~> 0.12", [hex: :pbkdf2_elixir, repo: "hexpm", optional: true]}], "hexpm"}, "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, "cors_plug": {:hex, :cors_plug, "1.5.2", "72df63c87e4f94112f458ce9d25800900cc88608c1078f0e4faddf20933eda6e", [:mix], [{:plug, "~> 1.3 or ~> 1.4 or ~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, - "cowboy": {:hex, :cowboy, "2.6.1", "f2e06f757c337b3b311f9437e6e072b678fcd71545a7b2865bdaa154d078593f", [:rebar3], [{:cowlib, "~> 2.7.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, - "cowlib": {:hex, :cowlib, "2.7.0", "3ef16e77562f9855a2605900cedb15c1462d76fb1be6a32fc3ae91973ee543d2", [:rebar3], [], "hexpm"}, + "cowboy": {:hex, :cowboy, "2.6.3", "99aa50e94e685557cad82e704457336a453d4abcb77839ad22dbe71f311fcc06", [:rebar3], [{:cowlib, "~> 2.7.3", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, + "cowlib": {:hex, :cowlib, "2.7.3", "a7ffcd0917e6d50b4d5fb28e9e2085a0ceb3c97dea310505f7460ff5ed764ce9", [:rebar3], [], "hexpm"}, "credo": {:hex, :credo, "0.9.3", "76fa3e9e497ab282e0cf64b98a624aa11da702854c52c82db1bf24e54ab7c97a", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:poison, ">= 0.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, + "crontab": {:hex, :crontab, "1.1.5", "2c9439506ceb0e9045de75879e994b88d6f0be88bfe017d58cb356c66c4a5482", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, "crypt": {:git, "https://github.com/msantos/crypt", "1f2b58927ab57e72910191a7ebaeff984382a1d3", [ref: "1f2b58927ab57e72910191a7ebaeff984382a1d3"]}, - "db_connection": {:hex, :db_connection, "2.0.5", "ddb2ba6761a08b2bb9ca0e7d260e8f4dd39067426d835c24491a321b7f92a4da", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"}, + "db_connection": {:hex, :db_connection, "2.0.6", "bde2f85d047969c5b5800cb8f4b3ed6316c8cb11487afedac4aa5f93fd39abfa", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"}, "decimal": {:hex, :decimal, "1.7.0", "30d6b52c88541f9a66637359ddf85016df9eb266170d53105f02e4a67e00c5aa", [:mix], [], "hexpm"}, "earmark": {:hex, :earmark, "1.3.2", "b840562ea3d67795ffbb5bd88940b1bed0ed9fa32834915125ea7d02e35888a5", [:mix], [], "hexpm"}, - "ecto": {:hex, :ecto, "3.0.7", "44dda84ac6b17bbbdeb8ac5dfef08b7da253b37a453c34ab1a98de7f7e5fec7f", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"}, + "ecto": {:hex, :ecto, "3.0.8", "9eb6a1fcfc593e6619d45ef51afe607f1554c21ca188a1cd48eecc27223567f1", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"}, "ecto_sql": {:hex, :ecto_sql, "3.0.5", "7e44172b4f7aca4469f38d7f6a3da394dbf43a1bcf0ca975e958cb957becd74e", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0.6", [hex: :ecto, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.9.1", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.3.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"}, - "eternal": {:hex, :eternal, "1.2.0", "e2a6b6ce3b8c248f7dc31451aefca57e3bdf0e48d73ae5043229380a67614c41", [:mix], [], "hexpm"}, + "eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm"}, "ex_aws": {:hex, :ex_aws, "2.1.0", "b92651527d6c09c479f9013caa9c7331f19cba38a650590d82ebf2c6c16a1d8a", [:mix], [{:configparser_ex, "~> 2.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "1.6.3 or 1.6.5 or 1.7.1 or 1.8.6 or ~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8", [hex: :jsx, repo: "hexpm", optional: true]}, {:poison, ">= 1.2.0", [hex: :poison, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:xml_builder, "~> 0.1.0", [hex: :xml_builder, repo: "hexpm", optional: true]}], "hexpm"}, "ex_aws_s3": {:hex, :ex_aws_s3, "2.0.1", "9e09366e77f25d3d88c5393824e613344631be8db0d1839faca49686e99b6704", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm"}, "ex_doc": {:hex, :ex_doc, "0.20.2", "1bd0dfb0304bade58beb77f20f21ee3558cc3c753743ae0ddbb0fd7ba2912331", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, @@ -27,57 +28,61 @@ "ex_syslogger": {:git, "https://github.com/slashmili/ex_syslogger.git", "f3963399047af17e038897c69e20d552e6899e1d", [tag: "1.4.0"]}, "floki": {:hex, :floki, "0.20.4", "be42ac911fece24b4c72f3b5846774b6e61b83fe685c2fc9d62093277fb3bc86", [:mix], [{:html_entities, "~> 0.4.0", [hex: :html_entities, repo: "hexpm", optional: false]}, {:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"}, "gen_smtp": {:hex, :gen_smtp, "0.13.0", "11f08504c4bdd831dc520b8f84a1dce5ce624474a797394e7aafd3c29f5dcd25", [:rebar3], [], "hexpm"}, - "gettext": {:hex, :gettext, "0.15.0", "40a2b8ce33a80ced7727e36768499fc9286881c43ebafccae6bab731e2b2b8ce", [:mix], [], "hexpm"}, + "gen_stage": {:hex, :gen_stage, "0.14.1", "9d46723fda072d4f4bb31a102560013f7960f5d80ea44dcb96fd6304ed61e7a4", [:mix], [], "hexpm"}, + "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"}, + "gettext": {:hex, :gettext, "0.16.1", "e2130b25eebcbe02bb343b119a07ae2c7e28bd4b146c4a154da2ffb2b3507af2", [:mix], [], "hexpm"}, "hackney": {:hex, :hackney, "1.15.1", "9f8f471c844b8ce395f7b6d8398139e26ddca9ebc171a8b91342ee15a19963f4", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, "html_entities": {:hex, :html_entities, "0.4.0", "f2fee876858cf6aaa9db608820a3209e45a087c5177332799592142b50e89a6b", [:mix], [], "hexpm"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.3.0", "f005ad692b717691203f940c686208aa3d8ffd9dd4bb3699240096a51fa9564e", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"}, "httpoison": {:hex, :httpoison, "1.2.0", "2702ed3da5fd7a8130fc34b11965c8cfa21ade2f232c00b42d96d4967c39a3a3", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, - "jose": {:hex, :jose, "1.8.4", "7946d1e5c03a76ac9ef42a6e6a20001d35987afd68c2107bcd8f01a84e75aa73", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm"}, + "jose": {:hex, :jose, "1.9.0", "4167c5f6d06ffaebffd15cdb8da61a108445ef5e85ab8f5a7ad926fdf3ada154", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm"}, + "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"}, "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, "makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"}, - "mochiweb": {:hex, :mochiweb, "2.15.0", "e1daac474df07651e5d17cc1e642c4069c7850dc4508d3db7263a0651330aacc", [:rebar3], [], "hexpm"}, - "mock": {:hex, :mock, "0.3.1", "994f00150f79a0ea50dc9d86134cd9ebd0d177ad60bd04d1e46336cdfdb98ff9", [:mix], [{:meck, "~> 0.8.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"}, + "mochiweb": {:hex, :mochiweb, "2.18.0", "eb55f1db3e6e960fac4e6db4e2db9ec3602cc9f30b86cd1481d56545c3145d2e", [:rebar3], [], "hexpm"}, + "mock": {:hex, :mock, "0.3.3", "42a433794b1291a9cf1525c6d26b38e039e0d3a360732b5e467bfc77ef26c914", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"}, "mogrify": {:hex, :mogrify, "0.6.1", "de1b527514f2d95a7bbe9642eb556061afb337e220cf97adbf3a4e6438ed70af", [:mix], [], "hexpm"}, "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, - "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "0.12.3", "6706a148809a29c306062862c803406e88f048277f6e85b68faf73291e820b84", [:mix], [], "hexpm"}, - "phoenix": {:hex, :phoenix, "1.4.1", "801f9d632808657f1f7c657c8bbe624caaf2ba91429123ebe3801598aea4c3d9", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm"}, + "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "0.12.4", "8dd29ed783f2e12195d7e0a4640effc0a7c37e6537da491f1db01839eee6d053", [:mix], [], "hexpm"}, + "phoenix": {:hex, :phoenix, "1.4.3", "8eed4a64ff1e12372cd634724bddd69185938f52c18e1396ebac76375d85677d", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.0.0", "c43117a136e7399ea04ecaac73f8f23ee0ffe3e07acfcb8062fe5f4c9f0f6531", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, - "phoenix_html": {:hex, :phoenix_html, "2.13.1", "fa8f034b5328e2dfa0e4131b5569379003f34bc1fafdaa84985b0b9d2f12e68b", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, - "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.1", "6668d787e602981f24f17a5fbb69cc98f8ab085114ebfac6cc36e10a90c8e93c", [:mix], [], "hexpm"}, + "phoenix_html": {:hex, :phoenix_html, "2.13.2", "f5d27c9b10ce881a60177d2b5227314fc60881e6b66b41dfe3349db6ed06cf57", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm"}, "pleroma_job_queue": {:hex, :pleroma_job_queue, "0.2.0", "879e660aa1cebe8dc6f0aaaa6aa48b4875e89cd961d4a585fd128e0773b31a18", [:mix], [], "hexpm"}, "plug": {:hex, :plug, "1.7.2", "d7b7db7fbd755e8283b6c0a50be71ec0a3d67d9213d74422d9372effc8e87fd1", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}], "hexpm"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.0.1", "d798f8ee5acc86b7d42dbe4450b8b0dadf665ce588236eb0a751a132417a980e", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.0.2", "6055f16868cc4882b24b6e1d63d2bada94fb4978413377a3b32ac16c18dffba2", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, - "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm"}, - "postgrex": {:hex, :postgrex, "0.14.1", "63247d4a5ad6b9de57a0bac5d807e1c32d41e39c04b8a4156a26c63bcd8a2e49", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, + "postgrex": {:hex, :postgrex, "0.14.2", "6680591bbce28d92f043249205e8b01b36cab9ef2a7911abc43649242e1a3b78", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, "prometheus": {:hex, :prometheus, "4.2.2", "a830e77b79dc6d28183f4db050a7cac926a6c58f1872f9ef94a35cd989aceef8", [:mix, :rebar3], [], "hexpm"}, "prometheus_ecto": {:hex, :prometheus_ecto, "1.4.1", "6c768ea9654de871e5b32fab2eac348467b3021604ebebbcbd8bcbe806a65ed5", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm"}, "prometheus_ex": {:hex, :prometheus_ex, "3.0.5", "fa58cfd983487fc5ead331e9a3e0aa622c67232b3ec71710ced122c4c453a02f", [:mix], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm"}, "prometheus_phoenix": {:hex, :prometheus_phoenix, "1.2.1", "964a74dfbc055f781d3a75631e06ce3816a2913976d1df7830283aa3118a797a", [:mix], [{:phoenix, "~> 1.3", [hex: :phoenix, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.3 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm"}, "prometheus_plugs": {:hex, :prometheus_plugs, "1.1.5", "25933d48f8af3a5941dd7b621c889749894d8a1082a6ff7c67cc99dec26377c5", [:mix], [{:accept, "~> 0.1", [hex: :accept, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}, {:prometheus_process_collector, "~> 1.1", [hex: :prometheus_process_collector, repo: "hexpm", optional: true]}], "hexpm"}, - "prometheus_process_collector": {:hex, :prometheus_process_collector, "1.4.0", "6dbd39e3165b9ef1c94a7a820e9ffe08479f949dcdd431ed4aaea7b250eebfde", [:rebar3], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm"}, + "prometheus_process_collector": {:hex, :prometheus_process_collector, "1.4.3", "657386e8f142fc817347d95c1f3a05ab08710f7df9e7f86db6facaed107ed929", [:rebar3], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm"}, "quack": {:hex, :quack, "0.1.1", "cca7b4da1a233757fdb44b3334fce80c94785b3ad5a602053b7a002b5a8967bf", [:mix], [{:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: false]}, {:tesla, "~> 1.2.0", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm"}, + "quantum": {:hex, :quantum, "2.3.4", "72a0e8855e2adc101459eac8454787cb74ab4169de6ca50f670e72142d4960e9", [:mix], [{:calendar, "~> 0.17", [hex: :calendar, repo: "hexpm", optional: true]}, {:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.12", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:swarm, "~> 3.3", [hex: :swarm, repo: "hexpm", optional: false]}, {:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: true]}], "hexpm"}, "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"}, "recon": {:git, "https://github.com/ferd/recon.git", "75d70c7c08926d2f24f1ee6de14ee50fe8a52763", [tag: "2.4.0"]}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"}, - "swoosh": {:hex, :swoosh, "0.20.0", "9a6c13822c9815993c03b6f8fccc370fcffb3c158d9754f67b1fdee6b3a5d928", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.12", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm"}, + "swarm": {:hex, :swarm, "3.4.0", "64f8b30055d74640d2186c66354b33b999438692a91be275bb89cdc7e401f448", [:mix], [{:gen_state_machine, "~> 2.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}, {:libring, "~> 1.0", [hex: :libring, repo: "hexpm", optional: false]}], "hexpm"}, + "swoosh": {:hex, :swoosh, "0.23.1", "209b7cc6d862c09d2a064c16caa4d4d1c9c936285476459e16591e0065f8432b", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.12", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm"}, "syslog": {:git, "https://github.com/Vagabond/erlang-syslog.git", "4a6c6f2c996483e86c1320e9553f91d337bcb6aa", [tag: "1.0.5"]}, "telemetry": {:hex, :telemetry, "0.3.0", "099a7f3ce31e4780f971b4630a3c22ec66d22208bc090fe33a2a3a6a67754a73", [:rebar3], [], "hexpm"}, "tesla": {:hex, :tesla, "1.2.1", "864783cc27f71dd8c8969163704752476cec0f3a51eb3b06393b3971dc9733ff", [:mix], [{:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"}, "timex": {:hex, :timex, "3.5.0", "b0a23167da02d0fe4f1a4e104d1f929a00d348502b52432c05de875d0b9cffa5", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"}, "trailing_format_plug": {:hex, :trailing_format_plug, "0.0.7", "64b877f912cf7273bed03379936df39894149e35137ac9509117e59866e10e45", [:mix], [{:plug, "> 0.12.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, - "tzdata": {:hex, :tzdata, "0.5.17", "50793e3d85af49736701da1a040c415c97dc1caf6464112fd9bd18f425d3053b", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, + "tzdata": {:hex, :tzdata, "0.5.20", "304b9e98a02840fb32a43ec111ffbe517863c8566eb04a061f1c4dbb90b4d84c", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, "ueberauth": {:hex, :ueberauth, "0.6.1", "9e90d3337dddf38b1ca2753aca9b1e53d8a52b890191cdc55240247c89230412", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, - "unsafe": {:hex, :unsafe, "1.0.0", "7c21742cd05380c7875546b023481d3a26f52df8e5dfedcb9f958f322baae305", [:mix], [], "hexpm"}, + "unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm"}, "web_push_encryption": {:hex, :web_push_encryption, "0.2.1", "d42cecf73420d9dc0053ba3299cc8c8d6ff2be2487d67ca2a57265868e4d9a98", [:mix], [{:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:poison, "~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, "websocket_client": {:git, "https://github.com/jeremyong/websocket_client.git", "9a6f65d05ebf2725d62fb19262b21f1805a59fbf", []}, } diff --git a/test/notification_test.exs b/test/notification_test.exs index 907b9e669..27d8cace7 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -4,12 +4,15 @@ defmodule Pleroma.NotificationTest do use Pleroma.DataCase + + import Pleroma.Factory + import Mock + alias Pleroma.Notification alias Pleroma.User alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.CommonAPI alias Pleroma.Web.TwitterAPI.TwitterAPI - import Pleroma.Factory describe "create_notifications" do test "notifies someone when they are directly addressed" do @@ -312,16 +315,19 @@ test "Updates `updated_at` field" do }) end) - Process.sleep(1000) - [notification | _] = Notification.for_user(user2) - Notification.set_read_up_to(user2, notification.id) + utc_now = NaiveDateTime.utc_now() + future = NaiveDateTime.add(utc_now, 5, :second) - Notification.for_user(user2) - |> Enum.each(fn notification -> - assert notification.updated_at > notification.inserted_at - end) + with_mock NaiveDateTime, utc_now: fn -> future end do + Notification.set_read_up_to(user2, notification.id) + + Notification.for_user(user2) + |> Enum.each(fn notification -> + assert notification.updated_at > notification.inserted_at + end) + end end end From aeafa0b2ef996f15f9ff4a6ade70a693b12b208f Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Fri, 19 Apr 2019 22:16:17 +0700 Subject: [PATCH 04/56] Add Notification.for_user_since/2 --- config/config.exs | 1 + lib/pleroma/notification.ex | 21 +++++++++++++++++ test/notification_test.exs | 45 +++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/config/config.exs b/config/config.exs index 747d33884..c452b728b 100644 --- a/config/config.exs +++ b/config/config.exs @@ -467,6 +467,7 @@ config :pleroma, :email_notifications, digest: %{ # When to send digest email, in crontab format (https://en.wikipedia.org/wiki/Cron) + # 0 0 * * 0 - once a week at midnight on Sunday morning schedule: "0 0 * * 0", # Minimum interval between digest emails to one user interval: 7, diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 29845b9da..d79f0f563 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -17,6 +17,8 @@ defmodule Pleroma.Notification do import Ecto.Query import Ecto.Changeset + @type t :: %__MODULE__{} + schema "notifications" do field(:seen, :boolean, default: false) belongs_to(:user, User, type: Pleroma.FlakeId) @@ -51,6 +53,25 @@ def for_user(user, opts \\ %{}) do |> Pagination.fetch_paginated(opts) end + @doc """ + Returns notifications for user received since given date. + + ## Examples + + iex> Pleroma.Notification.for_user_since(%Pleroma.User{}, ~N[2019-04-13 11:22:33]) + [%Pleroma.Notification{}, %Pleroma.Notification{}] + + iex> Pleroma.Notification.for_user_since(%Pleroma.User{}, ~N[2019-04-15 11:22:33]) + [] + """ + @spec for_user_since(Pleroma.User.t(), NaiveDateTime.t()) :: [t()] + def for_user_since(user, date) do + from(n in for_user_query(user), + where: n.updated_at > ^date + ) + |> Repo.all() + end + def set_read_up_to(%{id: user_id} = _user, id) do query = from( diff --git a/test/notification_test.exs b/test/notification_test.exs index 27d8cace7..dbc4f48f6 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -331,6 +331,51 @@ test "Updates `updated_at` field" do end end + describe "for_user_since/2" do + defp days_ago(days) do + NaiveDateTime.add( + NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second), + -days * 60 * 60 * 24, + :second + ) + end + + test "Returns recent notifications" do + user1 = insert(:user) + user2 = insert(:user) + + Enum.each(0..10, fn i -> + {:ok, _activity} = + CommonAPI.post(user1, %{ + "status" => "hey ##{i} @#{user2.nickname}!" + }) + end) + + {old, new} = Enum.split(Notification.for_user(user2), 5) + + Enum.each(old, fn notification -> + notification + |> cast(%{updated_at: days_ago(10)}, [:updated_at]) + |> Pleroma.Repo.update!() + end) + + recent_notifications_ids = + user2 + |> Notification.for_user_since( + NaiveDateTime.add(NaiveDateTime.utc_now(), -5 * 86400, :second) + ) + |> Enum.map(& &1.id) + + Enum.each(old, fn %{id: id} -> + refute id in recent_notifications_ids + end) + + Enum.each(new, fn %{id: id} -> + assert id in recent_notifications_ids + end) + end + end + describe "notification target determination" do test "it sends notifications to addressed users in new messages" do user = insert(:user) From 8add1194448cfc183dce01b86451422195d44023 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Fri, 19 Apr 2019 22:17:54 +0700 Subject: [PATCH 05/56] Add User.list_inactive_users_query/1 --- lib/pleroma/user.ex | 38 +++++++ ...d_signin_and_last_digest_dates_to_user.exs | 9 ++ test/user_test.exs | 103 ++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 priv/repo/migrations/20190413085040_add_signin_and_last_digest_dates_to_user.exs diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 0982f6ed8..c67a7b7a1 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1447,4 +1447,42 @@ defp paginate(query, page, page_size) do def showing_reblogs?(%User{} = user, %User{} = target) do target.ap_id not in user.info.muted_reblogs end + + @doc """ + The function returns a query to get users with no activity for given interval of days. + Inactive users are those who didn't read any notification, or had any activity where + the user is the activity's actor, during `inactivity_threshold` days. + Deactivated users will not appear in this list. + + ## Examples + + iex> Pleroma.User.list_inactive_users() + %Ecto.Query{} + """ + @spec list_inactive_users_query(integer()) :: Ecto.Query.t() + def list_inactive_users_query(inactivity_threshold \\ 7) do + negative_inactivity_threshold = -inactivity_threshold + now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) + # Subqueries are not supported in `where` clauses, join gets too complicated. + has_read_notifications = + from(n in Pleroma.Notification, + where: n.seen == true, + group_by: n.id, + having: max(n.updated_at) > datetime_add(^now, ^negative_inactivity_threshold, "day"), + select: n.user_id + ) + |> Pleroma.Repo.all() + + from(u in Pleroma.User, + left_join: a in Pleroma.Activity, + on: u.ap_id == a.actor, + where: not is_nil(u.nickname), + where: fragment("not (?->'deactivated' @> 'true')", u.info), + where: u.id not in ^has_read_notifications, + group_by: u.id, + having: + max(a.inserted_at) < datetime_add(^now, ^negative_inactivity_threshold, "day") or + is_nil(max(a.inserted_at)) + ) + end end diff --git a/priv/repo/migrations/20190413085040_add_signin_and_last_digest_dates_to_user.exs b/priv/repo/migrations/20190413085040_add_signin_and_last_digest_dates_to_user.exs new file mode 100644 index 000000000..4312b171f --- /dev/null +++ b/priv/repo/migrations/20190413085040_add_signin_and_last_digest_dates_to_user.exs @@ -0,0 +1,9 @@ +defmodule Pleroma.Repo.Migrations.AddSigninAndLastDigestDatesToUser do + use Ecto.Migration + + def change do + alter table(:users) do + add(:last_digest_emailed_at, :naive_datetime, default: fragment("now()")) + end + end +end diff --git a/test/user_test.exs b/test/user_test.exs index d2167a970..ba02997dc 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -1167,4 +1167,107 @@ test "follower count is updated when a follower is blocked" do assert Map.get(user_show, "followers_count") == 2 end + + describe "list_inactive_users_query/1" do + defp days_ago(days) do + NaiveDateTime.add( + NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second), + -days * 60 * 60 * 24, + :second + ) + end + + test "Users are inactive by default" do + total = 10 + + users = + Enum.map(1..total, fn _ -> + insert(:user, last_digest_emailed_at: days_ago(20), info: %{deactivated: false}) + end) + + inactive_users_ids = + Pleroma.User.list_inactive_users_query() + |> Pleroma.Repo.all() + |> Enum.map(& &1.id) + + Enum.each(users, fn user -> + assert user.id in inactive_users_ids + end) + end + + test "Only includes users who has no recent activity" do + total = 10 + + users = + Enum.map(1..total, fn _ -> + insert(:user, last_digest_emailed_at: days_ago(20), info: %{deactivated: false}) + end) + + {inactive, active} = Enum.split(users, trunc(total / 2)) + + Enum.map(active, fn user -> + to = Enum.random(users -- [user]) + + {:ok, _} = + Pleroma.Web.TwitterAPI.TwitterAPI.create_status(user, %{ + "status" => "hey @#{to.nickname}" + }) + end) + + inactive_users_ids = + Pleroma.User.list_inactive_users_query() + |> Pleroma.Repo.all() + |> Enum.map(& &1.id) + + Enum.each(active, fn user -> + refute user.id in inactive_users_ids + end) + + Enum.each(inactive, fn user -> + assert user.id in inactive_users_ids + end) + end + + test "Only includes users with no read notifications" do + total = 10 + + users = + Enum.map(1..total, fn _ -> + insert(:user, last_digest_emailed_at: days_ago(20), info: %{deactivated: false}) + end) + + [sender | recipients] = users + {inactive, active} = Enum.split(recipients, trunc(total / 2)) + + Enum.each(recipients, fn to -> + {:ok, _} = + Pleroma.Web.TwitterAPI.TwitterAPI.create_status(sender, %{ + "status" => "hey @#{to.nickname}" + }) + + {:ok, _} = + Pleroma.Web.TwitterAPI.TwitterAPI.create_status(sender, %{ + "status" => "hey again @#{to.nickname}" + }) + end) + + Enum.each(active, fn user -> + [n1, _n2] = Pleroma.Notification.for_user(user) + {:ok, _} = Pleroma.Notification.read_one(user, n1.id) + end) + + inactive_users_ids = + Pleroma.User.list_inactive_users_query() + |> Pleroma.Repo.all() + |> Enum.map(& &1.id) + + Enum.each(active, fn user -> + refute user.id in inactive_users_ids + end) + + Enum.each(inactive, fn user -> + assert user.id in inactive_users_ids + end) + end + end end From bc7862106d9881f858a58319e9e4b44cba1bcf01 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Fri, 19 Apr 2019 23:26:41 +0700 Subject: [PATCH 06/56] Fix tests --- lib/pleroma/user.ex | 1 - test/notification_test.exs | 27 --------------------------- test/support/builders/user_builder.ex | 3 ++- test/support/factory.ex | 3 ++- 4 files changed, 4 insertions(+), 30 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index c67a7b7a1..7053dfaf3 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -55,7 +55,6 @@ defmodule Pleroma.User do field(:tags, {:array, :string}, default: []) field(:bookmarks, {:array, :string}, default: []) field(:last_refreshed_at, :naive_datetime_usec) - field(:current_sign_in_at, :naive_datetime) field(:last_digest_emailed_at, :naive_datetime) has_many(:notifications, Notification) has_many(:registrations, Registration) diff --git a/test/notification_test.exs b/test/notification_test.exs index dbc4f48f6..462398d75 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -6,7 +6,6 @@ defmodule Pleroma.NotificationTest do use Pleroma.DataCase import Pleroma.Factory - import Mock alias Pleroma.Notification alias Pleroma.User @@ -303,32 +302,6 @@ test "it sets all notifications as read up to a specified notification ID" do assert n2.seen == true assert n3.seen == false end - - test "Updates `updated_at` field" do - user1 = insert(:user) - user2 = insert(:user) - - Enum.each(0..10, fn i -> - {:ok, _activity} = - TwitterAPI.create_status(user1, %{ - "status" => "#{i} hi @#{user2.nickname}" - }) - end) - - [notification | _] = Notification.for_user(user2) - - utc_now = NaiveDateTime.utc_now() - future = NaiveDateTime.add(utc_now, 5, :second) - - with_mock NaiveDateTime, utc_now: fn -> future end do - Notification.set_read_up_to(user2, notification.id) - - Notification.for_user(user2) - |> Enum.each(fn notification -> - assert notification.updated_at > notification.inserted_at - end) - end - end end describe "for_user_since/2" do diff --git a/test/support/builders/user_builder.ex b/test/support/builders/user_builder.ex index f58e1b0ad..6da16f71a 100644 --- a/test/support/builders/user_builder.ex +++ b/test/support/builders/user_builder.ex @@ -9,7 +9,8 @@ def build(data \\ %{}) do nickname: "testname", password_hash: Comeonin.Pbkdf2.hashpwsalt("test"), bio: "A tester.", - ap_id: "some id" + ap_id: "some id", + last_digest_emailed_at: NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) } Map.merge(user, data) diff --git a/test/support/factory.ex b/test/support/factory.ex index ea59912cf..0840f31ec 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -12,7 +12,8 @@ def user_factory do nickname: sequence(:nickname, &"nick#{&1}"), password_hash: Comeonin.Pbkdf2.hashpwsalt("test"), bio: sequence(:bio, &"Tester Number #{&1}"), - info: %{} + info: %{}, + last_digest_emailed_at: NaiveDateTime.utc_now() } %{ From 64a2c6a041ca62ad84b1d682ef56fbca45e44de5 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 20 Apr 2019 19:42:19 +0700 Subject: [PATCH 07/56] Digest emails --- config/config.exs | 2 + lib/mix/tasks/pleroma/instance.ex | 2 + lib/mix/tasks/pleroma/sample_config.eex | 2 + lib/pleroma/application.ex | 22 ++++++- lib/pleroma/digest_email_worker.ex | 45 ++++++++++++++ lib/pleroma/emails/user_email.ex | 59 ++++++++++++++++++- lib/pleroma/jwt.ex | 9 +++ lib/pleroma/quantum_scheduler.ex | 4 ++ lib/pleroma/user.ex | 36 +++++++++++ .../web/mailer/subscription_controller.ex | 18 ++++++ lib/pleroma/web/router.ex | 2 + .../web/templates/email/digest.html.eex | 20 +++++++ .../web/templates/layout/email.html.eex | 10 ++++ .../subscription/unsubscribe_failure.html.eex | 1 + .../subscription/unsubscribe_success.html.eex | 1 + lib/pleroma/web/views/email_view.ex | 5 ++ .../web/views/mailer/subscription_view.ex | 3 + mix.exs | 4 +- mix.lock | 2 + 19 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 lib/pleroma/digest_email_worker.ex create mode 100644 lib/pleroma/jwt.ex create mode 100644 lib/pleroma/quantum_scheduler.ex create mode 100644 lib/pleroma/web/mailer/subscription_controller.ex create mode 100644 lib/pleroma/web/templates/email/digest.html.eex create mode 100644 lib/pleroma/web/templates/layout/email.html.eex create mode 100644 lib/pleroma/web/templates/mailer/subscription/unsubscribe_failure.html.eex create mode 100644 lib/pleroma/web/templates/mailer/subscription/unsubscribe_success.html.eex create mode 100644 lib/pleroma/web/views/email_view.ex create mode 100644 lib/pleroma/web/views/mailer/subscription_view.ex diff --git a/config/config.exs b/config/config.exs index 25dc91eb1..2663b1ebd 100644 --- a/config/config.exs +++ b/config/config.exs @@ -468,6 +468,8 @@ config :pleroma, :email_notifications, digest: %{ + # Globally enable or disable digest emails + active: true, # When to send digest email, in crontab format (https://en.wikipedia.org/wiki/Cron) # 0 0 * * 0 - once a week at midnight on Sunday morning schedule: "0 0 * * 0", diff --git a/lib/mix/tasks/pleroma/instance.ex b/lib/mix/tasks/pleroma/instance.ex index 6cee8d630..d276df93a 100644 --- a/lib/mix/tasks/pleroma/instance.ex +++ b/lib/mix/tasks/pleroma/instance.ex @@ -125,6 +125,7 @@ def run(["gen" | rest]) do ) secret = :crypto.strong_rand_bytes(64) |> Base.encode64() |> binary_part(0, 64) + jwt_secret = :crypto.strong_rand_bytes(64) |> Base.encode64() |> binary_part(0, 64) signing_salt = :crypto.strong_rand_bytes(8) |> Base.encode64() |> binary_part(0, 8) {web_push_public_key, web_push_private_key} = :crypto.generate_key(:ecdh, :prime256v1) @@ -142,6 +143,7 @@ def run(["gen" | rest]) do dbpass: dbpass, version: Pleroma.Mixfile.project() |> Keyword.get(:version), secret: secret, + jwt_secret: jwt_secret, signing_salt: signing_salt, web_push_public_key: Base.url_encode64(web_push_public_key, padding: false), web_push_private_key: Base.url_encode64(web_push_private_key, padding: false) diff --git a/lib/mix/tasks/pleroma/sample_config.eex b/lib/mix/tasks/pleroma/sample_config.eex index 52bd57cb7..ec7d8821e 100644 --- a/lib/mix/tasks/pleroma/sample_config.eex +++ b/lib/mix/tasks/pleroma/sample_config.eex @@ -76,3 +76,5 @@ config :web_push_encryption, :vapid_details, # storage_url: "https://swift-endpoint.prodider.com/v1/AUTH_/", # object_url: "https://cdn-endpoint.provider.com/" # + +config :joken, default_signer: "<%= jwt_secret %>" diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index eeb415084..76f8d9bcd 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -105,7 +105,8 @@ def start(_type, _args) do id: :cachex_idem ), worker(Pleroma.FlakeId, []), - worker(Pleroma.ScheduledActivityWorker, []) + worker(Pleroma.ScheduledActivityWorker, []), + worker(Pleroma.QuantumScheduler, []) ] ++ hackney_pool_children() ++ [ @@ -125,7 +126,9 @@ def start(_type, _args) do # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html # for other strategies and supported options opts = [strategy: :one_for_one, name: Pleroma.Supervisor] - Supervisor.start_link(children, opts) + result = Supervisor.start_link(children, opts) + :ok = after_supervisor_start() + result end defp setup_instrumenters do @@ -183,4 +186,19 @@ defp hackney_pool_children do :hackney_pool.child_spec(pool, options) end end + + defp after_supervisor_start() do + with digest_config <- Application.get_env(:pleroma, :email_notifications)[:digest], + true <- digest_config[:active], + %Crontab.CronExpression{} = schedule <- + Crontab.CronExpression.Parser.parse!(digest_config[:schedule]) do + Pleroma.QuantumScheduler.new_job() + |> Quantum.Job.set_name(:digest_emails) + |> Quantum.Job.set_schedule(schedule) + |> Quantum.Job.set_task(&Pleroma.DigestEmailWorker.run/0) + |> Pleroma.QuantumScheduler.add_job() + end + + :ok + end end diff --git a/lib/pleroma/digest_email_worker.ex b/lib/pleroma/digest_email_worker.ex new file mode 100644 index 000000000..fa6067a03 --- /dev/null +++ b/lib/pleroma/digest_email_worker.ex @@ -0,0 +1,45 @@ +defmodule Pleroma.DigestEmailWorker do + import Ecto.Query + require Logger + + # alias Pleroma.User + + def run() do + Logger.warn("Running digester") + config = Application.get_env(:pleroma, :email_notifications)[:digest] + negative_interval = -Map.fetch!(config, :interval) + inactivity_threshold = Map.fetch!(config, :inactivity_threshold) + inactive_users_query = Pleroma.User.list_inactive_users_query(inactivity_threshold) + + now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) + + from(u in inactive_users_query, + where: fragment("? #> '{\"email_notifications\",\"digest\"}' @> 'true'", u.info), + where: u.last_digest_emailed_at < datetime_add(^now, ^negative_interval, "day"), + select: u + ) + |> Pleroma.Repo.all() + |> run(:pre) + end + + defp run(v, :pre) do + Logger.warn("Running for #{length(v)} users") + run(v) + end + + defp run([]), do: :ok + + defp run([user | users]) do + with %Swoosh.Email{} = email <- Pleroma.Emails.UserEmail.digest_email(user) do + Logger.warn("Sending to #{user.nickname}") + Pleroma.Emails.Mailer.deliver_async(email) + else + _ -> + Logger.warn("Skipping #{user.nickname}") + end + + Pleroma.User.touch_last_digest_emailed_at(user) + + run(users) + end +end diff --git a/lib/pleroma/emails/user_email.ex b/lib/pleroma/emails/user_email.ex index 8502a0d0c..64f855112 100644 --- a/lib/pleroma/emails/user_email.ex +++ b/lib/pleroma/emails/user_email.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Emails.UserEmail do @moduledoc "User emails" - import Swoosh.Email + use Phoenix.Swoosh, view: Pleroma.Web.EmailView, layout: {Pleroma.Web.LayoutView, :email} alias Pleroma.Web.Endpoint alias Pleroma.Web.Router @@ -92,4 +92,61 @@ def account_confirmation_email(user) do |> subject("#{instance_name()} account confirmation") |> html_body(html_body) end + + @doc """ + Email used in digest email notifications + Includes Mentions and New Followers data + If there are no mentions (even when new followers exist), the function will return nil + """ + @spec digest_email(Pleroma.User.t()) :: Swoosh.Email.t() | nil + def digest_email(user) do + new_notifications = + Pleroma.Notification.for_user_since(user, user.last_digest_emailed_at) + |> Enum.reduce(%{followers: [], mentions: []}, fn + %{activity: %{data: %{"type" => "Create"}, actor: actor}} = notification, acc -> + new_mention = %{data: notification, from: Pleroma.User.get_by_ap_id(actor)} + %{acc | mentions: [new_mention | acc.mentions]} + + %{activity: %{data: %{"type" => "Follow"}, actor: actor}} = notification, acc -> + new_follower = %{data: notification, from: Pleroma.User.get_by_ap_id(actor)} + %{acc | followers: [new_follower | acc.followers]} + + _, acc -> + acc + end) + + with [_ | _] = mentions <- new_notifications.mentions do + html_data = %{ + instance: instance_name(), + user: user, + mentions: mentions, + followers: new_notifications.followers, + unsubscribe_link: unsubscribe_url(user, "digest") + } + + new() + |> to(recipient(user)) + |> from(sender()) + |> subject("Your digest from #{instance_name()}") + |> render_body("digest.html", html_data) + else + _ -> + nil + end + end + + @doc """ + Generate unsubscribe link for given user and notifications type. + The link contains JWT token with the data, and subscription can be modified without + authorization. + """ + @spec unsubscribe_url(Pleroma.User.t(), String.t()) :: String.t() + def unsubscribe_url(user, notifications_type) do + token = + %{"sub" => user.id, "act" => %{"unsubscribe" => notifications_type}, "exp" => false} + |> Pleroma.JWT.generate_and_sign!() + |> Base.encode64() + + Router.Helpers.subscription_url(Pleroma.Web.Endpoint, :unsubscribe, token) + end end diff --git a/lib/pleroma/jwt.ex b/lib/pleroma/jwt.ex new file mode 100644 index 000000000..10102ff5d --- /dev/null +++ b/lib/pleroma/jwt.ex @@ -0,0 +1,9 @@ +defmodule Pleroma.JWT do + use Joken.Config + + @impl true + def token_config do + default_claims(skip: [:aud]) + |> add_claim("aud", &Pleroma.Web.Endpoint.url/0, &(&1 == Pleroma.Web.Endpoint.url())) + end +end diff --git a/lib/pleroma/quantum_scheduler.ex b/lib/pleroma/quantum_scheduler.ex new file mode 100644 index 000000000..9a3df81f6 --- /dev/null +++ b/lib/pleroma/quantum_scheduler.ex @@ -0,0 +1,4 @@ +defmodule Pleroma.QuantumScheduler do + use Quantum.Scheduler, + otp_app: :pleroma +end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 7053dfaf3..2509d2366 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1484,4 +1484,40 @@ def list_inactive_users_query(inactivity_threshold \\ 7) do is_nil(max(a.inserted_at)) ) end + + @doc """ + Enable or disable email notifications for user + + ## Examples + + iex> Pleroma.User.switch_email_notifications(Pleroma.User{info: %{email_notifications: %{"digest" => false}}}, "digest", true) + Pleroma.User{info: %{email_notifications: %{"digest" => true}}} + + iex> Pleroma.User.switch_email_notifications(Pleroma.User{info: %{email_notifications: %{"digest" => true}}}, "digest", false) + Pleroma.User{info: %{email_notifications: %{"digest" => false}}} + """ + @spec switch_email_notifications(t(), String.t(), boolean()) :: + {:ok, t()} | {:error, Ecto.Changeset.t()} + def switch_email_notifications(user, type, status) do + info = Pleroma.User.Info.update_email_notifications(user.info, %{type => status}) + + change(user) + |> put_embed(:info, info) + |> update_and_set_cache() + end + + @doc """ + Set `last_digest_emailed_at` value for the user to current time + """ + @spec touch_last_digest_emailed_at(t()) :: t() + def touch_last_digest_emailed_at(user) do + now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) + + {:ok, updated_user} = + user + |> change(%{last_digest_emailed_at: now}) + |> update_and_set_cache() + + updated_user + end end diff --git a/lib/pleroma/web/mailer/subscription_controller.ex b/lib/pleroma/web/mailer/subscription_controller.ex new file mode 100644 index 000000000..2334ebacb --- /dev/null +++ b/lib/pleroma/web/mailer/subscription_controller.ex @@ -0,0 +1,18 @@ +defmodule Pleroma.Web.Mailer.SubscriptionController do + use Pleroma.Web, :controller + + alias Pleroma.{JWT, Repo, User} + + def unsubscribe(conn, %{"token" => encoded_token}) do + with {:ok, token} <- Base.decode64(encoded_token), + {:ok, claims} <- JWT.verify_and_validate(token), + %{"act" => %{"unsubscribe" => type}, "sub" => uid} <- claims, + %User{} = user <- Repo.get(User, uid), + {:ok, _user} <- User.switch_email_notifications(user, type, false) do + render(conn, "unsubscribe_success.html", email: user.email) + else + _err -> + render(conn, "unsubscribe_failure.html") + end + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 8b665d61b..09e51e602 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -562,6 +562,8 @@ defmodule Pleroma.Web.Router do post("/push/hub/:nickname", Websub.WebsubController, :websub_subscription_request) get("/push/subscriptions/:id", Websub.WebsubController, :websub_subscription_confirmation) post("/push/subscriptions/:id", Websub.WebsubController, :websub_incoming) + + get("/mailer/unsubscribe/:token", Mailer.SubscriptionController, :unsubscribe) end scope "/", Pleroma.Web do diff --git a/lib/pleroma/web/templates/email/digest.html.eex b/lib/pleroma/web/templates/email/digest.html.eex new file mode 100644 index 000000000..93c9c884f --- /dev/null +++ b/lib/pleroma/web/templates/email/digest.html.eex @@ -0,0 +1,20 @@ +

Hey <%= @user.nickname %>, here is what you've missed!

+ +

New Mentions:

+
    +<%= for %{data: mention, from: from} <- @mentions do %> +
  • <%= link from.nickname, to: mention.activity.actor %>: <%= raw mention.activity.object.data["content"] %>
  • +<% end %> +
+ +<%= if @followers != [] do %> +

<%= length(@followers) %> New Followers:

+
    +<%= for %{data: follow, from: from} <- @followers do %> +
  • <%= link from.nickname, to: follow.activity.actor %>
  • +<% end %> +
+<% end %> + +

You have received this email because you have signed up to receive digest emails from <%= @instance %> Pleroma instance.

+

The email address you are subscribed as is <%= @user.email %>. To unsubscribe, please go <%= link "here", to: @unsubscribe_link %>.

\ No newline at end of file diff --git a/lib/pleroma/web/templates/layout/email.html.eex b/lib/pleroma/web/templates/layout/email.html.eex new file mode 100644 index 000000000..f6dcd7f0f --- /dev/null +++ b/lib/pleroma/web/templates/layout/email.html.eex @@ -0,0 +1,10 @@ + + + + + <%= @email.subject %> + + + <%= render @view_module, @view_template, assigns %> + + \ No newline at end of file diff --git a/lib/pleroma/web/templates/mailer/subscription/unsubscribe_failure.html.eex b/lib/pleroma/web/templates/mailer/subscription/unsubscribe_failure.html.eex new file mode 100644 index 000000000..7b476f02d --- /dev/null +++ b/lib/pleroma/web/templates/mailer/subscription/unsubscribe_failure.html.eex @@ -0,0 +1 @@ +

UNSUBSCRIBE FAILURE

diff --git a/lib/pleroma/web/templates/mailer/subscription/unsubscribe_success.html.eex b/lib/pleroma/web/templates/mailer/subscription/unsubscribe_success.html.eex new file mode 100644 index 000000000..6dfa2c185 --- /dev/null +++ b/lib/pleroma/web/templates/mailer/subscription/unsubscribe_success.html.eex @@ -0,0 +1 @@ +

UNSUBSCRIBE SUCCESSFUL

diff --git a/lib/pleroma/web/views/email_view.ex b/lib/pleroma/web/views/email_view.ex new file mode 100644 index 000000000..b63eb162c --- /dev/null +++ b/lib/pleroma/web/views/email_view.ex @@ -0,0 +1,5 @@ +defmodule Pleroma.Web.EmailView do + use Pleroma.Web, :view + import Phoenix.HTML + import Phoenix.HTML.Link +end diff --git a/lib/pleroma/web/views/mailer/subscription_view.ex b/lib/pleroma/web/views/mailer/subscription_view.ex new file mode 100644 index 000000000..fc3d20816 --- /dev/null +++ b/lib/pleroma/web/views/mailer/subscription_view.ex @@ -0,0 +1,3 @@ +defmodule Pleroma.Web.Mailer.SubscriptionView do + use Pleroma.Web, :view +end diff --git a/mix.exs b/mix.exs index da2e284f8..6bb105538 100644 --- a/mix.exs +++ b/mix.exs @@ -93,6 +93,7 @@ defp deps do {:ex_doc, "~> 0.20.2", only: :dev, runtime: false}, {:web_push_encryption, "~> 0.2.1"}, {:swoosh, "~> 0.20"}, + {:phoenix_swoosh, "~> 0.2"}, {:gen_smtp, "~> 0.13"}, {:websocket_client, git: "https://github.com/jeremyong/websocket_client.git", only: :test}, {:floki, "~> 0.20.0"}, @@ -111,7 +112,8 @@ defp deps do {:prometheus_process_collector, "~> 1.4"}, {:recon, github: "ferd/recon", tag: "2.4.0"}, {:quack, "~> 0.1.1"}, - {:quantum, "~> 2.3"} + {:quantum, "~> 2.3"}, + {:joken, "~> 2.0"} ] ++ oauth_deps end diff --git a/mix.lock b/mix.lock index 6e322240a..73aed012f 100644 --- a/mix.lock +++ b/mix.lock @@ -37,6 +37,7 @@ "httpoison": {:hex, :httpoison, "1.2.0", "2702ed3da5fd7a8130fc34b11965c8cfa21ade2f232c00b42d96d4967c39a3a3", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, + "joken": {:hex, :joken, "2.0.1", "ec9ab31bf660f343380da033b3316855197c8d4c6ef597fa3fcb451b326beb14", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm"}, "jose": {:hex, :jose, "1.9.0", "4167c5f6d06ffaebffd15cdb8da61a108445ef5e85ab8f5a7ad926fdf3ada154", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm"}, "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"}, "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, @@ -55,6 +56,7 @@ "phoenix_ecto": {:hex, :phoenix_ecto, "4.0.0", "c43117a136e7399ea04ecaac73f8f23ee0ffe3e07acfcb8062fe5f4c9f0f6531", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, "phoenix_html": {:hex, :phoenix_html, "2.13.2", "f5d27c9b10ce881a60177d2b5227314fc60881e6b66b41dfe3349db6ed06cf57", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm"}, + "phoenix_swoosh": {:hex, :phoenix_swoosh, "0.2.0", "a7e0b32077cd6d2323ae15198839b05d9caddfa20663fd85787479e81f89520e", [:mix], [{:phoenix, "~> 1.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.2", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:swoosh, "~> 0.1", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm"}, "pleroma_job_queue": {:hex, :pleroma_job_queue, "0.2.0", "879e660aa1cebe8dc6f0aaaa6aa48b4875e89cd961d4a585fd128e0773b31a18", [:mix], [], "hexpm"}, "plug": {:hex, :plug, "1.7.2", "d7b7db7fbd755e8283b6c0a50be71ec0a3d67d9213d74422d9372effc8e87fd1", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}], "hexpm"}, "plug_cowboy": {:hex, :plug_cowboy, "2.0.2", "6055f16868cc4882b24b6e1d63d2bada94fb4978413377a3b32ac16c18dffba2", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, From 05cdb2f2389376081973d96b32e876d2a032d1f1 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 20 Apr 2019 19:42:50 +0700 Subject: [PATCH 08/56] Do not track coverage files --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 774893b35..8166e65e9 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ erl_crash.dump # Prevent committing docs files /priv/static/doc/* + +/cover From 724311e15177a1a97f533f11ff17d8d0146800ef Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 20 Apr 2019 19:57:43 +0700 Subject: [PATCH 09/56] Fix Credo warnings --- lib/pleroma/application.ex | 2 +- lib/pleroma/digest_email_worker.ex | 4 ++-- lib/pleroma/web/mailer/subscription_controller.ex | 4 +++- mix.exs | 2 +- test/notification_test.exs | 2 +- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 76f8d9bcd..299f8807b 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -187,7 +187,7 @@ defp hackney_pool_children do end end - defp after_supervisor_start() do + defp after_supervisor_start do with digest_config <- Application.get_env(:pleroma, :email_notifications)[:digest], true <- digest_config[:active], %Crontab.CronExpression{} = schedule <- diff --git a/lib/pleroma/digest_email_worker.ex b/lib/pleroma/digest_email_worker.ex index fa6067a03..7be470f5f 100644 --- a/lib/pleroma/digest_email_worker.ex +++ b/lib/pleroma/digest_email_worker.ex @@ -4,7 +4,7 @@ defmodule Pleroma.DigestEmailWorker do # alias Pleroma.User - def run() do + def run do Logger.warn("Running digester") config = Application.get_env(:pleroma, :email_notifications)[:digest] negative_interval = -Map.fetch!(config, :interval) @@ -14,7 +14,7 @@ def run() do now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) from(u in inactive_users_query, - where: fragment("? #> '{\"email_notifications\",\"digest\"}' @> 'true'", u.info), + where: fragment(~s(? #> '{"email_notifications","digest"}' @> 'true'), u.info), where: u.last_digest_emailed_at < datetime_add(^now, ^negative_interval, "day"), select: u ) diff --git a/lib/pleroma/web/mailer/subscription_controller.ex b/lib/pleroma/web/mailer/subscription_controller.ex index 2334ebacb..478a83518 100644 --- a/lib/pleroma/web/mailer/subscription_controller.ex +++ b/lib/pleroma/web/mailer/subscription_controller.ex @@ -1,7 +1,9 @@ defmodule Pleroma.Web.Mailer.SubscriptionController do use Pleroma.Web, :controller - alias Pleroma.{JWT, Repo, User} + alias Pleroma.JWT + alias Pleroma.Repo + alias Pleroma.User def unsubscribe(conn, %{"token" => encoded_token}) do with {:ok, token} <- Base.decode64(encoded_token), diff --git a/mix.exs b/mix.exs index 6bb105538..2cdfb1392 100644 --- a/mix.exs +++ b/mix.exs @@ -8,7 +8,7 @@ def project do elixir: "~> 1.7", elixirc_paths: elixirc_paths(Mix.env()), compilers: [:phoenix, :gettext] ++ Mix.compilers(), - # elixirc_options: [warnings_as_errors: true], + elixirc_options: [warnings_as_errors: true], xref: [exclude: [:eldap]], start_permanent: Mix.env() == :prod, aliases: aliases(), diff --git a/test/notification_test.exs b/test/notification_test.exs index 462398d75..3bbce8fcf 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -335,7 +335,7 @@ test "Returns recent notifications" do recent_notifications_ids = user2 |> Notification.for_user_since( - NaiveDateTime.add(NaiveDateTime.utc_now(), -5 * 86400, :second) + NaiveDateTime.add(NaiveDateTime.utc_now(), -5 * 86_400, :second) ) |> Enum.map(& &1.id) From 2359ee38b38de17df4dfe9cbdfe551bb7d9a034d Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sun, 21 Apr 2019 16:36:25 +0700 Subject: [PATCH 10/56] Set digest emails to false by default --- lib/pleroma/user/info.ex | 2 +- priv/repo/migrations/20190412052952_add_user_info_fields.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/user/info.ex b/lib/pleroma/user/info.ex index 194dd5581..d827293b8 100644 --- a/lib/pleroma/user/info.ex +++ b/lib/pleroma/user/info.ex @@ -42,7 +42,7 @@ defmodule Pleroma.User.Info do field(:hide_follows, :boolean, default: false) field(:pinned_activities, {:array, :string}, default: []) field(:flavour, :string, default: nil) - field(:email_notifications, :map, default: %{"digest" => true}) + field(:email_notifications, :map, default: %{"digest" => false}) field(:notification_settings, :map, default: %{"remote" => true, "local" => true, "followers" => true, "follows" => true} diff --git a/priv/repo/migrations/20190412052952_add_user_info_fields.exs b/priv/repo/migrations/20190412052952_add_user_info_fields.exs index 203d0fc3b..646c91f32 100644 --- a/priv/repo/migrations/20190412052952_add_user_info_fields.exs +++ b/priv/repo/migrations/20190412052952_add_user_info_fields.exs @@ -6,7 +6,7 @@ def up do UPDATE users SET info = info || '{ \"email_notifications\": { - \"digest\": true + \"digest\": false } }'") end From f1d90ee94206db00025d41b13a2906aa30d748f0 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sun, 21 Apr 2019 16:40:05 +0700 Subject: [PATCH 11/56] Remove debug code --- lib/pleroma/digest_email_worker.ex | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/lib/pleroma/digest_email_worker.ex b/lib/pleroma/digest_email_worker.ex index 7be470f5f..65013f77e 100644 --- a/lib/pleroma/digest_email_worker.ex +++ b/lib/pleroma/digest_email_worker.ex @@ -1,11 +1,7 @@ defmodule Pleroma.DigestEmailWorker do import Ecto.Query - require Logger - - # alias Pleroma.User def run do - Logger.warn("Running digester") config = Application.get_env(:pleroma, :email_notifications)[:digest] negative_interval = -Map.fetch!(config, :interval) inactivity_threshold = Map.fetch!(config, :inactivity_threshold) @@ -19,23 +15,14 @@ def run do select: u ) |> Pleroma.Repo.all() - |> run(:pre) - end - - defp run(v, :pre) do - Logger.warn("Running for #{length(v)} users") - run(v) + |> run() end defp run([]), do: :ok defp run([user | users]) do with %Swoosh.Email{} = email <- Pleroma.Emails.UserEmail.digest_email(user) do - Logger.warn("Sending to #{user.nickname}") Pleroma.Emails.Mailer.deliver_async(email) - else - _ -> - Logger.warn("Skipping #{user.nickname}") end Pleroma.User.touch_last_digest_emailed_at(user) From b87ad13803df59d88feb736c3d0ff9cf514989d7 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sun, 21 Apr 2019 19:36:31 +0700 Subject: [PATCH 12/56] Move comments for email_notifications config to docs --- config/config.exs | 5 ----- docs/config.md | 12 ++++++++++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/config/config.exs b/config/config.exs index 2663b1ebd..b1d506b59 100644 --- a/config/config.exs +++ b/config/config.exs @@ -468,14 +468,9 @@ config :pleroma, :email_notifications, digest: %{ - # Globally enable or disable digest emails active: true, - # When to send digest email, in crontab format (https://en.wikipedia.org/wiki/Cron) - # 0 0 * * 0 - once a week at midnight on Sunday morning schedule: "0 0 * * 0", - # Minimum interval between digest emails to one user interval: 7, - # Minimum user inactivity threshold inactivity_threshold: 7 } diff --git a/docs/config.md b/docs/config.md index 5a97033b2..69d389382 100644 --- a/docs/config.md +++ b/docs/config.md @@ -435,6 +435,18 @@ Authentication / authorization settings. * `oauth_consumer_template`: OAuth consumer mode authentication form template. By default it's `consumer.html` which corresponds to `lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex`. * `oauth_consumer_strategies`: the list of enabled OAuth consumer strategies; by default it's set by OAUTH_CONSUMER_STRATEGIES environment variable. +## :email_notifications + +Email notifications settings. + + - digest - emails of "what you've missed" for users who have been + inactive for a while. + - active: globally enable or disable digest emails + - schedule: When to send digest email, in [crontab format](https://en.wikipedia.org/wiki/Cron). + "0 0 * * 0" is the default, meaning "once a week at midnight on Sunday morning" + - interval: Minimum interval between digest emails to one user + - inactivity_threshold: Minimum user inactivity threshold + # OAuth consumer mode OAuth consumer mode allows sign in / sign up via external OAuth providers (e.g. Twitter, Facebook, Google, Microsoft, etc.). From 5cee2fe9fea4f0c98acd49a2a288ecd44bce3d1f Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Wed, 29 May 2019 21:31:27 +0300 Subject: [PATCH 13/56] Replace Application.get_env/2 with Pleroma.Config.get/1 --- lib/pleroma/digest_email_worker.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/digest_email_worker.ex b/lib/pleroma/digest_email_worker.ex index 65013f77e..f7b3d81cd 100644 --- a/lib/pleroma/digest_email_worker.ex +++ b/lib/pleroma/digest_email_worker.ex @@ -2,7 +2,7 @@ defmodule Pleroma.DigestEmailWorker do import Ecto.Query def run do - config = Application.get_env(:pleroma, :email_notifications)[:digest] + config = Pleroma.Config.get([:email_notifications, :digest]) negative_interval = -Map.fetch!(config, :interval) inactivity_threshold = Map.fetch!(config, :inactivity_threshold) inactive_users_query = Pleroma.User.list_inactive_users_query(inactivity_threshold) From 3e1761058711b12fa995f2b43117fb90ca40c9ad Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Tue, 4 Jun 2019 02:48:21 +0300 Subject: [PATCH 14/56] Add task to test emails --- lib/mix/tasks/pleroma/digest.ex | 34 +++++++++++++++ lib/pleroma/digest_email_worker.ex | 4 +- lib/pleroma/emails/user_email.ex | 20 +++++++-- .../web/templates/email/digest.html.eex | 4 +- test/mix/tasks/pleroma.digest_test.exs | 42 +++++++++++++++++++ 5 files changed, 96 insertions(+), 8 deletions(-) create mode 100644 lib/mix/tasks/pleroma/digest.ex create mode 100644 test/mix/tasks/pleroma.digest_test.exs diff --git a/lib/mix/tasks/pleroma/digest.ex b/lib/mix/tasks/pleroma/digest.ex new file mode 100644 index 000000000..7ac3df5c7 --- /dev/null +++ b/lib/mix/tasks/pleroma/digest.ex @@ -0,0 +1,34 @@ +defmodule Mix.Tasks.Pleroma.Digest do + use Mix.Task + alias Mix.Tasks.Pleroma.Common + + @shortdoc "Manages digest emails" + @moduledoc """ + Manages digest emails + + ## Send digest email since given date (user registration date by default) + ignoring user activity status. + + ``mix pleroma.digest test `` + + Example: ``mix pleroma.digest test donaldtheduck 2019-05-20`` + """ + def run(["test", nickname | opts]) do + Common.start_pleroma() + + user = Pleroma.User.get_by_nickname(nickname) + + last_digest_emailed_at = + with [date] <- opts, + {:ok, datetime} <- Timex.parse(date, "{YYYY}-{0M}-{0D}") do + datetime + else + _ -> user.inserted_at + end + + patched_user = %{user | last_digest_emailed_at: last_digest_emailed_at} + + :ok = Pleroma.DigestEmailWorker.run([patched_user]) + Mix.shell().info("Digest email have been sent to #{nickname} (#{user.email})") + end +end diff --git a/lib/pleroma/digest_email_worker.ex b/lib/pleroma/digest_email_worker.ex index f7b3d81cd..8c28dca18 100644 --- a/lib/pleroma/digest_email_worker.ex +++ b/lib/pleroma/digest_email_worker.ex @@ -18,9 +18,9 @@ def run do |> run() end - defp run([]), do: :ok + def run([]), do: :ok - defp run([user | users]) do + def run([user | users]) do with %Swoosh.Email{} = email <- Pleroma.Emails.UserEmail.digest_email(user) do Pleroma.Emails.Mailer.deliver_async(email) end diff --git a/lib/pleroma/emails/user_email.ex b/lib/pleroma/emails/user_email.ex index 64f855112..0ad0aed40 100644 --- a/lib/pleroma/emails/user_email.ex +++ b/lib/pleroma/emails/user_email.ex @@ -103,12 +103,24 @@ def digest_email(user) do new_notifications = Pleroma.Notification.for_user_since(user, user.last_digest_emailed_at) |> Enum.reduce(%{followers: [], mentions: []}, fn - %{activity: %{data: %{"type" => "Create"}, actor: actor}} = notification, acc -> - new_mention = %{data: notification, from: Pleroma.User.get_by_ap_id(actor)} + %{activity: %{data: %{"type" => "Create"}, actor: actor} = activity} = notification, + acc -> + new_mention = %{ + data: notification, + object: Pleroma.Object.normalize(activity), + from: Pleroma.User.get_by_ap_id(actor) + } + %{acc | mentions: [new_mention | acc.mentions]} - %{activity: %{data: %{"type" => "Follow"}, actor: actor}} = notification, acc -> - new_follower = %{data: notification, from: Pleroma.User.get_by_ap_id(actor)} + %{activity: %{data: %{"type" => "Follow"}, actor: actor} = activity} = notification, + acc -> + new_follower = %{ + data: notification, + object: Pleroma.Object.normalize(activity), + from: Pleroma.User.get_by_ap_id(actor) + } + %{acc | followers: [new_follower | acc.followers]} _, acc -> diff --git a/lib/pleroma/web/templates/email/digest.html.eex b/lib/pleroma/web/templates/email/digest.html.eex index 93c9c884f..c9dd699fd 100644 --- a/lib/pleroma/web/templates/email/digest.html.eex +++ b/lib/pleroma/web/templates/email/digest.html.eex @@ -2,8 +2,8 @@

New Mentions:

    -<%= for %{data: mention, from: from} <- @mentions do %> -
  • <%= link from.nickname, to: mention.activity.actor %>: <%= raw mention.activity.object.data["content"] %>
  • +<%= for %{data: mention, object: object, from: from} <- @mentions do %> +
  • <%= link from.nickname, to: mention.activity.actor %>: <%= raw object.data["content"] %>
  • <% end %>
diff --git a/test/mix/tasks/pleroma.digest_test.exs b/test/mix/tasks/pleroma.digest_test.exs new file mode 100644 index 000000000..1a54ac35b --- /dev/null +++ b/test/mix/tasks/pleroma.digest_test.exs @@ -0,0 +1,42 @@ +defmodule Mix.Tasks.Pleroma.DigestTest do + use Pleroma.DataCase + + import Pleroma.Factory + import Swoosh.TestAssertions + + alias Pleroma.Web.CommonAPI + + setup_all do + Mix.shell(Mix.Shell.Process) + + on_exit(fn -> + Mix.shell(Mix.Shell.IO) + end) + + :ok + end + + describe "pleroma.digest test" do + test "Sends digest to the given user" do + user1 = insert(:user) + user2 = insert(:user) + + Enum.each(0..10, fn i -> + {:ok, _activity} = + CommonAPI.post(user1, %{ + "status" => "hey ##{i} @#{user2.nickname}!" + }) + end) + + Mix.Tasks.Pleroma.Digest.run(["test", user2.nickname]) + + assert_email_sent( + to: {user2.name, user2.email}, + html_body: ~r/new mentions:/i + ) + + assert_received {:mix_shell, :info, [message]} + assert message =~ "Digest email have been sent" + end + end +end From bd325132ca337c57f63b6443ae44748d9a422f65 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Tue, 4 Jun 2019 03:07:49 +0300 Subject: [PATCH 15/56] Fix tests --- config/test.exs | 2 ++ test/mix/tasks/pleroma.digest_test.exs | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/config/test.exs b/config/test.exs index 41cddb9bd..adb874223 100644 --- a/config/test.exs +++ b/config/test.exs @@ -67,6 +67,8 @@ config :pleroma, :database, rum_enabled: rum_enabled IO.puts("RUM enabled: #{rum_enabled}") +config :joken, default_signer: "yU8uHKq+yyAkZ11Hx//jcdacWc8yQ1bxAAGrplzB0Zwwjkp35v0RK9SO8WTPr6QZ" + try do import_config "test.secret.exs" rescue diff --git a/test/mix/tasks/pleroma.digest_test.exs b/test/mix/tasks/pleroma.digest_test.exs index 1a54ac35b..3dafe05fe 100644 --- a/test/mix/tasks/pleroma.digest_test.exs +++ b/test/mix/tasks/pleroma.digest_test.exs @@ -30,13 +30,13 @@ test "Sends digest to the given user" do Mix.Tasks.Pleroma.Digest.run(["test", user2.nickname]) + assert_received {:mix_shell, :info, [message]} + assert message =~ "Digest email have been sent" + assert_email_sent( to: {user2.name, user2.email}, html_body: ~r/new mentions:/i ) - - assert_received {:mix_shell, :info, [message]} - assert message =~ "Digest email have been sent" end end end From 7718a215e9b20471169bf2474771a4fe486a3050 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Tue, 4 Jun 2019 03:08:00 +0300 Subject: [PATCH 16/56] Update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2144bbe28..fef10463a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased] ### Added +- Digest email for inactive users - Optional SSH access mode. (Needs `erlang-ssh` package on some distributions). - [MongooseIM](https://github.com/esl/MongooseIM) http authentication support. - LDAP authentication @@ -25,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Configuration: `notify_email` option - Configuration: Media proxy `whitelist` option - Configuration: `report_uri` option +- Configuration: `email_notifications` option - Pleroma API: User subscriptions - Pleroma API: Healthcheck endpoint - Pleroma API: `/api/v1/pleroma/mascot` per-user frontend mascot configuration endpoints From f6036ce3b9649902ce1c2af819616ad25f0caef1 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Tue, 4 Jun 2019 03:38:53 +0300 Subject: [PATCH 17/56] Fix tests --- test/mix/tasks/pleroma.digest_test.exs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/test/mix/tasks/pleroma.digest_test.exs b/test/mix/tasks/pleroma.digest_test.exs index 3dafe05fe..595f64ed7 100644 --- a/test/mix/tasks/pleroma.digest_test.exs +++ b/test/mix/tasks/pleroma.digest_test.exs @@ -28,9 +28,18 @@ test "Sends digest to the given user" do }) end) - Mix.Tasks.Pleroma.Digest.run(["test", user2.nickname]) + yesterday = + NaiveDateTime.add( + NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second), + -60 * 60 * 24, + :second + ) - assert_received {:mix_shell, :info, [message]} + {:ok, yesterday_date} = Timex.format(yesterday, "%F", :strftime) + + :ok = Mix.Tasks.Pleroma.Digest.run(["test", user2.nickname, yesterday_date]) + + assert_receive {:mix_shell, :info, [message]} assert message =~ "Digest email have been sent" assert_email_sent( From c0fa0001476a8a45878a0c75125627164497eddf Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Fri, 7 Jun 2019 01:22:35 +0300 Subject: [PATCH 18/56] Set default config for digest to false --- config/config.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/config.exs b/config/config.exs index 5a05ee043..509f4b081 100644 --- a/config/config.exs +++ b/config/config.exs @@ -494,7 +494,7 @@ config :pleroma, :email_notifications, digest: %{ - active: true, + active: false, schedule: "0 0 * * 0", interval: 7, inactivity_threshold: 7 From 0384459ce552c50edb582413808a099086b6495e Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Fri, 12 Jul 2019 18:16:54 +0300 Subject: [PATCH 19/56] Update mix.lock --- mix.lock | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mix.lock b/mix.lock index 654972ddd..a09022acb 100644 --- a/mix.lock +++ b/mix.lock @@ -35,6 +35,8 @@ "excoveralls": {:hex, :excoveralls, "0.11.1", "dd677fbdd49114fdbdbf445540ec735808250d56b011077798316505064edb2c", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, "floki": {:hex, :floki, "0.20.4", "be42ac911fece24b4c72f3b5846774b6e61b83fe685c2fc9d62093277fb3bc86", [:mix], [{:html_entities, "~> 0.4.0", [hex: :html_entities, repo: "hexpm", optional: false]}, {:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"}, "gen_smtp": {:hex, :gen_smtp, "0.14.0", "39846a03522456077c6429b4badfd1d55e5e7d0fdfb65e935b7c5e38549d9202", [:rebar3], [], "hexpm"}, + "gen_stage": {:hex, :gen_stage, "0.14.2", "6a2a578a510c5bfca8a45e6b27552f613b41cf584b58210f017088d3d17d0b14", [:mix], [], "hexpm"}, + "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"}, "gettext": {:hex, :gettext, "0.17.0", "abe21542c831887a2b16f4c94556db9c421ab301aee417b7c4fbde7fbdbe01ec", [:mix], [], "hexpm"}, "hackney": {:hex, :hackney, "1.15.1", "9f8f471c844b8ce395f7b6d8398139e26ddca9ebc171a8b91342ee15a19963f4", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, "html_entities": {:hex, :html_entities, "0.4.0", "f2fee876858cf6aaa9db608820a3209e45a087c5177332799592142b50e89a6b", [:mix], [], "hexpm"}, @@ -83,6 +85,7 @@ "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"}, "recon": {:git, "https://github.com/ferd/recon.git", "75d70c7c08926d2f24f1ee6de14ee50fe8a52763", [tag: "2.4.0"]}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"}, + "swarm": {:hex, :swarm, "3.4.0", "64f8b30055d74640d2186c66354b33b999438692a91be275bb89cdc7e401f448", [:mix], [{:gen_state_machine, "~> 2.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}, {:libring, "~> 1.0", [hex: :libring, repo: "hexpm", optional: false]}], "hexpm"}, "swoosh": {:hex, :swoosh, "0.23.2", "7dda95ff0bf54a2298328d6899c74dae1223777b43563ccebebb4b5d2b61df38", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm"}, "syslog": {:git, "https://github.com/Vagabond/erlang-syslog.git", "4a6c6f2c996483e86c1320e9553f91d337bcb6aa", [tag: "1.0.5"]}, "telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm"}, From 168dc97c37f274b258b04eb7e883640b84259714 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sun, 14 Jul 2019 22:04:55 +0300 Subject: [PATCH 20/56] Make opts optional in Pleroma.Notification.for_user_query/2 --- lib/pleroma/notification.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index f680fe049..04bbfa0df 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -33,7 +33,7 @@ def changeset(%Notification{} = notification, attrs) do |> cast(attrs, [:seen]) end - def for_user_query(user, opts) do + def for_user_query(user, opts \\ []) do query = Notification |> where(user_id: ^user.id) From b052a9d4d0323eb64c0a741a499906659a674244 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sun, 14 Jul 2019 22:32:11 +0300 Subject: [PATCH 21/56] Update DigestEmailWorker to compile and send emails via queue --- lib/mix/tasks/pleroma/digest.ex | 2 +- lib/pleroma/digest_email_worker.ex | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/lib/mix/tasks/pleroma/digest.ex b/lib/mix/tasks/pleroma/digest.ex index 19c4ce71e..81c207e10 100644 --- a/lib/mix/tasks/pleroma/digest.ex +++ b/lib/mix/tasks/pleroma/digest.ex @@ -27,7 +27,7 @@ def run(["test", nickname | opts]) do patched_user = %{user | last_digest_emailed_at: last_digest_emailed_at} - :ok = Pleroma.DigestEmailWorker.run([patched_user]) + _user = Pleroma.DigestEmailWorker.perform(patched_user) Mix.shell().info("Digest email have been sent to #{nickname} (#{user.email})") end end diff --git a/lib/pleroma/digest_email_worker.ex b/lib/pleroma/digest_email_worker.ex index 8c28dca18..adc24797f 100644 --- a/lib/pleroma/digest_email_worker.ex +++ b/lib/pleroma/digest_email_worker.ex @@ -1,6 +1,8 @@ defmodule Pleroma.DigestEmailWorker do import Ecto.Query + @queue_name :digest_emails + def run do config = Pleroma.Config.get([:email_notifications, :digest]) negative_interval = -Map.fetch!(config, :interval) @@ -15,18 +17,19 @@ def run do select: u ) |> Pleroma.Repo.all() - |> run() + |> Enum.each(&PleromaJobQueue.enqueue(@queue_name, __MODULE__, [&1])) end - def run([]), do: :ok - - def run([user | users]) do + @doc """ + Send digest email to the given user. + Updates `last_digest_emailed_at` field for the user and returns the updated user. + """ + @spec perform(Pleroma.User.t()) :: Pleroma.User.t() + def perform(user) do with %Swoosh.Email{} = email <- Pleroma.Emails.UserEmail.digest_email(user) do Pleroma.Emails.Mailer.deliver_async(email) end Pleroma.User.touch_last_digest_emailed_at(user) - - run(users) end end From e7c175c943e9e3f53df76d812c09cfeffdb1c56b Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Tue, 16 Jul 2019 16:49:50 +0300 Subject: [PATCH 22/56] Use PleromaJobQueue for scheduling --- lib/pleroma/application.ex | 18 ++++++------------ lib/pleroma/digest_email_worker.ex | 2 +- lib/pleroma/quantum_scheduler.ex | 4 ---- mix.exs | 4 ++-- mix.lock | 11 ++--------- 5 files changed, 11 insertions(+), 28 deletions(-) delete mode 100644 lib/pleroma/quantum_scheduler.ex diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 29cd14477..7df6bc9ae 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -115,10 +115,6 @@ def start(_type, _args) do %{ id: Pleroma.ScheduledActivityWorker, start: {Pleroma.ScheduledActivityWorker, :start_link, []} - }, - %{ - id: Pleroma.QuantumScheduler, - start: {Pleroma.QuantumScheduler, :start_link, []} } ] ++ hackney_pool_children() ++ @@ -231,14 +227,12 @@ defp hackney_pool_children do defp after_supervisor_start do with digest_config <- Application.get_env(:pleroma, :email_notifications)[:digest], - true <- digest_config[:active], - %Crontab.CronExpression{} = schedule <- - Crontab.CronExpression.Parser.parse!(digest_config[:schedule]) do - Pleroma.QuantumScheduler.new_job() - |> Quantum.Job.set_name(:digest_emails) - |> Quantum.Job.set_schedule(schedule) - |> Quantum.Job.set_task(&Pleroma.DigestEmailWorker.run/0) - |> Pleroma.QuantumScheduler.add_job() + true <- digest_config[:active] do + PleromaJobQueue.schedule( + digest_config[:schedule], + :digest_emails, + Pleroma.DigestEmailWorker + ) end :ok diff --git a/lib/pleroma/digest_email_worker.ex b/lib/pleroma/digest_email_worker.ex index adc24797f..18e67d39b 100644 --- a/lib/pleroma/digest_email_worker.ex +++ b/lib/pleroma/digest_email_worker.ex @@ -3,7 +3,7 @@ defmodule Pleroma.DigestEmailWorker do @queue_name :digest_emails - def run do + def perform do config = Pleroma.Config.get([:email_notifications, :digest]) negative_interval = -Map.fetch!(config, :interval) inactivity_threshold = Map.fetch!(config, :inactivity_threshold) diff --git a/lib/pleroma/quantum_scheduler.ex b/lib/pleroma/quantum_scheduler.ex deleted file mode 100644 index 9a3df81f6..000000000 --- a/lib/pleroma/quantum_scheduler.ex +++ /dev/null @@ -1,4 +0,0 @@ -defmodule Pleroma.QuantumScheduler do - use Quantum.Scheduler, - otp_app: :pleroma -end diff --git a/mix.exs b/mix.exs index a4f468726..25332deb9 100644 --- a/mix.exs +++ b/mix.exs @@ -140,7 +140,8 @@ defp deps do {:http_signatures, git: "https://git.pleroma.social/pleroma/http_signatures.git", ref: "9789401987096ead65646b52b5a2ca6bf52fc531"}, - {:pleroma_job_queue, "~> 0.2.0"}, + {:pleroma_job_queue, + git: "https://git.pleroma.social/pleroma/pleroma_job_queue.git", ref: "0637ccb1"}, {:telemetry, "~> 0.3"}, {:prometheus_ex, "~> 3.0"}, {:prometheus_plugs, "~> 1.1"}, @@ -148,7 +149,6 @@ defp deps do {:prometheus_ecto, "~> 1.4"}, {:recon, github: "ferd/recon", tag: "2.4.0"}, {:quack, "~> 0.1.1"}, - {:quantum, "~> 2.3"}, {:joken, "~> 2.0"}, {:benchee, "~> 1.0"}, {:esshd, "~> 0.1.0", runtime: Application.get_env(:esshd, :enabled, false)}, diff --git a/mix.lock b/mix.lock index 4f1214bca..3621d9c27 100644 --- a/mix.lock +++ b/mix.lock @@ -15,7 +15,7 @@ "cowboy": {:hex, :cowboy, "2.6.3", "99aa50e94e685557cad82e704457336a453d4abcb77839ad22dbe71f311fcc06", [:rebar3], [{:cowlib, "~> 2.7.3", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, "cowlib": {:hex, :cowlib, "2.7.3", "a7ffcd0917e6d50b4d5fb28e9e2085a0ceb3c97dea310505f7460ff5ed764ce9", [:rebar3], [], "hexpm"}, "credo": {:hex, :credo, "0.9.3", "76fa3e9e497ab282e0cf64b98a624aa11da702854c52c82db1bf24e54ab7c97a", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:poison, ">= 0.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, - "crontab": {:hex, :crontab, "1.1.5", "2c9439506ceb0e9045de75879e994b88d6f0be88bfe017d58cb356c66c4a5482", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, + "crontab": {:hex, :crontab, "1.1.7", "b9219f0bdc8678b94143655a8f229716c5810c0636a4489f98c0956137e53985", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, "crypt": {:git, "https://github.com/msantos/crypt", "1f2b58927ab57e72910191a7ebaeff984382a1d3", [ref: "1f2b58927ab57e72910191a7ebaeff984382a1d3"]}, "db_connection": {:hex, :db_connection, "2.0.6", "bde2f85d047969c5b5800cb8f4b3ed6316c8cb11487afedac4aa5f93fd39abfa", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"}, "decimal": {:hex, :decimal, "1.8.0", "ca462e0d885f09a1c5a342dbd7c1dcf27ea63548c65a65e67334f4b61803822e", [:mix], [], "hexpm"}, @@ -35,8 +35,6 @@ "excoveralls": {:hex, :excoveralls, "0.11.1", "dd677fbdd49114fdbdbf445540ec735808250d56b011077798316505064edb2c", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, "floki": {:hex, :floki, "0.20.4", "be42ac911fece24b4c72f3b5846774b6e61b83fe685c2fc9d62093277fb3bc86", [:mix], [{:html_entities, "~> 0.4.0", [hex: :html_entities, repo: "hexpm", optional: false]}, {:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"}, "gen_smtp": {:hex, :gen_smtp, "0.14.0", "39846a03522456077c6429b4badfd1d55e5e7d0fdfb65e935b7c5e38549d9202", [:rebar3], [], "hexpm"}, - "gen_stage": {:hex, :gen_stage, "0.14.2", "6a2a578a510c5bfca8a45e6b27552f613b41cf584b58210f017088d3d17d0b14", [:mix], [], "hexpm"}, - "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"}, "gettext": {:hex, :gettext, "0.17.0", "abe21542c831887a2b16f4c94556db9c421ab301aee417b7c4fbde7fbdbe01ec", [:mix], [], "hexpm"}, "hackney": {:hex, :hackney, "1.15.1", "9f8f471c844b8ce395f7b6d8398139e26ddca9ebc171a8b91342ee15a19963f4", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, "html_entities": {:hex, :html_entities, "0.4.0", "f2fee876858cf6aaa9db608820a3209e45a087c5177332799592142b50e89a6b", [:mix], [], "hexpm"}, @@ -47,7 +45,6 @@ "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, "joken": {:hex, :joken, "2.0.1", "ec9ab31bf660f343380da033b3316855197c8d4c6ef597fa3fcb451b326beb14", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm"}, "jose": {:hex, :jose, "1.9.0", "4167c5f6d06ffaebffd15cdb8da61a108445ef5e85ab8f5a7ad926fdf3ada154", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm"}, - "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"}, "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, "makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm"}, @@ -66,26 +63,22 @@ "phoenix_html": {:hex, :phoenix_html, "2.13.1", "fa8f034b5328e2dfa0e4131b5569379003f34bc1fafdaa84985b0b9d2f12e68b", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm"}, "phoenix_swoosh": {:hex, :phoenix_swoosh, "0.2.0", "a7e0b32077cd6d2323ae15198839b05d9caddfa20663fd85787479e81f89520e", [:mix], [{:phoenix, "~> 1.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.2", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:swoosh, "~> 0.1", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm"}, - "pleroma_job_queue": {:hex, :pleroma_job_queue, "0.2.0", "879e660aa1cebe8dc6f0aaaa6aa48b4875e89cd961d4a585fd128e0773b31a18", [:mix], [], "hexpm"}, + "pleroma_job_queue": {:git, "https://git.pleroma.social/pleroma/pleroma_job_queue.git", "0637ccb163bab951fc8cd8bcfa3e6c10f0cc0c66", [ref: "0637ccb1"]}, "plug": {:hex, :plug, "1.8.2", "0bcce1daa420f189a6491f3940cc77ea7fb1919761175c9c3b59800d897440fc", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"}, "plug_cowboy": {:hex, :plug_cowboy, "2.0.2", "6055f16868cc4882b24b6e1d63d2bada94fb4978413377a3b32ac16c18dffba2", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, "plug_static_index_html": {:hex, :plug_static_index_html, "1.0.0", "840123d4d3975585133485ea86af73cb2600afd7f2a976f9f5fd8b3808e636a0", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, - "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm"}, "postgrex": {:hex, :postgrex, "0.14.3", "5754dee2fdf6e9e508cbf49ab138df964278700b764177e8f3871e658b345a1e", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, "prometheus": {:hex, :prometheus, "4.2.2", "a830e77b79dc6d28183f4db050a7cac926a6c58f1872f9ef94a35cd989aceef8", [:mix, :rebar3], [], "hexpm"}, "prometheus_ecto": {:hex, :prometheus_ecto, "1.4.1", "6c768ea9654de871e5b32fab2eac348467b3021604ebebbcbd8bcbe806a65ed5", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm"}, "prometheus_ex": {:hex, :prometheus_ex, "3.0.5", "fa58cfd983487fc5ead331e9a3e0aa622c67232b3ec71710ced122c4c453a02f", [:mix], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm"}, "prometheus_phoenix": {:hex, :prometheus_phoenix, "1.2.1", "964a74dfbc055f781d3a75631e06ce3816a2913976d1df7830283aa3118a797a", [:mix], [{:phoenix, "~> 1.3", [hex: :phoenix, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.3 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm"}, "prometheus_plugs": {:hex, :prometheus_plugs, "1.1.5", "25933d48f8af3a5941dd7b621c889749894d8a1082a6ff7c67cc99dec26377c5", [:mix], [{:accept, "~> 0.1", [hex: :accept, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}, {:prometheus_process_collector, "~> 1.1", [hex: :prometheus_process_collector, repo: "hexpm", optional: true]}], "hexpm"}, - "prometheus_process_collector": {:hex, :prometheus_process_collector, "1.4.0", "6dbd39e3165b9ef1c94a7a820e9ffe08479f949dcdd431ed4aaea7b250eebfde", [:rebar3], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm"}, "quack": {:hex, :quack, "0.1.1", "cca7b4da1a233757fdb44b3334fce80c94785b3ad5a602053b7a002b5a8967bf", [:mix], [{:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: false]}, {:tesla, "~> 1.2.0", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm"}, - "quantum": {:hex, :quantum, "2.3.4", "72a0e8855e2adc101459eac8454787cb74ab4169de6ca50f670e72142d4960e9", [:mix], [{:calendar, "~> 0.17", [hex: :calendar, repo: "hexpm", optional: true]}, {:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.12", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:swarm, "~> 3.3", [hex: :swarm, repo: "hexpm", optional: false]}, {:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: true]}], "hexpm"}, "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"}, "recon": {:git, "https://github.com/ferd/recon.git", "75d70c7c08926d2f24f1ee6de14ee50fe8a52763", [tag: "2.4.0"]}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"}, - "swarm": {:hex, :swarm, "3.4.0", "64f8b30055d74640d2186c66354b33b999438692a91be275bb89cdc7e401f448", [:mix], [{:gen_state_machine, "~> 2.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}, {:libring, "~> 1.0", [hex: :libring, repo: "hexpm", optional: false]}], "hexpm"}, "swoosh": {:hex, :swoosh, "0.23.2", "7dda95ff0bf54a2298328d6899c74dae1223777b43563ccebebb4b5d2b61df38", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm"}, "syslog": {:git, "https://github.com/Vagabond/erlang-syslog.git", "4a6c6f2c996483e86c1320e9553f91d337bcb6aa", [tag: "1.0.5"]}, "telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm"}, From ae4fc58589ac48a0853719e6f83b2559b6de44fb Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sat, 20 Jul 2019 01:24:01 +0300 Subject: [PATCH 23/56] Remove flavour from userinfo --- lib/pleroma/user/info.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/pleroma/user/info.ex b/lib/pleroma/user/info.ex index ae2f66cf1..60b7a82ab 100644 --- a/lib/pleroma/user/info.ex +++ b/lib/pleroma/user/info.ex @@ -43,7 +43,6 @@ defmodule Pleroma.User.Info do field(:hide_follows, :boolean, default: false) field(:hide_favorites, :boolean, default: true) field(:pinned_activities, {:array, :string}, default: []) - field(:flavour, :string, default: nil) field(:email_notifications, :map, default: %{"digest" => false}) field(:mascot, :map, default: nil) field(:emoji, {:array, :map}, default: []) From afc7708dbe00a70be616f00f01b22b0d01b9b61b Mon Sep 17 00:00:00 2001 From: Roman Chvanikov Date: Sun, 21 Jul 2019 00:01:58 +0300 Subject: [PATCH 24/56] Fix pleroma_job_queue version --- mix.exs | 3 +-- mix.lock | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/mix.exs b/mix.exs index 0eb92bb12..315ac808d 100644 --- a/mix.exs +++ b/mix.exs @@ -140,8 +140,7 @@ defp deps do {:http_signatures, git: "https://git.pleroma.social/pleroma/http_signatures.git", ref: "293d77bb6f4a67ac8bde1428735c3b42f22cbb30"}, - {:pleroma_job_queue, - git: "https://git.pleroma.social/pleroma/pleroma_job_queue.git", ref: "0637ccb1"}, + {:pleroma_job_queue, "~> 0.3"}, {:telemetry, "~> 0.3"}, {:prometheus_ex, "~> 3.0"}, {:prometheus_plugs, "~> 1.1"}, diff --git a/mix.lock b/mix.lock index d14dcac21..e7c2f25fc 100644 --- a/mix.lock +++ b/mix.lock @@ -63,7 +63,7 @@ "phoenix_html": {:hex, :phoenix_html, "2.13.1", "fa8f034b5328e2dfa0e4131b5569379003f34bc1fafdaa84985b0b9d2f12e68b", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm"}, "phoenix_swoosh": {:hex, :phoenix_swoosh, "0.2.0", "a7e0b32077cd6d2323ae15198839b05d9caddfa20663fd85787479e81f89520e", [:mix], [{:phoenix, "~> 1.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.2", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:swoosh, "~> 0.1", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm"}, - "pleroma_job_queue": {:git, "https://git.pleroma.social/pleroma/pleroma_job_queue.git", "0637ccb163bab951fc8cd8bcfa3e6c10f0cc0c66", [ref: "0637ccb1"]}, + "pleroma_job_queue": {:hex, :pleroma_job_queue, "0.3.0", "b84538d621f0c3d6fcc1cff9d5648d3faaf873b8b21b94e6503428a07a48ec47", [:mix], [{:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}], "hexpm"}, "plug": {:hex, :plug, "1.8.2", "0bcce1daa420f189a6491f3940cc77ea7fb1919761175c9c3b59800d897440fc", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"}, "plug_cowboy": {:hex, :plug_cowboy, "2.0.2", "6055f16868cc4882b24b6e1d63d2bada94fb4978413377a3b32ac16c18dffba2", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, From 9ca45063556f3b75860d516577776a00536e90a8 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Thu, 1 Aug 2019 15:53:37 +0700 Subject: [PATCH 25/56] Add configurable length limits for `User.bio` and `User.name` --- config/config.exs | 2 ++ docs/config.md | 2 ++ lib/pleroma/user.ex | 38 +++++++++++++++++++++----------------- test/user_test.exs | 5 ++++- 4 files changed, 29 insertions(+), 18 deletions(-) diff --git a/config/config.exs b/config/config.exs index 17770640a..aa4cdf409 100644 --- a/config/config.exs +++ b/config/config.exs @@ -253,6 +253,8 @@ skip_thread_containment: true, limit_to_local_content: :unauthenticated, dynamic_configuration: false, + user_bio_length: 5000, + user_name_length: 100, external_user_synchronization: true config :pleroma, :markup, diff --git a/docs/config.md b/docs/config.md index 02f86dc16..8f58eaf06 100644 --- a/docs/config.md +++ b/docs/config.md @@ -125,6 +125,8 @@ config :pleroma, Pleroma.Emails.Mailer, * `safe_dm_mentions`: If set to true, only mentions at the beginning of a post will be used to address people in direct messages. This is to prevent accidental mentioning of people when talking about them (e.g. "@friend hey i really don't like @enemy"). Default: `false`. * `healthcheck`: If set to true, system data will be shown on ``/api/pleroma/healthcheck``. * `remote_post_retention_days`: The default amount of days to retain remote posts when pruning the database. +* `user_bio_length`: A user bio maximum length (default: `5000`) +* `user_name_length`: A user name maximum length (default: `100`) * `skip_thread_containment`: Skip filter out broken threads. The default is `false`. * `limit_to_local_content`: Limit unauthenticated users to search for local statutes and users only. Possible values: `:unauthenticated`, `:all` and `false`. The default is `:unauthenticated`. * `dynamic_configuration`: Allow transferring configuration to DB with the subsequent customization from Admin api. diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 1adb82f32..776dbbe6d 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -149,10 +149,10 @@ def following_count(%User{} = user) do end def remote_user_creation(params) do - params = - params - |> Map.put(:info, params[:info] || %{}) + bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000) + name_limit = Pleroma.Config.get([:instance, :user_name_length], 100) + params = Map.put(params, :info, params[:info] || %{}) info_cng = User.Info.remote_user_creation(%User.Info{}, params[:info]) changes = @@ -161,8 +161,8 @@ def remote_user_creation(params) do |> validate_required([:name, :ap_id]) |> unique_constraint(:nickname) |> validate_format(:nickname, @email_regex) - |> validate_length(:bio, max: 5000) - |> validate_length(:name, max: 100) + |> validate_length(:bio, max: bio_limit) + |> validate_length(:name, max: name_limit) |> put_change(:local, false) |> put_embed(:info, info_cng) @@ -185,22 +185,23 @@ def remote_user_creation(params) do end def update_changeset(struct, params \\ %{}) do + bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000) + name_limit = Pleroma.Config.get([:instance, :user_name_length], 100) + struct |> cast(params, [:bio, :name, :avatar, :following]) |> unique_constraint(:nickname) |> validate_format(:nickname, local_nickname_regex()) - |> validate_length(:bio, max: 5000) - |> validate_length(:name, min: 1, max: 100) + |> validate_length(:bio, max: bio_limit) + |> validate_length(:name, min: 1, max: name_limit) end def upgrade_changeset(struct, params \\ %{}) do - params = - params - |> Map.put(:last_refreshed_at, NaiveDateTime.utc_now()) + bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000) + name_limit = Pleroma.Config.get([:instance, :user_name_length], 100) - info_cng = - struct.info - |> User.Info.user_upgrade(params[:info]) + params = Map.put(params, :last_refreshed_at, NaiveDateTime.utc_now()) + info_cng = User.Info.user_upgrade(struct.info, params[:info]) struct |> cast(params, [ @@ -213,8 +214,8 @@ def upgrade_changeset(struct, params \\ %{}) do ]) |> unique_constraint(:nickname) |> validate_format(:nickname, local_nickname_regex()) - |> validate_length(:bio, max: 5000) - |> validate_length(:name, max: 100) + |> validate_length(:bio, max: bio_limit) + |> validate_length(:name, max: name_limit) |> put_embed(:info, info_cng) end @@ -241,6 +242,9 @@ def reset_password(%User{id: user_id} = user, data) do end def register_changeset(struct, params \\ %{}, opts \\ []) do + bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000) + name_limit = Pleroma.Config.get([:instance, :user_name_length], 100) + need_confirmation? = if is_nil(opts[:need_confirmation]) do Pleroma.Config.get([:instance, :account_activation_required]) @@ -261,8 +265,8 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do |> validate_exclusion(:nickname, Pleroma.Config.get([User, :restricted_nicknames])) |> validate_format(:nickname, local_nickname_regex()) |> validate_format(:email, @email_regex) - |> validate_length(:bio, max: 1000) - |> validate_length(:name, min: 1, max: 100) + |> validate_length(:bio, max: bio_limit) + |> validate_length(:name, min: 1, max: name_limit) |> put_change(:info, info_change) changeset = diff --git a/test/user_test.exs b/test/user_test.exs index 556df45fd..dfa91a106 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -525,7 +525,10 @@ test "it has required fields" do end test "it restricts some sizes" do - [bio: 5000, name: 100] + bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000) + name_limit = Pleroma.Config.get([:instance, :user_name_length], 100) + + [bio: bio_limit, name: name_limit] |> Enum.each(fn {field, size} -> string = String.pad_leading(".", size) cs = User.remote_user_creation(Map.put(@valid_remote, field, string)) From bbd9ed02576f1599e90f8575573fe6e935d32eae Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn Date: Mon, 5 Aug 2019 15:33:34 +0700 Subject: [PATCH 26/56] Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd64b2259..e9d4e1710 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Added synchronization of following/followers counters for external users - Configuration: `enabled` option for `Pleroma.Emails.Mailer`, defaulting to `false`. - Configuration: Pleroma.Plugs.RateLimiter `bucket_name`, `params` options. +- Configuration: `user_bio_length` and `user_name_length` options. - Addressable lists - Twitter API: added rate limit for `/api/account/password_reset` endpoint. - ActivityPub: Add an internal service actor for fetching ActivityPub objects. From 409bcad54b5de631536761952faed05ad5fe3b99 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 9 Aug 2019 16:49:09 +0300 Subject: [PATCH 27/56] Mastodon API: Set follower/following counters to 0 when hiding followers/following is enabled We are already doing that in AP representation, so I think we should do it here as well for consistency. --- CHANGELOG.md | 1 + .../web/mastodon_api/views/account_view.ex | 11 ++++++-- test/web/mastodon_api/account_view_test.exs | 27 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dccc36965..5d08fe757 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Federation/MediaProxy not working with instances that have wrong certificate order - Mastodon API: Handling of search timeouts (`/api/v1/search` and `/api/v2/search`) - Mastodon API: Embedded relationships not being properly rendered in the Account entity of Status entity +- Mastodon API: follower/following counters not being nullified, when `hide_follows`/`hide_followers` is set - Mastodon API: Add `account_id`, `type`, `offset`, and `limit` to search API (`/api/v1/search` and `/api/v2/search`) - Mastodon API, streaming: Fix filtering of notifications based on blocks/mutes/thread mutes - ActivityPub C2S: follower/following collection pages being inaccessible even when authentifucated if `hide_followers`/ `hide_follows` was set diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index de084fd6e..72c092f25 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -72,6 +72,13 @@ defp do_render("account.json", %{user: user} = opts) do image = User.avatar_url(user) |> MediaProxy.url() header = User.banner_url(user) |> MediaProxy.url() user_info = User.get_cached_user_info(user) + + following_count = + ((!user.info.hide_follows or opts[:for] == user) && user_info.following_count) || 0 + + followers_count = + ((!user.info.hide_followers or opts[:for] == user) && user_info.follower_count) || 0 + bot = (user.info.source_data["type"] || "Person") in ["Application", "Service"] emojis = @@ -102,8 +109,8 @@ defp do_render("account.json", %{user: user} = opts) do display_name: display_name, locked: user_info.locked, created_at: Utils.to_masto_date(user.inserted_at), - followers_count: user_info.follower_count, - following_count: user_info.following_count, + followers_count: followers_count, + following_count: following_count, statuses_count: user_info.note_count, note: bio || "", url: User.profile_url(user), diff --git a/test/web/mastodon_api/account_view_test.exs b/test/web/mastodon_api/account_view_test.exs index 905e9af98..a26f514a5 100644 --- a/test/web/mastodon_api/account_view_test.exs +++ b/test/web/mastodon_api/account_view_test.exs @@ -356,4 +356,31 @@ test "sanitizes display names" do result = AccountView.render("account.json", %{user: user}) refute result.display_name == " username " end + + describe "hiding follows/following" do + test "shows when follows/following are hidden and sets follower/following count to 0" do + user = insert(:user, info: %{hide_followers: true, hide_follows: true}) + other_user = insert(:user) + {:ok, user, other_user, _activity} = CommonAPI.follow(user, other_user) + {:ok, _other_user, user, _activity} = CommonAPI.follow(other_user, user) + + assert %{ + followers_count: 0, + following_count: 0, + pleroma: %{hide_follows: true, hide_followers: true} + } = AccountView.render("account.json", %{user: user}) + end + + test "shows actual follower/following count to the account owner" do + user = insert(:user, info: %{hide_followers: true, hide_follows: true}) + other_user = insert(:user) + {:ok, user, other_user, _activity} = CommonAPI.follow(user, other_user) + {:ok, _other_user, user, _activity} = CommonAPI.follow(other_user, user) + + assert %{ + followers_count: 1, + following_count: 1 + } = AccountView.render("account.json", %{user: user, for: user}) + end + end end From bb9c53958038bb74ad76a9d887b15e6decb5249c Mon Sep 17 00:00:00 2001 From: Maksim Date: Sat, 10 Aug 2019 11:27:59 +0000 Subject: [PATCH 28/56] Uploader.S3 added support stream uploads --- lib/pleroma/uploaders/s3.ex | 12 ++--- mix.exs | 3 +- mix.lock | 1 + test/uploaders/s3_test.exs | 90 +++++++++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 test/uploaders/s3_test.exs diff --git a/lib/pleroma/uploaders/s3.ex b/lib/pleroma/uploaders/s3.ex index 521daa93b..8c353bed3 100644 --- a/lib/pleroma/uploaders/s3.ex +++ b/lib/pleroma/uploaders/s3.ex @@ -6,10 +6,12 @@ defmodule Pleroma.Uploaders.S3 do @behaviour Pleroma.Uploaders.Uploader require Logger + alias Pleroma.Config + # The file name is re-encoded with S3's constraints here to comply with previous # links with less strict filenames def get_file(file) do - config = Pleroma.Config.get([__MODULE__]) + config = Config.get([__MODULE__]) bucket = Keyword.fetch!(config, :bucket) bucket_with_namespace = @@ -34,15 +36,15 @@ def get_file(file) do end def put_file(%Pleroma.Upload{} = upload) do - config = Pleroma.Config.get([__MODULE__]) + config = Config.get([__MODULE__]) bucket = Keyword.get(config, :bucket) - {:ok, file_data} = File.read(upload.tempfile) - s3_name = strict_encode(upload.path) op = - ExAws.S3.put_object(bucket, s3_name, file_data, [ + upload.tempfile + |> ExAws.S3.Upload.stream_file() + |> ExAws.S3.upload(bucket, s3_name, [ {:acl, :public_read}, {:content_type, upload.content_type} ]) diff --git a/mix.exs b/mix.exs index ac175dfed..334fabb33 100644 --- a/mix.exs +++ b/mix.exs @@ -114,8 +114,9 @@ defp deps do {:tesla, "~> 1.2"}, {:jason, "~> 1.0"}, {:mogrify, "~> 0.6.1"}, - {:ex_aws, "~> 2.0"}, + {:ex_aws, "~> 2.1"}, {:ex_aws_s3, "~> 2.0"}, + {:sweet_xml, "~> 0.6.6"}, {:earmark, "~> 1.3"}, {:bbcode, "~> 0.1.1"}, {:ex_machina, "~> 2.3", only: :test}, diff --git a/mix.lock b/mix.lock index 13728d11f..f8ee80c83 100644 --- a/mix.lock +++ b/mix.lock @@ -80,6 +80,7 @@ "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"}, "recon": {:git, "https://github.com/ferd/recon.git", "75d70c7c08926d2f24f1ee6de14ee50fe8a52763", [tag: "2.4.0"]}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"}, + "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm"}, "swoosh": {:hex, :swoosh, "0.23.2", "7dda95ff0bf54a2298328d6899c74dae1223777b43563ccebebb4b5d2b61df38", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm"}, "syslog": {:git, "https://github.com/Vagabond/erlang-syslog.git", "4a6c6f2c996483e86c1320e9553f91d337bcb6aa", [tag: "1.0.5"]}, "telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm"}, diff --git a/test/uploaders/s3_test.exs b/test/uploaders/s3_test.exs new file mode 100644 index 000000000..a0a1cfdf0 --- /dev/null +++ b/test/uploaders/s3_test.exs @@ -0,0 +1,90 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Uploaders.S3Test do + use Pleroma.DataCase + + alias Pleroma.Config + alias Pleroma.Uploaders.S3 + + import Mock + import ExUnit.CaptureLog + + setup do + config = Config.get([Pleroma.Uploaders.S3]) + + Config.put([Pleroma.Uploaders.S3], + bucket: "test_bucket", + public_endpoint: "https://s3.amazonaws.com" + ) + + on_exit(fn -> + Config.put([Pleroma.Uploaders.S3], config) + end) + + :ok + end + + describe "get_file/1" do + test "it returns path to local folder for files" do + assert S3.get_file("test_image.jpg") == { + :ok, + {:url, "https://s3.amazonaws.com/test_bucket/test_image.jpg"} + } + end + + test "it returns path without bucket when truncated_namespace set to ''" do + Config.put([Pleroma.Uploaders.S3], + bucket: "test_bucket", + public_endpoint: "https://s3.amazonaws.com", + truncated_namespace: "" + ) + + assert S3.get_file("test_image.jpg") == { + :ok, + {:url, "https://s3.amazonaws.com/test_image.jpg"} + } + end + + test "it returns path with bucket namespace when namespace is set" do + Config.put([Pleroma.Uploaders.S3], + bucket: "test_bucket", + public_endpoint: "https://s3.amazonaws.com", + bucket_namespace: "family" + ) + + assert S3.get_file("test_image.jpg") == { + :ok, + {:url, "https://s3.amazonaws.com/family:test_bucket/test_image.jpg"} + } + end + end + + describe "put_file/1" do + setup do + file_upload = %Pleroma.Upload{ + name: "image-tet.jpg", + content_type: "image/jpg", + path: "test_folder/image-tet.jpg", + tempfile: Path.absname("test/fixtures/image_tmp.jpg") + } + + [file_upload: file_upload] + end + + test "save file", %{file_upload: file_upload} do + with_mock ExAws, request: fn _ -> {:ok, :ok} end do + assert S3.put_file(file_upload) == {:ok, {:file, "test_folder/image-tet.jpg"}} + end + end + + test "returns error", %{file_upload: file_upload} do + with_mock ExAws, request: fn _ -> {:error, "S3 Upload failed"} end do + assert capture_log(fn -> + assert S3.put_file(file_upload) == {:error, "S3 Upload failed"} + end) =~ "Elixir.Pleroma.Uploaders.S3: {:error, \"S3 Upload failed\"}" + end + end + end +end From 0802a08871afee7f09362cbca8b802f0e27ff4b9 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sat, 10 Aug 2019 16:27:46 +0300 Subject: [PATCH 29/56] Mastodon API: Fix thread mute detection It was calling CommonAPI.thread_muted? with post author's account instead of viewer's one. --- CHANGELOG.md | 1 + lib/pleroma/web/mastodon_api/views/status_view.ex | 2 +- test/web/mastodon_api/mastodon_api_controller_test.exs | 4 +++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dccc36965..31caef499 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Federation/MediaProxy not working with instances that have wrong certificate order - Mastodon API: Handling of search timeouts (`/api/v1/search` and `/api/v2/search`) - Mastodon API: Embedded relationships not being properly rendered in the Account entity of Status entity +- Mastodon API: `muted` in the Status entity, using author's account to determine if the tread was muted - Mastodon API: Add `account_id`, `type`, `offset`, and `limit` to search API (`/api/v1/search` and `/api/v2/search`) - Mastodon API, streaming: Fix filtering of notifications based on blocks/mutes/thread mutes - ActivityPub C2S: follower/following collection pages being inaccessible even when authentifucated if `hide_followers`/ `hide_follows` was set diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 80df9b2ac..02819e116 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -168,7 +168,7 @@ def render("status.json", %{activity: %{data: %{"object" => _object}} = activity thread_muted? = case activity.thread_muted? do thread_muted? when is_boolean(thread_muted?) -> thread_muted? - nil -> CommonAPI.thread_muted?(user, activity) + nil -> (opts[:for] && CommonAPI.thread_muted?(opts[:for], activity)) || false end attachment_data = object.data["attachment"] || [] diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index e49c4cc22..b023d1e4f 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -2901,8 +2901,10 @@ test "bookmarks" do describe "conversation muting" do setup do + post_user = insert(:user) user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{"status" => "HIE"}) + + {:ok, activity} = CommonAPI.post(post_user, %{"status" => "HIE"}) [user: user, activity: activity] end From 11d08c2de0226caed8119bb3a45a8e0ab8791fbe Mon Sep 17 00:00:00 2001 From: Maksim Date: Sat, 10 Aug 2019 18:46:26 +0000 Subject: [PATCH 30/56] tests for Pleroma.Uploaders --- docs/config.md | 1 + lib/pleroma/uploaders/local.ex | 4 +-- lib/pleroma/uploaders/mdii.ex | 2 ++ test/uploaders/local_test.exs | 32 ++++++++++++++++++++++ test/uploaders/mdii_test.exs | 50 ++++++++++++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 test/uploaders/local_test.exs create mode 100644 test/uploaders/mdii_test.exs diff --git a/docs/config.md b/docs/config.md index 703ef67dd..55311b76d 100644 --- a/docs/config.md +++ b/docs/config.md @@ -18,6 +18,7 @@ Note: `strip_exif` has been replaced by `Pleroma.Upload.Filter.Mogrify`. ## Pleroma.Uploaders.S3 * `bucket`: S3 bucket name +* `bucket_namespace`: S3 bucket namespace * `public_endpoint`: S3 endpoint that the user finally accesses(ex. "https://s3.dualstack.ap-northeast-1.amazonaws.com") * `truncated_namespace`: If you use S3 compatible service such as Digital Ocean Spaces or CDN, set folder name or "" etc. For example, when using CDN to S3 virtual host format, set "". diff --git a/lib/pleroma/uploaders/local.ex b/lib/pleroma/uploaders/local.ex index fc533da23..36b3c35ec 100644 --- a/lib/pleroma/uploaders/local.ex +++ b/lib/pleroma/uploaders/local.ex @@ -11,7 +11,7 @@ def get_file(_) do def put_file(upload) do {local_path, file} = - case Enum.reverse(String.split(upload.path, "/", trim: true)) do + case Enum.reverse(Path.split(upload.path)) do [file] -> {upload_path(), file} @@ -23,7 +23,7 @@ def put_file(upload) do result_file = Path.join(local_path, file) - unless File.exists?(result_file) do + if not File.exists?(result_file) do File.cp!(upload.tempfile, result_file) end diff --git a/lib/pleroma/uploaders/mdii.ex b/lib/pleroma/uploaders/mdii.ex index 237544337..c36f3d61d 100644 --- a/lib/pleroma/uploaders/mdii.ex +++ b/lib/pleroma/uploaders/mdii.ex @@ -3,6 +3,8 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Uploaders.MDII do + @moduledoc "Represents uploader for https://github.com/hakaba-hitoyo/minimal-digital-image-infrastructure" + alias Pleroma.Config alias Pleroma.HTTP diff --git a/test/uploaders/local_test.exs b/test/uploaders/local_test.exs new file mode 100644 index 000000000..fc442d0f1 --- /dev/null +++ b/test/uploaders/local_test.exs @@ -0,0 +1,32 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Uploaders.LocalTest do + use Pleroma.DataCase + alias Pleroma.Uploaders.Local + + describe "get_file/1" do + test "it returns path to local folder for files" do + assert Local.get_file("") == {:ok, {:static_dir, "test/uploads"}} + end + end + + describe "put_file/1" do + test "put file to local folder" do + file_path = "local_upload/files/image.jpg" + + file = %Pleroma.Upload{ + name: "image.jpg", + content_type: "image/jpg", + path: file_path, + tempfile: Path.absname("test/fixtures/image_tmp.jpg") + } + + assert Local.put_file(file) == :ok + + assert Path.join([Local.upload_path(), file_path]) + |> File.exists?() + end + end +end diff --git a/test/uploaders/mdii_test.exs b/test/uploaders/mdii_test.exs new file mode 100644 index 000000000..d432d40f0 --- /dev/null +++ b/test/uploaders/mdii_test.exs @@ -0,0 +1,50 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Uploaders.MDIITest do + use Pleroma.DataCase + alias Pleroma.Uploaders.MDII + import Tesla.Mock + + describe "get_file/1" do + test "it returns path to local folder for files" do + assert MDII.get_file("") == {:ok, {:static_dir, "test/uploads"}} + end + end + + describe "put_file/1" do + setup do + file_upload = %Pleroma.Upload{ + name: "mdii-image.jpg", + content_type: "image/jpg", + path: "test_folder/mdii-image.jpg", + tempfile: Path.absname("test/fixtures/image_tmp.jpg") + } + + [file_upload: file_upload] + end + + test "save file", %{file_upload: file_upload} do + mock(fn + %{method: :post, url: "https://mdii.sakura.ne.jp/mdii-post.cgi?jpg"} -> + %Tesla.Env{status: 200, body: "mdii-image"} + end) + + assert MDII.put_file(file_upload) == + {:ok, {:url, "https://mdii.sakura.ne.jp/mdii-image.jpg"}} + end + + test "save file to local if MDII isn`t available", %{file_upload: file_upload} do + mock(fn + %{method: :post, url: "https://mdii.sakura.ne.jp/mdii-post.cgi?jpg"} -> + %Tesla.Env{status: 500} + end) + + assert MDII.put_file(file_upload) == :ok + + assert Path.join([Pleroma.Uploaders.Local.upload_path(), file_upload.path]) + |> File.exists?() + end + end +end From af4cf35e2096a6d1660271f6935b6b9ce77c6757 Mon Sep 17 00:00:00 2001 From: Sergey Suprunenko Date: Sat, 10 Aug 2019 18:47:40 +0000 Subject: [PATCH 31/56] Strip internal fields including likes from incoming and outgoing activities --- CHANGELOG.md | 2 ++ lib/mix/tasks/pleroma/database.ex | 36 +++++++++++++++++++ .../web/activity_pub/transmogrifier.ex | 34 ++---------------- test/tasks/database_test.exs | 36 +++++++++++++++++++ test/web/activity_pub/transmogrifier_test.exs | 30 +++++++++++----- 5 files changed, 98 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31caef499..759779034 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Admin API: Endpoint for fetching latest user's statuses - Pleroma API: Add `/api/v1/pleroma/accounts/confirmation_resend?email=` for resending account confirmation. - Relays: Added a task to list relay subscriptions. +- Mix Tasks: `mix pleroma.database fix_likes_collections` +- Federation: Remove `likes` from objects. ### Changed - Configuration: Filter.AnonymizeFilename added ability to retain file extension with custom text diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index 8547a329a..bcc2052d6 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -36,6 +36,10 @@ defmodule Mix.Tasks.Pleroma.Database do ## Remove duplicated items from following and update followers count for all users mix pleroma.database update_users_following_followers_counts + + ## Fix the pre-existing "likes" collections for all objects + + mix pleroma.database fix_likes_collections """ def run(["remove_embedded_objects" | args]) do {options, [], []} = @@ -125,4 +129,36 @@ def run(["prune_objects" | args]) do ) end end + + def run(["fix_likes_collections"]) do + import Ecto.Query + + start_pleroma() + + from(object in Object, + where: fragment("(?)->>'likes' is not null", object.data), + select: %{id: object.id, likes: fragment("(?)->>'likes'", object.data)} + ) + |> Pleroma.RepoStreamer.chunk_stream(100) + |> Stream.each(fn objects -> + ids = + objects + |> Enum.filter(fn object -> object.likes |> Jason.decode!() |> is_map() end) + |> Enum.map(& &1.id) + + Object + |> where([object], object.id in ^ids) + |> update([object], + set: [ + data: + fragment( + "jsonb_set(?, '{likes}', '[]'::jsonb, true)", + object.data + ) + ] + ) + |> Repo.update_all([], timeout: :infinity) + end) + |> Stream.run() + end end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 5403b71d8..b7bc48f0a 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -26,6 +26,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do """ def fix_object(object, options \\ []) do object + |> strip_internal_fields |> fix_actor |> fix_url |> fix_attachments @@ -34,7 +35,6 @@ def fix_object(object, options \\ []) do |> fix_emoji |> fix_tag |> fix_content_map - |> fix_likes |> fix_addressing |> fix_summary |> fix_type(options) @@ -151,20 +151,6 @@ def fix_actor(%{"attributedTo" => actor} = object) do |> Map.put("actor", Containment.get_actor(%{"actor" => actor})) end - # Check for standardisation - # This is what Peertube does - # curl -H 'Accept: application/activity+json' $likes | jq .totalItems - # Prismo returns only an integer (count) as "likes" - def fix_likes(%{"likes" => likes} = object) when not is_map(likes) do - object - |> Map.put("likes", []) - |> Map.put("like_count", 0) - end - - def fix_likes(object) do - object - end - def fix_in_reply_to(object, options \\ []) def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object, options) @@ -784,7 +770,6 @@ def prepare_object(object) do |> add_mention_tags |> add_emoji_tags |> add_attributed_to - |> add_likes |> prepare_attachments |> set_conversation |> set_reply_to_uri @@ -971,22 +956,6 @@ def add_attributed_to(object) do |> Map.put("attributedTo", attributed_to) end - def add_likes(%{"id" => id, "like_count" => likes} = object) do - likes = %{ - "id" => "#{id}/likes", - "first" => "#{id}/likes?page=1", - "type" => "OrderedCollection", - "totalItems" => likes - } - - object - |> Map.put("likes", likes) - end - - def add_likes(object) do - object - end - def prepare_attachments(object) do attachments = (object["attachment"] || []) @@ -1002,6 +971,7 @@ def prepare_attachments(object) do defp strip_internal_fields(object) do object |> Map.drop([ + "likes", "like_count", "announcements", "announcement_count", diff --git a/test/tasks/database_test.exs b/test/tasks/database_test.exs index 579130b05..a8f25f500 100644 --- a/test/tasks/database_test.exs +++ b/test/tasks/database_test.exs @@ -3,8 +3,11 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Mix.Tasks.Pleroma.DatabaseTest do + alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User + alias Pleroma.Web.CommonAPI + use Pleroma.DataCase import Pleroma.Factory @@ -46,4 +49,37 @@ test "following and followers count are updated" do assert user.info.follower_count == 0 end end + + describe "running fix_likes_collections" do + test "it turns OrderedCollection likes into empty arrays" do + [user, user2] = insert_pair(:user) + + {:ok, %{id: id, object: object}} = CommonAPI.post(user, %{"status" => "test"}) + {:ok, %{object: object2}} = CommonAPI.post(user, %{"status" => "test test"}) + + CommonAPI.favorite(id, user2) + + likes = %{ + "first" => + "http://mastodon.example.org/objects/dbdbc507-52c8-490d-9b7c-1e1d52e5c132/likes?page=1", + "id" => "http://mastodon.example.org/objects/dbdbc507-52c8-490d-9b7c-1e1d52e5c132/likes", + "totalItems" => 3, + "type" => "OrderedCollection" + } + + new_data = Map.put(object2.data, "likes", likes) + + object2 + |> Ecto.Changeset.change(%{data: new_data}) + |> Repo.update() + + assert length(Object.get_by_id(object.id).data["likes"]) == 1 + assert is_map(Object.get_by_id(object2.id).data["likes"]) + + assert :ok == Mix.Tasks.Pleroma.Database.run(["fix_likes_collections"]) + + assert length(Object.get_by_id(object.id).data["likes"]) == 1 + assert Enum.empty?(Object.get_by_id(object2.id).data["likes"]) + end + end end diff --git a/test/web/activity_pub/transmogrifier_test.exs b/test/web/activity_pub/transmogrifier_test.exs index e7498e005..060b91e29 100644 --- a/test/web/activity_pub/transmogrifier_test.exs +++ b/test/web/activity_pub/transmogrifier_test.exs @@ -450,6 +450,27 @@ test "it ensures that address fields become lists" do assert !is_nil(data["cc"]) end + test "it strips internal likes" do + data = + File.read!("test/fixtures/mastodon-post-activity.json") + |> Poison.decode!() + + likes = %{ + "first" => + "http://mastodon.example.org/objects/dbdbc507-52c8-490d-9b7c-1e1d52e5c132/likes?page=1", + "id" => "http://mastodon.example.org/objects/dbdbc507-52c8-490d-9b7c-1e1d52e5c132/likes", + "totalItems" => 3, + "type" => "OrderedCollection" + } + + object = Map.put(data["object"], "likes", likes) + data = Map.put(data, "object", object) + + {:ok, %Activity{object: object}} = Transmogrifier.handle_incoming(data) + + refute Map.has_key?(object.data, "likes") + end + test "it works for incoming update activities" do data = File.read!("test/fixtures/mastodon-post-activity.json") |> Poison.decode!() @@ -1061,14 +1082,7 @@ test "it strips internal fields of article" do assert is_nil(modified["object"]["announcements"]) assert is_nil(modified["object"]["announcement_count"]) assert is_nil(modified["object"]["context_id"]) - end - - test "it adds like collection to object" do - activity = insert(:note_activity) - {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data) - - assert modified["object"]["likes"]["type"] == "OrderedCollection" - assert modified["object"]["likes"]["totalItems"] == 0 + assert is_nil(modified["object"]["likes"]) end test "the directMessage flag is present" do From 9cfc289594c1d2a1b53c99e3e72bba4b6dc615ca Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Sat, 10 Aug 2019 21:18:26 +0000 Subject: [PATCH 32/56] MRF: ensure that subdomain_match calls are case-insensitive --- CHANGELOG.md | 1 + lib/pleroma/web/activity_pub/mrf.ex | 2 +- test/web/activity_pub/mrf/mrf_test.exs | 24 +++++++++++++++++++----- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfc73c8df..6f1a22359 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Invalid SemVer version generation, when the current branch does not have commits ahead of tag/checked out on a tag - Pleroma.Upload base_url was not automatically whitelisted by MediaProxy. Now your custom CDN or file hosting will be accessed directly as expected. - Report email not being sent to admins when the reporter is a remote user +- MRF: ensure that subdomain_match calls are case-insensitive ### Added - MRF: Support for priming the mediaproxy cache (`Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy`) diff --git a/lib/pleroma/web/activity_pub/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex index dd204b21c..caa2a3231 100644 --- a/lib/pleroma/web/activity_pub/mrf.ex +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -28,7 +28,7 @@ defp get_policies(_), do: [] @spec subdomains_regex([String.t()]) :: [Regex.t()] def subdomains_regex(domains) when is_list(domains) do - for domain <- domains, do: ~r(^#{String.replace(domain, "*.", "(.*\\.)*")}$) + for domain <- domains, do: ~r(^#{String.replace(domain, "*.", "(.*\\.)*")}$)i end @spec subdomain_match?([Regex.t()], String.t()) :: boolean() diff --git a/test/web/activity_pub/mrf/mrf_test.exs b/test/web/activity_pub/mrf/mrf_test.exs index a9cdf5317..1a888e18f 100644 --- a/test/web/activity_pub/mrf/mrf_test.exs +++ b/test/web/activity_pub/mrf/mrf_test.exs @@ -4,8 +4,8 @@ defmodule Pleroma.Web.ActivityPub.MRFTest do test "subdomains_regex/1" do assert MRF.subdomains_regex(["unsafe.tld", "*.unsafe.tld"]) == [ - ~r/^unsafe.tld$/, - ~r/^(.*\.)*unsafe.tld$/ + ~r/^unsafe.tld$/i, + ~r/^(.*\.)*unsafe.tld$/i ] end @@ -13,7 +13,7 @@ test "subdomains_regex/1" do test "common domains" do regexes = MRF.subdomains_regex(["unsafe.tld", "unsafe2.tld"]) - assert regexes == [~r/^unsafe.tld$/, ~r/^unsafe2.tld$/] + assert regexes == [~r/^unsafe.tld$/i, ~r/^unsafe2.tld$/i] assert MRF.subdomain_match?(regexes, "unsafe.tld") assert MRF.subdomain_match?(regexes, "unsafe2.tld") @@ -24,7 +24,7 @@ test "common domains" do test "wildcard domains with one subdomain" do regexes = MRF.subdomains_regex(["*.unsafe.tld"]) - assert regexes == [~r/^(.*\.)*unsafe.tld$/] + assert regexes == [~r/^(.*\.)*unsafe.tld$/i] assert MRF.subdomain_match?(regexes, "unsafe.tld") assert MRF.subdomain_match?(regexes, "sub.unsafe.tld") @@ -35,12 +35,26 @@ test "wildcard domains with one subdomain" do test "wildcard domains with two subdomains" do regexes = MRF.subdomains_regex(["*.unsafe.tld"]) - assert regexes == [~r/^(.*\.)*unsafe.tld$/] + assert regexes == [~r/^(.*\.)*unsafe.tld$/i] assert MRF.subdomain_match?(regexes, "unsafe.tld") assert MRF.subdomain_match?(regexes, "sub.sub.unsafe.tld") refute MRF.subdomain_match?(regexes, "sub.anotherunsafe.tld") refute MRF.subdomain_match?(regexes, "sub.unsafe.tldanother") end + + test "matches are case-insensitive" do + regexes = MRF.subdomains_regex(["UnSafe.TLD", "UnSAFE2.Tld"]) + + assert regexes == [~r/^UnSafe.TLD$/i, ~r/^UnSAFE2.Tld$/i] + + assert MRF.subdomain_match?(regexes, "UNSAFE.TLD") + assert MRF.subdomain_match?(regexes, "UNSAFE2.TLD") + assert MRF.subdomain_match?(regexes, "unsafe.tld") + assert MRF.subdomain_match?(regexes, "unsafe2.tld") + + refute MRF.subdomain_match?(regexes, "EXAMPLE.COM") + refute MRF.subdomain_match?(regexes, "example.com") + end end end From 92479c6f4870f1ebe4f530db6e31ba960855e1fa Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sun, 11 Aug 2019 22:49:55 +0300 Subject: [PATCH 33/56] Do not fetch the reply object in `fix_type` unless the object has the `name` key and use a depth limit when fetching it --- lib/pleroma/web/activity_pub/transmogrifier.ex | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index b7bc48f0a..0aee9369f 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -333,13 +333,15 @@ def fix_content_map(object), do: object def fix_type(object, options \\ []) - def fix_type(%{"inReplyTo" => reply_id} = object, options) when is_binary(reply_id) do + def fix_type(%{"inReplyTo" => reply_id, "name" => _} = object, options) + when is_binary(reply_id) do reply = - if Federator.allowed_incoming_reply_depth?(options[:depth]) do - Object.normalize(reply_id, true) + with true <- Federator.allowed_incoming_reply_depth?(options[:depth]), + {:ok, object} <- get_obj_helper(reply_id, options) do + object end - if reply && (reply.data["type"] == "Question" and object["name"]) do + if reply && reply.data["type"] == "Question" do Map.put(object, "type", "Answer") else object From d4d31ffdc4ea1b7a1bb154dbf6a61a90b99c646e Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sun, 11 Aug 2019 23:19:20 +0300 Subject: [PATCH 34/56] Add a changelog entry for !1552 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f1a22359..f3338a5b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Not being able to pin unlisted posts - Objects being re-embedded to activities after being updated (e.g faved/reposted). Running 'mix pleroma.database prune_objects' again is advised. - Metadata rendering errors resulting in the entire page being inaccessible +- `federation_incoming_replies_max_depth` option being ignored in certain cases - Federation/MediaProxy not working with instances that have wrong certificate order - Mastodon API: Handling of search timeouts (`/api/v1/search` and `/api/v2/search`) - Mastodon API: Embedded relationships not being properly rendered in the Account entity of Status entity From 24a731a9a67a719749c99ca925f4adb2973a3f2d Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 12 Aug 2019 15:00:03 -0500 Subject: [PATCH 35/56] Update AdminFE Now permits server configuration. Consider this ALPHA. --- priv/static/adminfe/chunk-0e18.e12401fb.css | Bin 0 -> 723 bytes ...9.c27dac5e.css => chunk-1fbf.d7a1893c.css} | Bin 3624 -> 3624 bytes ...8.0d22684d.css => chunk-2325.0d22684d.css} | Bin priv/static/adminfe/chunk-5e57.ac97b15a.css | Bin 0 -> 2321 bytes ...f.1a04e979.css => chunk-e547.e4b6230b.css} | Bin 3304 -> 3304 bytes .../adminfe/chunk-elementUI.e5cd8da6.css | Bin 0 -> 224642 bytes .../adminfe/chunk-elementUI.f74c256b.css | Bin 202027 -> 0 bytes priv/static/adminfe/index.html | 2 +- .../static/fonts/element-icons.2fad952.woff | Bin 6164 -> 0 bytes .../static/fonts/element-icons.535877f.woff | Bin 0 -> 28200 bytes .../static/fonts/element-icons.6f0a763.ttf | Bin 11040 -> 0 bytes .../static/fonts/element-icons.732389d.ttf | Bin 0 -> 55956 bytes priv/static/adminfe/static/js/app.4137ad8f.js | Bin 115467 -> 0 bytes priv/static/adminfe/static/js/app.8e186193.js | Bin 0 -> 137815 bytes .../adminfe/static/js/chunk-0e18.208cd826.js | Bin 0 -> 4774 bytes .../adminfe/static/js/chunk-1fbf.616fb309.js | Bin 0 -> 17717 bytes ...018.e1a7a454.js => chunk-2325.154a537b.js} | Bin 8220 -> 8220 bytes .../adminfe/static/js/chunk-56c9.28e35fc3.js | Bin 14105 -> 0 bytes .../adminfe/static/js/chunk-5e57.7313703a.js | Bin 0 -> 217441 bytes .../adminfe/static/js/chunk-5eaf.5b76e416.js | Bin 23071 -> 0 bytes .../adminfe/static/js/chunk-7fe2.458f9da5.js | Bin 0 -> 408401 bytes .../adminfe/static/js/chunk-e547.d57d1b91.js | Bin 0 -> 23125 bytes .../static/js/chunk-elementUI.1911151b.js | Bin 0 -> 638883 bytes .../static/js/chunk-elementUI.1fa5434b.js | Bin 562077 -> 0 bytes ...ibs.d5609760.js => chunk-libs.fb0b7f4a.js} | Bin 204098 -> 204635 bytes .../adminfe/static/js/runtime.d8d12c12.js | Bin 3434 -> 0 bytes .../adminfe/static/js/runtime.f40c8ec4.js | Bin 0 -> 3608 bytes 27 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 priv/static/adminfe/chunk-0e18.e12401fb.css rename priv/static/adminfe/{chunk-56c9.c27dac5e.css => chunk-1fbf.d7a1893c.css} (95%) rename priv/static/adminfe/{chunk-f018.0d22684d.css => chunk-2325.0d22684d.css} (100%) create mode 100644 priv/static/adminfe/chunk-5e57.ac97b15a.css rename priv/static/adminfe/{chunk-5eaf.1a04e979.css => chunk-e547.e4b6230b.css} (60%) create mode 100644 priv/static/adminfe/chunk-elementUI.e5cd8da6.css delete mode 100644 priv/static/adminfe/chunk-elementUI.f74c256b.css delete mode 100644 priv/static/adminfe/static/fonts/element-icons.2fad952.woff create mode 100644 priv/static/adminfe/static/fonts/element-icons.535877f.woff delete mode 100644 priv/static/adminfe/static/fonts/element-icons.6f0a763.ttf create mode 100644 priv/static/adminfe/static/fonts/element-icons.732389d.ttf delete mode 100644 priv/static/adminfe/static/js/app.4137ad8f.js create mode 100644 priv/static/adminfe/static/js/app.8e186193.js create mode 100644 priv/static/adminfe/static/js/chunk-0e18.208cd826.js create mode 100644 priv/static/adminfe/static/js/chunk-1fbf.616fb309.js rename priv/static/adminfe/static/js/{chunk-f018.e1a7a454.js => chunk-2325.154a537b.js} (98%) delete mode 100644 priv/static/adminfe/static/js/chunk-56c9.28e35fc3.js create mode 100644 priv/static/adminfe/static/js/chunk-5e57.7313703a.js delete mode 100644 priv/static/adminfe/static/js/chunk-5eaf.5b76e416.js create mode 100644 priv/static/adminfe/static/js/chunk-7fe2.458f9da5.js create mode 100644 priv/static/adminfe/static/js/chunk-e547.d57d1b91.js create mode 100644 priv/static/adminfe/static/js/chunk-elementUI.1911151b.js delete mode 100644 priv/static/adminfe/static/js/chunk-elementUI.1fa5434b.js rename priv/static/adminfe/static/js/{chunk-libs.d5609760.js => chunk-libs.fb0b7f4a.js} (71%) delete mode 100644 priv/static/adminfe/static/js/runtime.d8d12c12.js create mode 100644 priv/static/adminfe/static/js/runtime.f40c8ec4.js diff --git a/priv/static/adminfe/chunk-0e18.e12401fb.css b/priv/static/adminfe/chunk-0e18.e12401fb.css new file mode 100644 index 0000000000000000000000000000000000000000..ba85e77d555e97cbaf09bdaef884580de5d557c3 GIT binary patch literal 723 zcmZ{iL2iRE5Jj)Trn?R@RBa@4l&XsX4`9{UmOV*`qTIchKokWkcB40s?{D{nio&-- zMmWKtXby^$__@NF>R-)JyAjan&dP=?Q>b8w&>DJ~&Io9xA+Dg((Hp$TCsXy9Et1Lp zm?dd7VCb}!W$DLER34SmwgW>g%i`0Iw|0V+;(iK|#5oz57hviq?DZ$HoyvND0=wXjexrTmwm?m-3v{iq`ArI|Qal$V z2ei>+m~Q2kduL2`+(~V8WQcq*1bp!%t+TY&Dn)fa)Q5Px<$Azwr>r|sK8Q>Y-6rI9 zDMutMGV(D}+*0dx2Ho{6%el$eyEFKpPslreXBv5Ve)Cdgv?b_i7JME2xSj=`oPWX$ B0WSam literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/chunk-56c9.c27dac5e.css b/priv/static/adminfe/chunk-1fbf.d7a1893c.css similarity index 95% rename from priv/static/adminfe/chunk-56c9.c27dac5e.css rename to priv/static/adminfe/chunk-1fbf.d7a1893c.css index 2b4283ec5479d85daee882406947355c6599c6c9..4672a9f758af89f89f69ce05a0216d8cece66601 100644 GIT binary patch delta 34 icmZ1>vqEMAFE@vQxtXPDQnJZpQSN3qXY&H?4kiGlSqX;# delta 34 icmZ1>vqEMAFE>Y0l8LdUK}zCeQSN3qXY&H?4kiGxbP6#5 diff --git a/priv/static/adminfe/chunk-f018.0d22684d.css b/priv/static/adminfe/chunk-2325.0d22684d.css similarity index 100% rename from priv/static/adminfe/chunk-f018.0d22684d.css rename to priv/static/adminfe/chunk-2325.0d22684d.css diff --git a/priv/static/adminfe/chunk-5e57.ac97b15a.css b/priv/static/adminfe/chunk-5e57.ac97b15a.css new file mode 100644 index 0000000000000000000000000000000000000000..0c9284744e242456ea5327b6682c1ec4f8abe280 GIT binary patch literal 2321 zcmd6pTWjM+6oCJVAS}e-5t8lLS?Q&;u$z~b!akK{F(c_%9x|E{GviAt{NH=#B1?*s z-7WN?J7Km=xJTRG>H$aEVJtbz zsv*ok(=}ApOc2HDy~&nI_^yFNX}S}u%sxw#^9d7k*S=_rtvt#SAnL1dAC%(~&~n%rWN69yh?!^)J=@jehaL(7i7c2$FDYPzKMxR-Q1dKB zK`l9}qB{TxCNNcG(PS3&C2puLr5S+<@dTk1eO-loNK=8$=)0f|j4WMo{u{(Uus#XF zx1)#Ve28k7()09Vnk=yUW^*a{OQcH$AO^`^ zAHmJK?_3Jc5if}=({{^*UA@Dham!_VXtrBU3*ecM<~|mZ7}*lvE3;mWxZ2vg5$yCe zvsIjwbvPx~PUbchI&1Jo(Hc6-!zY4`Q}M9>5z5Zs6bR@Acr%F|g}uGDr^RfUIk!X6}~*E$|8)4CI!>;sLY0ZKl`O z(b&|w-Jh2@YzIAzMPwVmpo^~+HIy95X7TZ1xhmmX7ou}s9fUWu_vw3j?n2pL0FmpX Fe*ifSS-=1Q literal 0 HcmV?d00001 diff --git a/priv/static/adminfe/chunk-5eaf.1a04e979.css b/priv/static/adminfe/chunk-e547.e4b6230b.css similarity index 60% rename from priv/static/adminfe/chunk-5eaf.1a04e979.css rename to priv/static/adminfe/chunk-e547.e4b6230b.css index a09287f584eb4329d6eefeee36f3424ec8977ee1..f740543a0ae4961dea98604533843ffc08f9f480 100644 GIT binary patch delta 321 zcmaDM`9g9-3JXWFsbO-8MQZBg92Qpu$Cle}@^_Y92!WlfP6!S&nkS zaL%wHIPvjDvT*1RLc`pyY3|zRnATcE+ SGcR2?CpE3a%A%m6wiW>NkY=#} delta 321 zcmaDM`9g9-3JZsET3SjkS zaL%wHIPvjDvT*1RLc`pyY3|zRnATcE+ SGcR2?CpE3a%A%m6wiW=aoMt5e diff --git a/priv/static/adminfe/chunk-elementUI.e5cd8da6.css b/priv/static/adminfe/chunk-elementUI.e5cd8da6.css new file mode 100644 index 0000000000000000000000000000000000000000..3fef5e5fdb26a8762d4ca7ef8be2410ec15c1654 GIT binary patch literal 224642 zcmeFaYj0yYk|6q5G*xIIvnP+&@-uCt8(psk7T8Z0_ruK2))=yMl-8tdc_q0rvy9r` ze&b0-kijF#S=}?UceXLLX&o{c491&5@~B^SPm>S*`rQ%yYdg7H_M`3nOZTw)wR_(5 z>u%F8`|0-jarM~WbPt>A$KS9YKv+CZwu{wc*KHmq%jM{3vR3Ms5 zyLq0^7oWNFy6>^$Y(4n^7=IYiq~XWzaW(62Z>OurZU49({qf~#wSa%FyZ#sWdqdMF zn6vfjX}0?GNImrrB+`7=&livV^?Wsb-dslcfgG`+oD_w#<;KTZ|@dxSs(HJbzouP58y zf~UV}P6TSZS}nJWCm~TD!v7w?eSTW5CbMq2*uZiww*3RH;pTZd1p?Xe2HSbKjKs_8 zdAo#F>pm@J+xzR|-~KWOa(45{w11I*JuH@Au0aMKfDgI_Fv#X+vz}f*ua|E&TVVO= zf5V2G|K^$;ou8h+zr36ueOk@uZ%6ah`eCwt^9}y>&D(c7`d*%%oW8%B9c{O!>vsLz zZ@)bC-@Lsc#6ITpyhA>GF?tKn?L^Pn-Rz+;@o}n^E>|W zU;m^({oJ16KP(=eR_pELaeK3Q>L(vr`e|G{PK?Pfll2063NAO>{`0omt|yNhqyj@C zlHc-gKY(yN?T4B3!4< z!)mp?2YI=M0T<96y3RmLf$myuK9_AitS4VKFiy|P0m-tM_T6;dPaeAU>iKbYeb)n$ z{fnr9>u>(oPZ!@Ee-pLe{O{+?nhwj=a=8Ez3LBo8hEFSCrd7Aut`|?6u=TlV z4NUp5+xC;N;e~046P&FVj~~O9mqp7K#4FQo3OwA$6aUUMTfvHdh?{+HngMsML)3mS zjV4b|z}wN<{AgND@B8)oOW5ou)9e%IsP#j?Tmh#AtNmBg6d2|y9QS+66QDZR3oxLt zeBo5SA6t+su!g|6#68{odAW?R`2M8TC2VaO?gJ0dglu_^+lC-5vw>NjwVCL9%WljZ z-SjzR`0wAFY0)|~TUw60Uu`>T9b#&kE{Sv4((>I5EC;Bau$^VS&GUztLBF@GM{d)n zhCcY-a^Kza8dxP9)^gs*$Hlw{{k0C|_6N&R*r?k)tzv%u!E(~{elh*nhkXBo<)Qg% z6{^A?Ec1YNzVF7-On$J;gU!N5mTf?dKX#8~&;ptK$r2EoM)3Y>MH2j_=h>HT@;C!8 z;qft43V*dke44Dc%P$@G3&A@&68Ny0C1ZPNJ9lWc=|e{Rt5wg?Y`xg@F~$FC1xs3n z=sB_KC%=5@){{j{Pb*hom+!ZaXlH}v`Ky&HYS{*AMeEZ7mgi~pDHeynTKPhk;0~gS zVED_iU75`ae8Nb)F2{CdfWfy-c$Rx%;E#Q~sawZ;@N8@HT%KCF1}|Bog~r;r(o5Gc8D5Y*(CwrJY=WKW2X`Xrz1en?#jFGOJ{tVk>X|Ks;rCxchPXVoT80~j@T`V;=x38qy&T&q zQ>(_HmJ2uA)l-YFFUMBY9~RRHx3LxT>FViAH-oeShM-|{30gHj5@6cAcQ% zb~;%{PTA#&6(Y$Pg%h(&IlZ53A)Nw=k7gWud19B0I?TWkA82n^F12sP)t4uB4XJq> z^r;n@jC&LIsS_WZYrCnn3*d-#Vd!#sYE6Q!bwuV<%W|uySO`z84S;0BBV$7#&x=_M%BqjY{!^6v zxIDGI^|Zz~Z##*+(aSSSe=eCSSs=9gzyhq&fZHaFiejjeWlh$5zrbT7e78Ofa3$pSrN@k!RCVco%l@XOry&(jJrL zm(3z1)rIAOrv+rc??JjkTO9$<5E3Yl?8 zUWG6%k4#sqH2}qZs373Du#EE1lDrGcBX{fPFoAV>VRh9#OeN0ZTwYim1y*+rDmcp9 zUtT&cnl3h*)jC|mOUp%^1BfPjY58cfg)JD^#tn#aX%!6^uSJ~4zPz-Iwt>8T7`9(t zT4vZzKK7GOlP_Jvh+W#XBi%BE?UE2n%Q@fy;O1=D&Mu1vO&!G9QRDYkX=~%Kp`E)n z1lj2!pBh89a+W(lBo~5rvL=yU$H|1Sy`^!2zV06&(hl2OaY8xjVM{w;h4)rI=HLdv z2Kc}vFD+|J^8utSe@p8Dlr57>>((k-Mwh1Oc{f(ZvOBj9Nnts&pTUmfKmz5$!o0OW;((gkLVDs;=oz~$18Nz^S8mS$+eLHu(TXb+Tfo*rxW*ShSY?6xb3N=;EcM}~Pg+Oqeze^F3&cLFjyAJvke5FMO(zYn&~5$9Ix4d1n#lg> zSWa5E=f$H6`rNRw{1-o3ZrXwDPnHbawu!1`X2`X*De#kBTx{6_`IB8& zXc(>OPgcE&^ zm+$Qoa?9q_FR$zxa^n^OF0U*baoYyAmseKsXBale!TOaY*9JV>P{&?bdjUDsFkNzG z`C-$2ST905|D9!t4I9gVbuPcNd!SsM;v+i zo#jZta@k1berLI2(}9YeU`be@v%a$u2oP5ft>pA~mVW@|GS1t$l8R0DFj<3>65FUt zS5X0lV33B0aQxm{XcSn&l}IMx3mmd{d=okU?CgLzANxtPPVvP6;8>s z8$@ogPwy%yklBVOWEt)*PIz6O*hQDDc?XHFSjw;LoY5Eq3(yl94P=>j@OACK|zzwY#!#X$L51b@8K}8U;LID=Sq`OQ`a#730N^c8wkukBwGV=I{GusJ~9Vc(j^* zZ_U|sv4+Y4+}e(n&3g+|8)Fz7%;oaLS#ySr^Va1I`&eu^yc=w8h2_`P>H!-^a4)Th zP{TMCe`)nD6a{m#x9ck-jzerJ87|NY!U z@+;Q#-&q?66=+zbGeGZ)A1r%H`w#)Uj31yLEMoRcS6v29QmdV-XN0QhHSUx~!;bCh zalNOx#@jXo)QqE=^PE7>sJ-gGpbym>c3^auK?Ab2}kP zUc6`}_~daRwJ>X}p#}jMooq&!fu5{KAYhOu*!DMjwaSIw9A&+L8wzO`4jRv&KAZ0t zs_179aq#i;_90xNa>-IfAaKs1oXRs?G8}b-<}?yj!#eB@q)|a zW1@aOX>>iALk^hFsHEdZ%51RnOX(KcI0LYBtmpb0u0JkeynxBXDqlEDP>eC+(wJh9 z5zOHcrm%gZ2QB!B(a&#RbX<34fzK)|e(kTvr!YzI3go=W>G=`PT24ZoyoY~NQHK4Y zI2{2|dO8CEN%d~C_?4n0a-K2GeiQy$&I^}Djz=w^{9ye;$29l=#uRpv`!yen}LVyI% z2QET69+jLdT;`7?!C|C=s8Xgy=ceF@3Q5Y@@l`*cH(CNS(&0O8JUyPCO{WGIJaLEe zHlNRLrq650yCPS^Ih-Yw<<3f|M|WGO(grRl#eqIR3BoTgW}4^6e;C0&___xM+Ky<5 z{1w?2qyZ@-R{G*RK@1Nm;^lv*U&PaGsQ!1dw=FMbJ%>7)al zjn#MG5vU?m9tbs4-J?lHG~l>2rh%C|?1{uRoRQl?#tsS~L~unMNHxKU08oR>7R;>3 zB~Clz52~s0-&wDp)LfBJHV$Zm46sV3(Tf}NtYS9>99k;<*fv;p0&b*%DKOP#eQ$4Z z!)sLf3AjsUjKkjN|Pwx<47bl`i~JJ1EK2V^ej!L+>Z{iP~np zax3w?akgG;4FbIsH6y#2rqv)sk_Yi+&SA-SHLcf1MJM7>CV187`dSG`&JTF!;GBl4 zL`8Q11f*{8!hj4SMK8vUq>BV}>54f<^gDpeg+48z8Do)k2)TF3IZF=F+5BvN?)8Y# z@24n{-YKlrWjVas&t&I!psVe#)s5SuJu=%N@Ui{A@D2IW5Z+bUv6=<|1y2deTlGT* zeA<$lZD2=y_k!_7bye9})>Q?kmk^%8!SU4xB` rY}3kDkPzH8JxDaf5|H9;_9$s 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