diff options
Diffstat (limited to 'lib')
31 files changed, 1758 insertions, 174 deletions
diff --git a/lib/mix/tasks/pleroma/digest.ex b/lib/mix/tasks/pleroma/digest.ex index 81c207e10..430116a50 100644 --- a/lib/mix/tasks/pleroma/digest.ex +++ b/lib/mix/tasks/pleroma/digest.ex @@ -27,7 +27,15 @@ defmodule Mix.Tasks.Pleroma.Digest do patched_user = %{user | last_digest_emailed_at: last_digest_emailed_at} - _user = Pleroma.DigestEmailWorker.perform(patched_user) - Mix.shell().info("Digest email have been sent to #{nickname} (#{user.email})") + with %Swoosh.Email{} = email <- Pleroma.Emails.UserEmail.digest_email(patched_user) do + {:ok, _} = Pleroma.Emails.Mailer.deliver(email) + + Mix.shell().info("Digest email have been sent to #{nickname} (#{user.email})") + else + _ -> + Mix.shell().info( + "Cound't find any mentions for #{nickname} since #{last_digest_emailed_at}" + ) + end end end diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 35612c882..2d4e9da0c 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Activity do use Ecto.Schema alias Pleroma.Activity + alias Pleroma.ActivityExpiration alias Pleroma.Bookmark alias Pleroma.Notification alias Pleroma.Object @@ -59,6 +60,8 @@ defmodule Pleroma.Activity do # typical case. has_one(:object, Object, on_delete: :nothing, foreign_key: :id) + has_one(:expiration, ActivityExpiration, on_delete: :delete_all) + timestamps() end diff --git a/lib/pleroma/activity_expiration.ex b/lib/pleroma/activity_expiration.ex new file mode 100644 index 000000000..bf57abca4 --- /dev/null +++ b/lib/pleroma/activity_expiration.ex @@ -0,0 +1,68 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ActivityExpiration do + use Ecto.Schema + + alias Pleroma.Activity + alias Pleroma.ActivityExpiration + alias Pleroma.FlakeId + alias Pleroma.Repo + + import Ecto.Changeset + import Ecto.Query + + @type t :: %__MODULE__{} + @min_activity_lifetime :timer.hours(1) + + schema "activity_expirations" do + belongs_to(:activity, Activity, type: FlakeId) + field(:scheduled_at, :naive_datetime) + end + + def changeset(%ActivityExpiration{} = expiration, attrs) do + expiration + |> cast(attrs, [:scheduled_at]) + |> validate_required([:scheduled_at]) + |> validate_scheduled_at() + end + + def get_by_activity_id(activity_id) do + ActivityExpiration + |> where([exp], exp.activity_id == ^activity_id) + |> Repo.one() + end + + def create(%Activity{} = activity, scheduled_at) do + %ActivityExpiration{activity_id: activity.id} + |> changeset(%{scheduled_at: scheduled_at}) + |> Repo.insert() + end + + def due_expirations(offset \\ 0) do + naive_datetime = + NaiveDateTime.utc_now() + |> NaiveDateTime.add(offset, :millisecond) + + ActivityExpiration + |> where([exp], exp.scheduled_at < ^naive_datetime) + |> Repo.all() + end + + def validate_scheduled_at(changeset) do + validate_change(changeset, :scheduled_at, fn _, scheduled_at -> + if not expires_late_enough?(scheduled_at) do + [scheduled_at: "an ephemeral activity must live for at least one hour"] + else + [] + end + end) + end + + def expires_late_enough?(scheduled_at) do + now = NaiveDateTime.utc_now() + diff = NaiveDateTime.diff(scheduled_at, now, :millisecond) + diff >= @min_activity_lifetime + end +end diff --git a/lib/pleroma/activity_expiration_worker.ex b/lib/pleroma/activity_expiration_worker.ex new file mode 100644 index 000000000..0f9e715f8 --- /dev/null +++ b/lib/pleroma/activity_expiration_worker.ex @@ -0,0 +1,62 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ActivityExpirationWorker do + alias Pleroma.Activity + alias Pleroma.ActivityExpiration + alias Pleroma.Config + alias Pleroma.Repo + alias Pleroma.User + alias Pleroma.Web.CommonAPI + require Logger + use GenServer + import Ecto.Query + + @schedule_interval :timer.minutes(1) + + def start_link(_) do + GenServer.start_link(__MODULE__, nil) + end + + @impl true + def init(_) do + if Config.get([ActivityExpiration, :enabled]) do + schedule_next() + {:ok, nil} + else + :ignore + end + end + + def perform(:execute, expiration_id) do + try do + expiration = + ActivityExpiration + |> where([e], e.id == ^expiration_id) + |> Repo.one!() + + activity = Activity.get_by_id_with_object(expiration.activity_id) + user = User.get_by_ap_id(activity.object.data["actor"]) + CommonAPI.delete(activity.id, user) + rescue + error -> + Logger.error("#{__MODULE__} Couldn't delete expired activity: #{inspect(error)}") + end + end + + @impl true + def handle_info(:perform, state) do + ActivityExpiration.due_expirations(@schedule_interval) + |> Enum.each(fn expiration -> + PleromaJobQueue.enqueue(:activity_expiration, __MODULE__, [:execute, expiration.id]) + end) + + schedule_next() + {:noreply, state} + end + + defp schedule_next do + Process.send_after(self(), :perform, @schedule_interval) + end +end diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 5ff2e06b0..00d89f4c4 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -35,7 +35,8 @@ defmodule Pleroma.Application do Pleroma.Emoji, Pleroma.Captcha, Pleroma.FlakeId, - Pleroma.ScheduledActivityWorker + Pleroma.ScheduledActivityWorker, + Pleroma.ActivityExpirationWorker ] ++ cachex_children() ++ gun_pools() ++ diff --git a/lib/pleroma/emails/user_email.ex b/lib/pleroma/emails/user_email.ex index 49046bb8b..40b67ff56 100644 --- a/lib/pleroma/emails/user_email.ex +++ b/lib/pleroma/emails/user_email.ex @@ -7,21 +7,21 @@ defmodule Pleroma.Emails.UserEmail do use Phoenix.Swoosh, view: Pleroma.Web.EmailView, layout: {Pleroma.Web.LayoutView, :email} + alias Pleroma.Config + alias Pleroma.User alias Pleroma.Web.Endpoint alias Pleroma.Web.Router - defp instance_config, do: Pleroma.Config.get(:instance) - - defp instance_name, do: instance_config()[:name] + defp instance_name, do: Config.get([:instance, :name]) defp sender do - email = Keyword.get(instance_config(), :notify_email, instance_config()[:email]) + email = Config.get([:instance, :notify_email]) || Config.get([:instance, :email]) {instance_name(), email} end defp recipient(email, nil), do: email defp recipient(email, name), do: {name, email} - defp recipient(%Pleroma.User{} = user), do: recipient(user.email, user.name) + defp recipient(%User{} = user), do: recipient(user.email, user.name) def password_reset_email(user, token) when is_binary(token) do password_reset_url = Router.Helpers.reset_password_url(Endpoint, :reset, token) @@ -93,67 +93,86 @@ defmodule Pleroma.Emails.UserEmail do 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 + @spec digest_email(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} = 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} = 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 -> - acc + notifications = Pleroma.Notification.for_user_since(user, user.last_digest_emailed_at) + + mentions = + notifications + |> Enum.filter(&(&1.activity.data["type"] == "Create")) + |> Enum.map(fn notification -> + object = Pleroma.Object.normalize(notification.activity) + object = update_in(object.data["content"], &format_links/1) + + %{ + data: notification, + object: object, + from: User.get_by_ap_id(notification.activity.actor) + } + end) + + followers = + notifications + |> Enum.filter(&(&1.activity.data["type"] == "Follow")) + |> Enum.map(fn notification -> + %{ + data: notification, + object: Pleroma.Object.normalize(notification.activity), + from: User.get_by_ap_id(notification.activity.actor) + } end) - with [_ | _] = mentions <- new_notifications.mentions do + unless Enum.empty?(mentions) do + styling = Config.get([__MODULE__, :styling]) + logo = Config.get([__MODULE__, :logo]) + html_data = %{ instance: instance_name(), user: user, mentions: mentions, - followers: new_notifications.followers, - unsubscribe_link: unsubscribe_url(user, "digest") + followers: followers, + unsubscribe_link: unsubscribe_url(user, "digest"), + styling: styling } + logo_path = + if is_nil(logo) do + Path.join(:code.priv_dir(:pleroma), "static/static/logo.png") + else + Path.join(Config.get([:instance, :static_dir]), logo) + end + new() |> to(recipient(user)) |> from(sender()) |> subject("Your digest from #{instance_name()}") + |> put_layout(false) |> render_body("digest.html", html_data) - else - _ -> - nil + |> attachment(Swoosh.Attachment.new(logo_path, filename: "logo.png", type: :inline)) end end + defp format_links(str) do + re = ~r/<a.+href=['"].*>/iU + %{link_color: color} = Config.get([__MODULE__, :styling]) + + Regex.replace(re, str, fn link -> + String.replace(link, "<a", "<a style=\"color: #{color};text-decoration: none;\"") + 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() + @spec unsubscribe_url(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) + Router.Helpers.subscription_url(Endpoint, :unsubscribe, token) end end diff --git a/lib/pleroma/html.ex b/lib/pleroma/html.ex index 06e60cba3..3951f0f51 100644 --- a/lib/pleroma/html.ex +++ b/lib/pleroma/html.ex @@ -282,3 +282,31 @@ defmodule Pleroma.HTML.Transform.MediaProxy do def scrub({_tag, children}), do: children def scrub(text), do: text end + +defmodule Pleroma.HTML.Scrubber.LinksOnly do + @moduledoc """ + An HTML scrubbing policy which limits to links only. + """ + + @valid_schemes Pleroma.Config.get([:uri_schemes, :valid_schemes], []) + + require HtmlSanitizeEx.Scrubber.Meta + alias HtmlSanitizeEx.Scrubber.Meta + + Meta.remove_cdata_sections_before_scrub() + Meta.strip_comments() + + # links + Meta.allow_tag_with_uri_attributes("a", ["href"], @valid_schemes) + + Meta.allow_tag_with_this_attribute_values("a", "rel", [ + "tag", + "nofollow", + "noopener", + "noreferrer", + "me" + ]) + + Meta.allow_tag_with_these_attributes("a", ["name", "title"]) + Meta.strip_everything_not_covered() +end diff --git a/lib/pleroma/moderation_log.ex b/lib/pleroma/moderation_log.ex new file mode 100644 index 000000000..1ef6fe67a --- /dev/null +++ b/lib/pleroma/moderation_log.ex @@ -0,0 +1,433 @@ +defmodule Pleroma.ModerationLog do + use Ecto.Schema + + alias Pleroma.Activity + alias Pleroma.ModerationLog + alias Pleroma.Repo + alias Pleroma.User + + import Ecto.Query + + schema "moderation_log" do + field(:data, :map) + + timestamps() + end + + def get_all(page, page_size) do + from(q in __MODULE__, + order_by: [desc: q.inserted_at], + limit: ^page_size, + offset: ^((page - 1) * page_size) + ) + |> Repo.all() + end + + def insert_log(%{ + actor: %User{} = actor, + subject: %User{} = subject, + action: action, + permission: permission + }) do + Repo.insert(%ModerationLog{ + data: %{ + actor: user_to_map(actor), + subject: user_to_map(subject), + action: action, + permission: permission + } + }) + end + + def insert_log(%{ + actor: %User{} = actor, + action: "report_update", + subject: %Activity{data: %{"type" => "Flag"}} = subject + }) do + Repo.insert(%ModerationLog{ + data: %{ + actor: user_to_map(actor), + action: "report_update", + subject: report_to_map(subject) + } + }) + end + + def insert_log(%{ + actor: %User{} = actor, + action: "report_response", + subject: %Activity{} = subject, + text: text + }) do + Repo.insert(%ModerationLog{ + data: %{ + actor: user_to_map(actor), + action: "report_response", + subject: report_to_map(subject), + text: text + } + }) + end + + def insert_log(%{ + actor: %User{} = actor, + action: "status_update", + subject: %Activity{} = subject, + sensitive: sensitive, + visibility: visibility + }) do + Repo.insert(%ModerationLog{ + data: %{ + actor: user_to_map(actor), + action: "status_update", + subject: status_to_map(subject), + sensitive: sensitive, + visibility: visibility + } + }) + end + + def insert_log(%{ + actor: %User{} = actor, + action: "status_delete", + subject_id: subject_id + }) do + Repo.insert(%ModerationLog{ + data: %{ + actor: user_to_map(actor), + action: "status_delete", + subject_id: subject_id + } + }) + end + + @spec insert_log(%{actor: User, subject: User, action: String.t()}) :: + {:ok, ModerationLog} | {:error, any} + def insert_log(%{actor: %User{} = actor, subject: subject, action: action}) do + Repo.insert(%ModerationLog{ + data: %{ + actor: user_to_map(actor), + action: action, + subject: user_to_map(subject) + } + }) + end + + @spec insert_log(%{actor: User, subjects: [User], action: String.t()}) :: + {:ok, ModerationLog} | {:error, any} + def insert_log(%{actor: %User{} = actor, subjects: subjects, action: action}) do + subjects = Enum.map(subjects, &user_to_map/1) + + Repo.insert(%ModerationLog{ + data: %{ + actor: user_to_map(actor), + action: action, + subjects: subjects + } + }) + end + + def insert_log(%{ + actor: %User{} = actor, + followed: %User{} = followed, + follower: %User{} = follower, + action: "follow" + }) do + Repo.insert(%ModerationLog{ + data: %{ + actor: user_to_map(actor), + action: "follow", + followed: user_to_map(followed), + follower: user_to_map(follower) + } + }) + end + + def insert_log(%{ + actor: %User{} = actor, + followed: %User{} = followed, + follower: %User{} = follower, + action: "unfollow" + }) do + Repo.insert(%ModerationLog{ + data: %{ + actor: user_to_map(actor), + action: "unfollow", + followed: user_to_map(followed), + follower: user_to_map(follower) + } + }) + end + + def insert_log(%{ + actor: %User{} = actor, + nicknames: nicknames, + tags: tags, + action: action + }) do + Repo.insert(%ModerationLog{ + data: %{ + actor: user_to_map(actor), + nicknames: nicknames, + tags: tags, + action: action + } + }) + end + + def insert_log(%{ + actor: %User{} = actor, + action: action, + target: target + }) + when action in ["relay_follow", "relay_unfollow"] do + Repo.insert(%ModerationLog{ + data: %{ + actor: user_to_map(actor), + action: action, + target: target + } + }) + end + + defp user_to_map(%User{} = user) do + user + |> Map.from_struct() + |> Map.take([:id, :nickname]) + |> Map.put(:type, "user") + end + + defp report_to_map(%Activity{} = report) do + %{ + type: "report", + id: report.id, + state: report.data["state"] + } + end + + defp status_to_map(%Activity{} = status) do + %{ + type: "status", + id: status.id + } + end + + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => action, + "followed" => %{"nickname" => followed_nickname}, + "follower" => %{"nickname" => follower_nickname} + } + }) do + "@#{actor_nickname} made @#{follower_nickname} #{action} @#{followed_nickname}" + end + + @spec get_log_entry_message(ModerationLog) :: String.t() + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "delete", + "subject" => %{"nickname" => subject_nickname, "type" => "user"} + } + }) do + "@#{actor_nickname} deleted user @#{subject_nickname}" + end + + @spec get_log_entry_message(ModerationLog) :: String.t() + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "create", + "subjects" => subjects + } + }) do + nicknames = + subjects + |> Enum.map(&"@#{&1["nickname"]}") + |> Enum.join(", ") + + "@#{actor_nickname} created users: #{nicknames}" + end + + @spec get_log_entry_message(ModerationLog) :: String.t() + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "activate", + "subject" => %{"nickname" => subject_nickname, "type" => "user"} + } + }) do + "@#{actor_nickname} activated user @#{subject_nickname}" + end + + @spec get_log_entry_message(ModerationLog) :: String.t() + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "deactivate", + "subject" => %{"nickname" => subject_nickname, "type" => "user"} + } + }) do + "@#{actor_nickname} deactivated user @#{subject_nickname}" + end + + @spec get_log_entry_message(ModerationLog) :: String.t() + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "nicknames" => nicknames, + "tags" => tags, + "action" => "tag" + } + }) do + nicknames_string = + nicknames + |> Enum.map(&"@#{&1}") + |> Enum.join(", ") + + tags_string = tags |> Enum.join(", ") + + "@#{actor_nickname} added tags: #{tags_string} to users: #{nicknames_string}" + end + + @spec get_log_entry_message(ModerationLog) :: String.t() + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "nicknames" => nicknames, + "tags" => tags, + "action" => "untag" + } + }) do + nicknames_string = + nicknames + |> Enum.map(&"@#{&1}") + |> Enum.join(", ") + + tags_string = tags |> Enum.join(", ") + + "@#{actor_nickname} removed tags: #{tags_string} from users: #{nicknames_string}" + end + + @spec get_log_entry_message(ModerationLog) :: String.t() + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "grant", + "subject" => %{"nickname" => subject_nickname}, + "permission" => permission + } + }) do + "@#{actor_nickname} made @#{subject_nickname} #{permission}" + end + + @spec get_log_entry_message(ModerationLog) :: String.t() + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "revoke", + "subject" => %{"nickname" => subject_nickname}, + "permission" => permission + } + }) do + "@#{actor_nickname} revoked #{permission} role from @#{subject_nickname}" + end + + @spec get_log_entry_message(ModerationLog) :: String.t() + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "relay_follow", + "target" => target + } + }) do + "@#{actor_nickname} followed relay: #{target}" + end + + @spec get_log_entry_message(ModerationLog) :: String.t() + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "relay_unfollow", + "target" => target + } + }) do + "@#{actor_nickname} unfollowed relay: #{target}" + end + + @spec get_log_entry_message(ModerationLog) :: String.t() + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "report_update", + "subject" => %{"id" => subject_id, "state" => state, "type" => "report"} + } + }) do + "@#{actor_nickname} updated report ##{subject_id} with '#{state}' state" + end + + @spec get_log_entry_message(ModerationLog) :: String.t() + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "report_response", + "subject" => %{"id" => subject_id, "type" => "report"}, + "text" => text + } + }) do + "@#{actor_nickname} responded with '#{text}' to report ##{subject_id}" + end + + @spec get_log_entry_message(ModerationLog) :: String.t() + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "status_update", + "subject" => %{"id" => subject_id, "type" => "status"}, + "sensitive" => nil, + "visibility" => visibility + } + }) do + "@#{actor_nickname} updated status ##{subject_id}, set visibility: '#{visibility}'" + end + + @spec get_log_entry_message(ModerationLog) :: String.t() + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "status_update", + "subject" => %{"id" => subject_id, "type" => "status"}, + "sensitive" => sensitive, + "visibility" => nil + } + }) do + "@#{actor_nickname} updated status ##{subject_id}, set sensitive: '#{sensitive}'" + end + + @spec get_log_entry_message(ModerationLog) :: String.t() + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "status_update", + "subject" => %{"id" => subject_id, "type" => "status"}, + "sensitive" => sensitive, + "visibility" => visibility + } + }) do + "@#{actor_nickname} updated status ##{subject_id}, set sensitive: '#{sensitive}', visibility: '#{ + visibility + }'" + end + + @spec get_log_entry_message(ModerationLog) :: String.t() + def get_log_entry_message(%ModerationLog{ + data: %{ + "actor" => %{"nickname" => actor_nickname}, + "action" => "status_delete", + "subject_id" => subject_id + } + }) do + "@#{actor_nickname} deleted status ##{subject_id}" + end +end diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index 46531a13a..1dfba04eb 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -117,9 +117,7 @@ defmodule Pleroma.Object.Fetcher do def fetch_and_contain_remote_object_from_id(id) when is_binary(id) do Logger.info("Fetching object #{id} via AP") - date = - NaiveDateTime.utc_now() - |> Timex.format!("{WDshort}, {0D} {Mshort} {YYYY} {h24}:{m}:{s} GMT") + date = Pleroma.Signature.signed_date() headers = [{"accept", "application/activity+json"}] diff --git a/lib/pleroma/signature.ex b/lib/pleroma/signature.ex index 15bf3c317..f20aeb0d5 100644 --- a/lib/pleroma/signature.ex +++ b/lib/pleroma/signature.ex @@ -53,4 +53,10 @@ defmodule Pleroma.Signature do HTTPSignatures.sign(private_key, user.ap_id <> "#main-key", headers) end end + + def signed_date, do: signed_date(NaiveDateTime.utc_now()) + + def signed_date(%NaiveDateTime{} = date) do + Timex.format!(date, "{WDshort}, {0D} {Mshort} {YYYY} {h24}:{m}:{s} GMT") + end end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 02011f4e6..29fd6d2ea 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -222,12 +222,12 @@ defmodule Pleroma.User do |> validate_length(:name, min: 1, max: name_limit) end - def upgrade_changeset(struct, params \\ %{}) do + def upgrade_changeset(struct, params \\ %{}, remote? \\ false) do bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000) name_limit = Pleroma.Config.get([:instance, :user_name_length], 100) params = Map.put(params, :last_refreshed_at, NaiveDateTime.utc_now()) - info_cng = User.Info.user_upgrade(struct.info, params[:info]) + info_cng = User.Info.user_upgrade(struct.info, params[:info], remote?) struct |> cast(params, [ @@ -330,7 +330,13 @@ defmodule Pleroma.User do @doc "Inserts provided changeset, performs post-registration actions (confirmation email sending etc.)" def register(%Ecto.Changeset{} = changeset) do with {:ok, user} <- Repo.insert(changeset), - {:ok, user} <- autofollow_users(user), + {:ok, user} <- post_register_action(user) do + {:ok, user} + end + end + + def post_register_action(%User{} = user) do + with {:ok, user} <- autofollow_users(user), {:ok, user} <- set_cache(user), {:ok, _} <- User.WelcomeMessage.post_welcome_message_to_user(user), {:ok, _} <- try_send_confirmation_email(user) do diff --git a/lib/pleroma/user/info.ex b/lib/pleroma/user/info.ex index 22eb9a182..779bfbc18 100644 --- a/lib/pleroma/user/info.ex +++ b/lib/pleroma/user/info.ex @@ -49,6 +49,8 @@ defmodule Pleroma.User.Info do field(:mascot, :map, default: nil) field(:emoji, {:array, :map}, default: []) field(:pleroma_settings_store, :map, default: %{}) + field(:fields, {:array, :map}, default: nil) + field(:raw_fields, {:array, :map}, default: []) field(:notification_settings, :map, default: %{ @@ -254,11 +256,13 @@ defmodule Pleroma.User.Info do :hide_followers, :hide_follows, :follower_count, + :fields, :following_count ]) + |> validate_fields(true) end - def user_upgrade(info, params) do + def user_upgrade(info, params, remote? \\ false) do info |> cast(params, [ :ap_enabled, @@ -269,8 +273,10 @@ defmodule Pleroma.User.Info do :follower_count, :following_count, :hide_follows, + :fields, :hide_followers ]) + |> validate_fields(remote?) end def profile_update(info, params) do @@ -286,10 +292,40 @@ defmodule Pleroma.User.Info do :background, :show_role, :skip_thread_containment, + :fields, + :raw_fields, :pleroma_settings_store ]) + |> validate_fields() end + def validate_fields(changeset, remote? \\ false) do + limit_name = if remote?, do: :max_remote_account_fields, else: :max_account_fields + limit = Pleroma.Config.get([:instance, limit_name], 0) + + changeset + |> validate_length(:fields, max: limit) + |> validate_change(:fields, fn :fields, fields -> + if Enum.all?(fields, &valid_field?/1) do + [] + else + [fields: "invalid"] + end + end) + end + + defp valid_field?(%{"name" => name, "value" => value}) do + name_limit = Pleroma.Config.get([:instance, :account_field_name_length], 255) + value_limit = Pleroma.Config.get([:instance, :account_field_value_length], 255) + + is_binary(name) && + is_binary(value) && + String.length(name) <= name_limit && + String.length(value) <= value_limit + end + + defp valid_field?(_), do: false + @spec confirmation_changeset(Info.t(), keyword()) :: Changeset.t() def confirmation_changeset(info, opts) do need_confirmation? = Keyword.get(opts, :need_confirmation) @@ -384,6 +420,21 @@ defmodule Pleroma.User.Info do cast(info, params, [:muted_reblogs]) end + # ``fields`` is an array of mastodon profile field, containing ``{"name": "…", "value": "…"}``. + # For example: [{"name": "Pronoun", "value": "she/her"}, …] + def fields(%{fields: nil, source_data: %{"attachment" => attachment}}) do + limit = Pleroma.Config.get([:instance, :max_remote_account_fields], 0) + + attachment + |> Enum.filter(fn %{"type" => t} -> t == "PropertyValue" end) + |> Enum.map(fn fields -> Map.take(fields, ["name", "value"]) end) + |> Enum.take(limit) + end + + def fields(%{fields: nil}), do: [] + + def fields(%{fields: fields}), do: fields + def follow_information_update(info, params) do info |> cast(params, [ diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 1a8e3ad96..172c952d4 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -65,12 +65,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do if not is_nil(actor) do with user <- User.get_cached_by_ap_id(actor), false <- user.info.deactivated do - :ok + true else - _e -> :reject + _e -> false end else - :ok + true end end @@ -119,10 +119,10 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do def increase_poll_votes_if_vote(_create_data), do: :noop - def insert(map, local \\ true, fake \\ false) when is_map(map) do + def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when is_map(map) do with nil <- Activity.normalize(map), map <- lazy_put_activity_defaults(map, fake), - :ok <- check_actor_is_active(map["actor"]), + true <- bypass_actor_check || check_actor_is_active(map["actor"]), {_, true} <- {:remote_limit_error, check_remote_limit(map)}, {:ok, map} <- MRF.filter(map), {recipients, _, _} = get_recipients(map), @@ -411,7 +411,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do "actor" => ap_id, "object" => %{"type" => "Person", "id" => ap_id} }, - {:ok, activity} <- insert(data, true, true), + {:ok, activity} <- insert(data, true, true, true), :ok <- maybe_federate(activity) do {:ok, user} end @@ -1023,6 +1023,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do "url" => [%{"href" => data["image"]["url"]}] } + fields = + data + |> Map.get("attachment", []) + |> Enum.filter(fn %{"type" => t} -> t == "PropertyValue" end) + |> Enum.map(fn fields -> Map.take(fields, ["name", "value"]) end) + locked = data["manuallyApprovesFollowers"] || false data = Transmogrifier.maybe_fix_user_object(data) @@ -1032,6 +1038,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do ap_enabled: true, source_data: data, banner: banner, + fields: fields, locked: locked }, avatar: avatar, diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 133a726c5..ed801a7ae 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -41,7 +41,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do with %User{} = user <- User.get_cached_by_nickname(nickname), {:ok, user} <- User.ensure_keys_present(user) do conn - |> put_resp_header("content-type", "application/activity+json") + |> put_resp_content_type("application/activity+json") |> json(UserView.render("user.json", %{user: user})) else nil -> {:error, :not_found} @@ -53,7 +53,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do %Object{} = object <- Object.get_cached_by_ap_id(ap_id), {_, true} <- {:public?, Visibility.is_public?(object)} do conn - |> put_resp_header("content-type", "application/activity+json") + |> put_resp_content_type("application/activity+json") |> json(ObjectView.render("object.json", %{object: object})) else {:public?, false} -> @@ -69,7 +69,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do {page, _} = Integer.parse(page) conn - |> put_resp_header("content-type", "application/activity+json") + |> put_resp_content_type("application/activity+json") |> json(ObjectView.render("likes.json", ap_id, likes, page)) else {:public?, false} -> @@ -83,7 +83,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do {_, true} <- {:public?, Visibility.is_public?(object)}, likes <- Utils.get_object_likes(object) do conn - |> put_resp_header("content-type", "application/activity+json") + |> put_resp_content_type("application/activity+json") |> json(ObjectView.render("likes.json", ap_id, likes)) else {:public?, false} -> @@ -96,7 +96,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do %Activity{} = activity <- Activity.normalize(ap_id), {_, true} <- {:public?, Visibility.is_public?(activity)} do conn - |> put_resp_header("content-type", "application/activity+json") + |> put_resp_content_type("application/activity+json") |> json(ObjectView.render("object.json", %{object: activity})) else {:public?, false} -> @@ -104,6 +104,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do end end + # GET /relay/following + def following(%{assigns: %{relay: true}} = conn, _params) do + conn + |> put_resp_content_type("application/activity+json") + |> json(UserView.render("following.json", %{user: Relay.get_actor()})) + end + def following(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname, "page" => page}) do with %User{} = user <- User.get_cached_by_nickname(nickname), {user, for_user} <- ensure_user_keys_present_and_maybe_refresh_for_user(user, for_user), @@ -112,12 +119,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do {page, _} = Integer.parse(page) conn - |> put_resp_header("content-type", "application/activity+json") + |> put_resp_content_type("application/activity+json") |> json(UserView.render("following.json", %{user: user, page: page, for: for_user})) else {:show_follows, _} -> conn - |> put_resp_header("content-type", "application/activity+json") + |> put_resp_content_type("application/activity+json") |> send_resp(403, "") end end @@ -126,11 +133,18 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do with %User{} = user <- User.get_cached_by_nickname(nickname), {user, for_user} <- ensure_user_keys_present_and_maybe_refresh_for_user(user, for_user) do conn - |> put_resp_header("content-type", "application/activity+json") + |> put_resp_content_type("application/activity+json") |> json(UserView.render("following.json", %{user: user, for: for_user})) end end + # GET /relay/followers + def followers(%{assigns: %{relay: true}} = conn, _params) do + conn + |> put_resp_content_type("application/activity+json") + |> json(UserView.render("followers.json", %{user: Relay.get_actor()})) + end + def followers(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname, "page" => page}) do with %User{} = user <- User.get_cached_by_nickname(nickname), {user, for_user} <- ensure_user_keys_present_and_maybe_refresh_for_user(user, for_user), @@ -139,12 +153,12 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do {page, _} = Integer.parse(page) conn - |> put_resp_header("content-type", "application/activity+json") + |> put_resp_content_type("application/activity+json") |> json(UserView.render("followers.json", %{user: user, page: page, for: for_user})) else {:show_followers, _} -> conn - |> put_resp_header("content-type", "application/activity+json") + |> put_resp_content_type("application/activity+json") |> send_resp(403, "") end end @@ -153,7 +167,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do with %User{} = user <- User.get_cached_by_nickname(nickname), {user, for_user} <- ensure_user_keys_present_and_maybe_refresh_for_user(user, for_user) do conn - |> put_resp_header("content-type", "application/activity+json") + |> put_resp_content_type("application/activity+json") |> json(UserView.render("followers.json", %{user: user, for: for_user})) end end @@ -162,7 +176,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do with %User{} = user <- User.get_cached_by_nickname(nickname), {:ok, user} <- User.ensure_keys_present(user) do conn - |> put_resp_header("content-type", "application/activity+json") + |> put_resp_content_type("application/activity+json") |> json(UserView.render("outbox.json", %{user: user, max_id: params["max_id"]})) end end @@ -210,7 +224,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do defp represent_service_actor(%User{} = user, conn) do with {:ok, user} <- User.ensure_keys_present(user) do conn - |> put_resp_header("content-type", "application/activity+json") + |> put_resp_content_type("application/activity+json") |> json(UserView.render("user.json", %{user: user})) else nil -> {:error, :not_found} @@ -231,7 +245,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do def whoami(%{assigns: %{user: %User{} = user}} = conn, _params) do conn - |> put_resp_header("content-type", "application/activity+json") + |> put_resp_content_type("application/activity+json") |> json(UserView.render("user.json", %{user: user})) end @@ -240,7 +254,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do def read_inbox(%{assigns: %{user: user}} = conn, %{"nickname" => nickname} = params) do if nickname == user.nickname do conn - |> put_resp_header("content-type", "application/activity+json") + |> put_resp_content_type("application/activity+json") |> json(UserView.render("inbox.json", %{user: user, max_id: params["max_id"]})) else err = diff --git a/lib/pleroma/web/activity_pub/publisher.ex b/lib/pleroma/web/activity_pub/publisher.ex index 262529b84..c97405690 100644 --- a/lib/pleroma/web/activity_pub/publisher.ex +++ b/lib/pleroma/web/activity_pub/publisher.ex @@ -50,9 +50,7 @@ defmodule Pleroma.Web.ActivityPub.Publisher do digest = "SHA-256=" <> (:crypto.hash(:sha256, json) |> Base.encode64()) - date = - NaiveDateTime.utc_now() - |> Timex.format!("{WDshort}, {0D} {Mshort} {YYYY} {h24}:{m}:{s} GMT") + date = Pleroma.Signature.signed_date() signature = Pleroma.Signature.sign(actor, %{ diff --git a/lib/pleroma/web/activity_pub/relay.ex b/lib/pleroma/web/activity_pub/relay.ex index 5f18cc64a..c2ac38907 100644 --- a/lib/pleroma/web/activity_pub/relay.ex +++ b/lib/pleroma/web/activity_pub/relay.ex @@ -22,13 +22,7 @@ defmodule Pleroma.Web.ActivityPub.Relay do Logger.info("relay: followed instance: #{target_instance}; id=#{activity.data["id"]}") {:ok, activity} else - {:error, _} = error -> - Logger.error("error: #{inspect(error)}") - error - - e -> - Logger.error("error: #{inspect(e)}") - {:error, e} + error -> format_error(error) end end @@ -37,16 +31,11 @@ defmodule Pleroma.Web.ActivityPub.Relay do with %User{} = local_user <- get_actor(), {:ok, %User{} = target_user} <- User.get_or_fetch_by_ap_id(target_instance), {:ok, activity} <- ActivityPub.unfollow(local_user, target_user) do + User.unfollow(local_user, target_user) Logger.info("relay: unfollowed instance: #{target_instance}: id=#{activity.data["id"]}") {:ok, activity} else - {:error, _} = error -> - Logger.error("error: #{inspect(error)}") - error - - e -> - Logger.error("error: #{inspect(e)}") - {:error, e} + error -> format_error(error) end end @@ -56,11 +45,16 @@ defmodule Pleroma.Web.ActivityPub.Relay do %Object{} = object <- Object.normalize(activity) do ActivityPub.announce(user, object, nil, true, false) else - e -> - Logger.error("error: #{inspect(e)}") - {:error, inspect(e)} + error -> format_error(error) end end def publish(_), do: {:error, "Not implemented"} + + defp format_error({:error, error}), do: format_error(error) + + defp format_error(error) do + Logger.error("error: #{inspect(error)}") + {:error, error} + end end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 0fcc81bf3..36340a3a1 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -598,14 +598,20 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do banner = new_user_data[:info][:banner] locked = new_user_data[:info][:locked] || false + attachment = get_in(new_user_data, [:info, :source_data, "attachment"]) || [] + + fields = + attachment + |> Enum.filter(fn %{"type" => t} -> t == "PropertyValue" end) + |> Enum.map(fn fields -> Map.take(fields, ["name", "value"]) end) update_data = new_user_data |> Map.take([:name, :bio, :avatar]) - |> Map.put(:info, %{banner: banner, locked: locked}) + |> Map.put(:info, %{banner: banner, locked: locked, fields: fields}) actor - |> User.upgrade_changeset(update_data) + |> User.upgrade_changeset(update_data, true) |> User.update_and_set_cache() ActivityPub.update(%{ diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index 06c9e1c71..7be734b26 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -80,6 +80,17 @@ defmodule Pleroma.Web.ActivityPub.UserView do |> Transmogrifier.add_emoji_tags() |> Map.get("tag", []) + fields = + user.info + |> User.Info.fields() + |> Enum.map(fn %{"name" => name, "value" => value} -> + %{ + "name" => Pleroma.HTML.strip_tags(name), + "value" => Pleroma.HTML.filter_tags(value, Pleroma.HTML.Scrubber.LinksOnly) + } + end) + |> Enum.map(&Map.put(&1, "type", "PropertyValue")) + %{ "id" => user.ap_id, "type" => "Person", @@ -98,6 +109,7 @@ defmodule Pleroma.Web.ActivityPub.UserView do "publicKeyPem" => public_key }, "endpoints" => endpoints, + "attachment" => fields, "tag" => (user.info.source_data["tag"] || []) ++ user_tags } |> Map.merge(maybe_make_image(&User.avatar_url/2, "icon", user)) diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 2d3d0adc4..544b9d7d8 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do use Pleroma.Web, :controller alias Pleroma.Activity + alias Pleroma.ModerationLog alias Pleroma.User alias Pleroma.UserInviteToken alias Pleroma.Web.ActivityPub.ActivityPub @@ -12,6 +13,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do alias Pleroma.Web.AdminAPI.AccountView alias Pleroma.Web.AdminAPI.Config alias Pleroma.Web.AdminAPI.ConfigView + alias Pleroma.Web.AdminAPI.ModerationLogView alias Pleroma.Web.AdminAPI.ReportView alias Pleroma.Web.AdminAPI.Search alias Pleroma.Web.CommonAPI @@ -25,52 +27,113 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do action_fallback(:errors) - def user_delete(conn, %{"nickname" => nickname}) do - User.get_cached_by_nickname(nickname) - |> User.delete() + def user_delete(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do + user = User.get_cached_by_nickname(nickname) + User.delete(user) + + ModerationLog.insert_log(%{ + actor: admin, + subject: user, + action: "delete" + }) conn |> json(nickname) end - def user_follow(conn, %{"follower" => follower_nick, "followed" => followed_nick}) do + def user_follow(%{assigns: %{user: admin}} = conn, %{ + "follower" => follower_nick, + "followed" => followed_nick + }) do with %User{} = follower <- User.get_cached_by_nickname(follower_nick), %User{} = followed <- User.get_cached_by_nickname(followed_nick) do User.follow(follower, followed) + + ModerationLog.insert_log(%{ + actor: admin, + followed: followed, + follower: follower, + action: "follow" + }) end conn |> json("ok") end - def user_unfollow(conn, %{"follower" => follower_nick, "followed" => followed_nick}) do + def user_unfollow(%{assigns: %{user: admin}} = conn, %{ + "follower" => follower_nick, + "followed" => followed_nick + }) do with %User{} = follower <- User.get_cached_by_nickname(follower_nick), %User{} = followed <- User.get_cached_by_nickname(followed_nick) do User.unfollow(follower, followed) + + ModerationLog.insert_log(%{ + actor: admin, + followed: followed, + follower: follower, + action: "unfollow" + }) end conn |> json("ok") end - def user_create( - conn, - %{"nickname" => nickname, "email" => email, "password" => password} - ) do - user_data = %{ - nickname: nickname, - name: nickname, - email: email, - password: password, - password_confirmation: password, - bio: "." - } + def users_create(%{assigns: %{user: admin}} = conn, %{"users" => users}) do + changesets = + Enum.map(users, fn %{"nickname" => nickname, "email" => email, "password" => password} -> + user_data = %{ + nickname: nickname, + name: nickname, + email: email, + password: password, + password_confirmation: password, + bio: "." + } - changeset = User.register_changeset(%User{}, user_data, need_confirmation: false) - {:ok, user} = User.register(changeset) + User.register_changeset(%User{}, user_data, need_confirmation: false) + end) + |> Enum.reduce(Ecto.Multi.new(), fn changeset, multi -> + Ecto.Multi.insert(multi, Ecto.UUID.generate(), changeset) + end) + + case Pleroma.Repo.transaction(changesets) do + {:ok, users} -> + res = + users + |> Map.values() + |> Enum.map(fn user -> + {:ok, user} = User.post_register_action(user) + + user + end) + |> Enum.map(&AccountView.render("created.json", %{user: &1})) - conn - |> json(user.nickname) + ModerationLog.insert_log(%{ + actor: admin, + subjects: Map.values(users), + action: "create" + }) + + conn + |> json(res) + + {:error, id, changeset, _} -> + res = + Enum.map(changesets.operations, fn + {current_id, {:changeset, _current_changeset, _}} when current_id == id -> + AccountView.render("create-error.json", %{changeset: changeset}) + + {_, {:changeset, current_changeset, _}} -> + AccountView.render("create-error.json", %{changeset: current_changeset}) + end) + + conn + |> put_status(:conflict) + |> json(res) + end end def user_show(conn, %{"nickname" => nickname}) do @@ -101,23 +164,47 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do end end - def user_toggle_activation(conn, %{"nickname" => nickname}) do + def user_toggle_activation(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do user = User.get_cached_by_nickname(nickname) {:ok, updated_user} = User.deactivate(user, !user.info.deactivated) + action = if user.info.deactivated, do: "activate", else: "deactivate" + + ModerationLog.insert_log(%{ + actor: admin, + subject: user, + action: action + }) + conn |> json(AccountView.render("show.json", %{user: updated_user})) end - def tag_users(conn, %{"nicknames" => nicknames, "tags" => tags}) do - with {:ok, _} <- User.tag(nicknames, tags), - do: json_response(conn, :no_content, "") + def tag_users(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames, "tags" => tags}) do + with {:ok, _} <- User.tag(nicknames, tags) do + ModerationLog.insert_log(%{ + actor: admin, + nicknames: nicknames, + tags: tags, + action: "tag" + }) + + json_response(conn, :no_content, "") + end end - def untag_users(conn, %{"nicknames" => nicknames, "tags" => tags}) do - with {:ok, _} <- User.untag(nicknames, tags), - do: json_response(conn, :no_content, "") + def untag_users(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames, "tags" => tags}) do + with {:ok, _} <- User.untag(nicknames, tags) do + ModerationLog.insert_log(%{ + actor: admin, + nicknames: nicknames, + tags: tags, + action: "untag" + }) + + json_response(conn, :no_content, "") + end end def list_users(conn, params) do @@ -158,7 +245,10 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do |> Enum.into(%{}, &{&1, true}) end - def right_add(conn, %{"permission_group" => permission_group, "nickname" => nickname}) + def right_add(%{assigns: %{user: admin}} = conn, %{ + "permission_group" => permission_group, + "nickname" => nickname + }) when permission_group in ["moderator", "admin"] do user = User.get_cached_by_nickname(nickname) @@ -173,6 +263,13 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do |> Ecto.Changeset.change() |> Ecto.Changeset.put_embed(:info, info_cng) + ModerationLog.insert_log(%{ + action: "grant", + actor: admin, + subject: user, + permission: permission_group + }) + {:ok, _user} = User.update_and_set_cache(cng) json(conn, info) @@ -193,7 +290,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do end def right_delete( - %{assigns: %{user: %User{:nickname => admin_nickname}}} = conn, + %{assigns: %{user: %User{:nickname => admin_nickname} = admin}} = conn, %{ "permission_group" => permission_group, "nickname" => nickname @@ -217,6 +314,13 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do {:ok, _user} = User.update_and_set_cache(cng) + ModerationLog.insert_log(%{ + action: "revoke", + actor: admin, + subject: user, + permission: permission_group + }) + json(conn, info) end end @@ -225,15 +329,33 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do render_error(conn, :not_found, "No such permission_group") end - def set_activation_status(conn, %{"nickname" => nickname, "status" => status}) do + def set_activation_status(%{assigns: %{user: admin}} = conn, %{ + "nickname" => nickname, + "status" => status + }) do with {:ok, status} <- Ecto.Type.cast(:boolean, status), %User{} = user <- User.get_cached_by_nickname(nickname), - {:ok, _} <- User.deactivate(user, !status), - do: json_response(conn, :no_content, "") + {:ok, _} <- User.deactivate(user, !status) do + action = if(user.info.deactivated, do: "activate", else: "deactivate") + + ModerationLog.insert_log(%{ + actor: admin, + subject: user, + action: action + }) + + json_response(conn, :no_content, "") + end end - def relay_follow(conn, %{"relay_url" => target}) do + def relay_follow(%{assigns: %{user: admin}} = conn, %{"relay_url" => target}) do with {:ok, _message} <- Relay.follow(target) do + ModerationLog.insert_log(%{ + action: "relay_follow", + actor: admin, + target: target + }) + json(conn, target) else _ -> @@ -243,8 +365,14 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do end end - def relay_unfollow(conn, %{"relay_url" => target}) do + def relay_unfollow(%{assigns: %{user: admin}} = conn, %{"relay_url" => target}) do with {:ok, _message} <- Relay.unfollow(target) do + ModerationLog.insert_log(%{ + action: "relay_unfollow", + actor: admin, + target: target + }) + json(conn, target) else _ -> @@ -335,8 +463,14 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do end end - def report_update_state(conn, %{"id" => id, "state" => state}) do + def report_update_state(%{assigns: %{user: admin}} = conn, %{"id" => id, "state" => state}) do with {:ok, report} <- CommonAPI.update_report_state(id, state) do + ModerationLog.insert_log(%{ + action: "report_update", + actor: admin, + subject: report + }) + conn |> put_view(ReportView) |> render("show.json", %{report: report}) @@ -353,6 +487,13 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do {:ok, activity} = CommonAPI.post(user, params) + ModerationLog.insert_log(%{ + action: "report_response", + actor: user, + subject: activity, + text: params["status"] + }) + conn |> put_view(StatusView) |> render("status.json", %{activity: activity}) @@ -365,8 +506,18 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do end end - def status_update(conn, %{"id" => id} = params) do + def status_update(%{assigns: %{user: admin}} = conn, %{"id" => id} = params) do with {:ok, activity} <- CommonAPI.update_activity_scope(id, params) do + {:ok, sensitive} = Ecto.Type.cast(:boolean, params["sensitive"]) + + ModerationLog.insert_log(%{ + action: "status_update", + actor: admin, + subject: activity, + sensitive: sensitive, + visibility: params["visibility"] + }) + conn |> put_view(StatusView) |> render("status.json", %{activity: activity}) @@ -375,10 +526,26 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do def status_delete(%{assigns: %{user: user}} = conn, %{"id" => id}) do with {:ok, %Activity{}} <- CommonAPI.delete(id, user) do + ModerationLog.insert_log(%{ + action: "status_delete", + actor: user, + subject_id: id + }) + json(conn, %{}) end end + def list_log(conn, params) do + {page, page_size} = page_params(params) + + log = ModerationLog.get_all(page, page_size) + + conn + |> put_view(ModerationLogView) + |> render("index.json", %{log: log}) + end + def migrate_to_db(conn, _params) do Mix.Tasks.Pleroma.Config.run(["migrate_to_db"]) json(conn, %{}) diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex index 7e1b9c431..a96affd40 100644 --- a/lib/pleroma/web/admin_api/views/account_view.ex +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -52,4 +52,50 @@ defmodule Pleroma.Web.AdminAPI.AccountView do invites: render_many(invites, AccountView, "invite.json", as: :invite) } end + + def render("created.json", %{user: user}) do + %{ + type: "success", + code: 200, + data: %{ + nickname: user.nickname, + email: user.email + } + } + end + + def render("create-error.json", %{changeset: %Ecto.Changeset{changes: changes, errors: errors}}) do + %{ + type: "error", + code: 409, + error: parse_error(errors), + data: %{ + nickname: Map.get(changes, :nickname), + email: Map.get(changes, :email) + } + } + end + + defp parse_error([]), do: "" + + defp parse_error(errors) do + ## when nickname is duplicate ap_id constraint error is raised + nickname_error = Keyword.get(errors, :nickname) || Keyword.get(errors, :ap_id) + email_error = Keyword.get(errors, :email) + password_error = Keyword.get(errors, :password) + + cond do + nickname_error -> + "nickname #{elem(nickname_error, 0)}" + + email_error -> + "email #{elem(email_error, 0)}" + + password_error -> + "password #{elem(password_error, 0)}" + + true -> + "" + end + end end diff --git a/lib/pleroma/web/admin_api/views/moderation_log_view.ex b/lib/pleroma/web/admin_api/views/moderation_log_view.ex new file mode 100644 index 000000000..b3fc7cfe5 --- /dev/null +++ b/lib/pleroma/web/admin_api/views/moderation_log_view.ex @@ -0,0 +1,26 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.ModerationLogView do + use Pleroma.Web, :view + + alias Pleroma.ModerationLog + + def render("index.json", %{log: log}) do + render_many(log, __MODULE__, "show.json", as: :log_entry) + end + + def render("show.json", %{log_entry: log_entry}) do + time = + log_entry.inserted_at + |> DateTime.from_naive!("Etc/UTC") + |> DateTime.to_unix() + + %{ + data: log_entry.data, + time: time, + message: ModerationLog.get_log_entry_message(log_entry) + } + end +end diff --git a/lib/pleroma/web/common_api/common_api.ex b/lib/pleroma/web/common_api/common_api.ex index 72da46263..5faddc9f4 100644 --- a/lib/pleroma/web/common_api/common_api.ex +++ b/lib/pleroma/web/common_api/common_api.ex @@ -4,6 +4,7 @@ defmodule Pleroma.Web.CommonAPI do alias Pleroma.Activity + alias Pleroma.ActivityExpiration alias Pleroma.Conversation.Participation alias Pleroma.Formatter alias Pleroma.Object @@ -200,6 +201,23 @@ defmodule Pleroma.Web.CommonAPI do end end + defp check_expiry_date({:ok, nil} = res), do: res + + defp check_expiry_date({:ok, in_seconds}) do + expiry = NaiveDateTime.utc_now() |> NaiveDateTime.add(in_seconds) + + if ActivityExpiration.expires_late_enough?(expiry) do + {:ok, expiry} + else + {:error, "Expiry date is too soon"} + end + end + + defp check_expiry_date(expiry_str) do + Ecto.Type.cast(:integer, expiry_str) + |> check_expiry_date() + end + def post(user, %{"status" => status} = data) do limit = Pleroma.Config.get([:instance, :limit]) @@ -226,6 +244,7 @@ defmodule Pleroma.Web.CommonAPI do context <- make_context(in_reply_to, in_reply_to_conversation), cw <- data["spoiler_text"] || "", sensitive <- data["sensitive"] || Enum.member?(tags, {"#nsfw", "nsfw"}), + {:ok, expires_at} <- check_expiry_date(data["expires_in"]), full_payload <- String.trim(status <> cw), :ok <- validate_character_limit(full_payload, attachments, limit), object <- @@ -251,15 +270,24 @@ defmodule Pleroma.Web.CommonAPI do preview? = Pleroma.Web.ControllerHelper.truthy_param?(data["preview"]) || false direct? = visibility == "direct" - %{ - to: to, - actor: user, - context: context, - object: object, - additional: %{"cc" => cc, "directMessage" => direct?} - } - |> maybe_add_list_data(user, visibility) - |> ActivityPub.create(preview?) + result = + %{ + to: to, + actor: user, + context: context, + object: object, + additional: %{"cc" => cc, "directMessage" => direct?} + } + |> maybe_add_list_data(user, visibility) + |> ActivityPub.create(preview?) + + if expires_at do + with {:ok, activity} <- result do + {:ok, _} = ActivityExpiration.create(activity, expires_at) + end + end + + result else {:private_to_public, true} -> {:error, dgettext("errors", "The message visibility must be direct")} diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 61b96aba9..6958c7511 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -93,8 +93,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do Activity.t() | nil, String.t(), Participation.t() | nil - ) :: - {list(String.t()), list(String.t())} + ) :: {list(String.t()), list(String.t())} def get_to_and_cc(_, _, _, _, %Participation{} = participation) do participation = Repo.preload(participation, :recipients) diff --git a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex index 8fe7be8be..53cf95fbb 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex @@ -138,7 +138,9 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do emojis_text = (user_params["display_name"] || "") <> (user_params["note"] || "") user_info_emojis = - ((user.info.emoji || []) ++ Formatter.get_emoji_map(emojis_text)) + user.info + |> Map.get(:emoji, []) + |> Enum.concat(Formatter.get_emoji_map(emojis_text)) |> Enum.dedup() info_params = @@ -157,6 +159,12 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do end) end) |> add_if_present(params, "default_scope", :default_scope) + |> add_if_present(params, "fields", :fields, fn fields -> + fields = Enum.map(fields, fn f -> Map.update!(f, "value", &AutoLinker.link(&1)) end) + + {:ok, fields} + end) + |> add_if_present(params, "fields", :raw_fields) |> add_if_present(params, "pleroma_settings_store", :pleroma_settings_store, fn value -> {:ok, Map.merge(user.info.pleroma_settings_store, value)} end) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 0ef568f0f..169116d0d 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -94,12 +94,18 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do end) fields = - (user.info.source_data["attachment"] || []) - |> Enum.filter(fn %{"type" => t} -> t == "PropertyValue" end) - |> Enum.map(fn fields -> Map.take(fields, ["name", "value"]) end) + user.info + |> User.Info.fields() + |> Enum.map(fn %{"name" => name, "value" => value} -> + %{ + "name" => Pleroma.HTML.strip_tags(name), + "value" => Pleroma.HTML.filter_tags(value, Pleroma.HTML.Scrubber.LinksOnly) + } + end) - bio = HTML.filter_tags(user.bio, User.html_filter_policy(opts[:for])) + raw_fields = Map.get(user.info, :raw_fields, []) + bio = HTML.filter_tags(user.bio, User.html_filter_policy(opts[:for])) relationship = render("relationship.json", %{user: opts[:for], target: user}) %{ @@ -124,6 +130,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do source: %{ note: HTML.strip_tags((user.bio || "") |> String.replace("<br>", "\n")), sensitive: false, + fields: raw_fields, pleroma: %{} }, diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 42fbdf51b..a4ee0b5dd 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do require Pleroma.Constants alias Pleroma.Activity + alias Pleroma.ActivityExpiration alias Pleroma.Conversation alias Pleroma.Conversation.Participation alias Pleroma.HTML @@ -177,6 +178,15 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil + client_posted_this_activity = opts[:for] && user.id == opts[:for].id + + expires_at = + with true <- client_posted_this_activity, + expiration when not is_nil(expiration) <- + ActivityExpiration.get_by_activity_id(activity.id) do + expiration.scheduled_at + end + thread_muted? = case activity.thread_muted? do thread_muted? when is_boolean(thread_muted?) -> thread_muted? @@ -288,6 +298,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do in_reply_to_account_acct: reply_to_user && reply_to_user.nickname, content: %{"text/plain" => content_plaintext}, spoiler_text: %{"text/plain" => summary_plaintext}, + expires_at: expires_at, direct_conversation_id: direct_conversation_id } } diff --git a/lib/pleroma/web/ostatus/ostatus_controller.ex b/lib/pleroma/web/ostatus/ostatus_controller.ex index c70063b84..07e2a4c2d 100644 --- a/lib/pleroma/web/ostatus/ostatus_controller.ex +++ b/lib/pleroma/web/ostatus/ostatus_controller.ex @@ -22,6 +22,11 @@ defmodule Pleroma.Web.OStatus.OStatusController do alias Pleroma.Web.Router alias Pleroma.Web.XML + plug( + Pleroma.Plugs.RateLimiter, + {:ap_routes, params: ["uuid"]} when action in [:object, :activity] + ) + plug(Pleroma.Web.FederatingPlug when action in [:salmon_incoming]) plug( @@ -32,8 +37,7 @@ defmodule Pleroma.Web.OStatus.OStatusController do action_fallback(:errors) def feed_redirect(%{assigns: %{format: "html"}} = conn, %{"nickname" => nickname}) do - with {_, %User{} = user} <- - {:fetch_user, User.get_cached_by_nickname_or_id(nickname)} do + with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname_or_id(nickname)} do RedirectController.redirector_with_meta(conn, %{user: user}) end end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 1eb6f7b9d..1ad33630c 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -133,6 +133,10 @@ defmodule Pleroma.Web.Router do }) end + pipeline :http_signature do + plug(Pleroma.Web.Plugs.HTTPSignaturePlug) + end + scope "/api/pleroma", Pleroma.Web.TwitterAPI do pipe_through(:pleroma_api) @@ -155,7 +159,7 @@ defmodule Pleroma.Web.Router do post("/users/unfollow", AdminAPIController, :user_unfollow) delete("/users", AdminAPIController, :user_delete) - post("/users", AdminAPIController, :user_create) + post("/users", AdminAPIController, :users_create) patch("/users/:nickname/toggle_activation", AdminAPIController, :user_toggle_activation) put("/users/tag", AdminAPIController, :tag_users) delete("/users/tag", AdminAPIController, :untag_users) @@ -198,6 +202,8 @@ defmodule Pleroma.Web.Router do post("/config", AdminAPIController, :config_update) get("/config/migrate_to_db", AdminAPIController, :migrate_to_db) get("/config/migrate_from_db", AdminAPIController, :migrate_from_db) + + get("/moderation_log", AdminAPIController, :list_log) end scope "/", Pleroma.Web.TwitterAPI do @@ -686,7 +692,14 @@ defmodule Pleroma.Web.Router do pipe_through(:ap_service_actor) get("/", ActivityPubController, :relay) - post("/inbox", ActivityPubController, :inbox) + + scope [] do + pipe_through(:http_signature) + post("/inbox", ActivityPubController, :inbox) + end + + get("/following", ActivityPubController, :following, assigns: %{relay: true}) + get("/followers", ActivityPubController, :followers, assigns: %{relay: true}) end scope "/internal/fetch", Pleroma.Web.ActivityPub do diff --git a/lib/pleroma/web/templates/email/digest.html.eex b/lib/pleroma/web/templates/email/digest.html.eex index c9dd699fd..860df5f9c 100644 --- a/lib/pleroma/web/templates/email/digest.html.eex +++ b/lib/pleroma/web/templates/email/digest.html.eex @@ -1,20 +1,568 @@ -<h1>Hey <%= @user.nickname %>, here is what you've missed!</h1> - -<h2>New Mentions:</h2> -<ul> -<%= for %{data: mention, object: object, from: from} <- @mentions do %> - <li><%= link from.nickname, to: mention.activity.actor %>: <%= raw object.data["content"] %></li> -<% end %> -</ul> - -<%= if @followers != [] do %> -<h2><%= length(@followers) %> New Followers:</h2> -<ul> -<%= for %{data: follow, from: from} <- @followers do %> - <li><%= link from.nickname, to: follow.activity.actor %></li> -<% end %> -</ul> -<% end %> - -<p>You have received this email because you have signed up to receive digest emails from <b><%= @instance %></b> Pleroma instance.</p> -<p>The email address you are subscribed as is <%= @user.email %>. To unsubscribe, please go <%= link "here", to: @unsubscribe_link %>.</p>
\ No newline at end of file +<!DOCTYPE html + PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> + +<html xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office" + xmlns:v="urn:schemas-microsoft-com:vml"> + +<head> + <!--[if gte mso 9]><xml><o:OfficeDocumentSettings><o:AllowPNG/><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]--> + <meta content="text/html; charset=utf-8" http-equiv="Content-Type" /> + <meta content="width=device-width" name="viewport" /> + <!--[if !mso]><!--> + <meta content="IE=edge" http-equiv="X-UA-Compatible" /> + <!--<![endif]--> + <title><%= @email.subject %><</title> + <!--[if !mso]><!--> + <!--<![endif]--> + <style type="text/css"> + body { + margin: 0; + padding: 0; + } + + a { + + color: <%= @styling.link_color %>; + text-decoration: none; + } + + table, + td, + tr { + vertical-align: top; + border-collapse: collapse; + } + + * { + line-height: inherit; + } + + a[x-apple-data-detectors=true] { + color: inherit !important; + text-decoration: none !important; + } + </style> + <style id="media-query" type="text/css"> + @media (max-width: 610px) { + + .block-grid, + .col { + min-width: 320px !important; + max-width: 100% !important; + display: block !important; + } + + .block-grid { + width: 100% !important; + } + + .col { + width: 100% !important; + } + + .col>div { + margin: 0 auto; + } + + .no-stack .col { + min-width: 0 !important; + display: table-cell !important; + } + + .no-stack.two-up .col { + width: 50% !important; + } + + .no-stack .col.num4 { + width: 33% !important; + } + + .no-stack .col.num8 { + width: 66% !important; + } + + .no-stack .col.num4 { + width: 33% !important; + } + + .no-stack .col.num3 { + width: 25% !important; + } + + .no-stack .col.num6 { + width: 50% !important; + } + + .no-stack .col.num9 { + width: 75% !important; + } + + } + </style> +</head> + +<body class="clean-body" style="margin: 0; padding: 0; -webkit-text-size-adjust: 100%; background-color: <%= @styling.background_color %>;"> + <!--[if IE]><div class="ie-browser"><![endif]--> + <table bgcolor="<%= @styling.background_color %>" cellpadding="0" cellspacing="0" class="nl-container" role="presentation" + style="table-layout: fixed; vertical-align: top; min-width: 320px; Margin: 0 auto; border-spacing: 0; border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: <%= @styling.background_color %>; width: 100%;" + valign="top" width="100%"> + <tbody> + <tr style="vertical-align: top;" valign="top"> + <td style="word-break: break-word; vertical-align: top;" valign="top"> + <!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" style="background-color:<%= @styling.background_color %>"><![endif]--> + <div style="background-color:transparent;"> + <div class="block-grid" + style="Margin: 0 auto; min-width: 320px; max-width: 590px; overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; background-color: <%= @styling.content_background_color%>;"> + <div style="border-collapse: collapse;display: table;width: 100%;background-color:<%= @styling.content_background_color%>;"> + <!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:transparent;"><tr><td align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:590px"><tr class="layout-full-width" style="background-color:<%= @styling.content_background_color%>"><![endif]--> + <!--[if (mso)|(IE)]><td align="center" width="590" style="background-color:<%= @styling.content_background_color%>;width:590px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 0px; padding-left: 0px; padding-top:5px; padding-bottom:5px;"><![endif]--> + <div class="col num12" + style="min-width: 320px; max-width: 590px; display: table-cell; vertical-align: top; width: 590px;"> + <div style="width:100% !important;"> + <!--[if (!mso)&(!IE)]><!--> + <div + style="border-top:0px solid transparent; border-left:0px solid transparent; border-bottom:0px solid transparent; border-right:0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 0px; padding-left: 0px;"> + <!--<![endif]--> + <div align="center" class="img-container center" + style="padding-right: 0px;padding-left: 0px;"> + <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr style="line-height:0px"><td style="padding-right: 0px;padding-left: 0px;" align="center"><![endif]--><img + align="center" alt="Image" border="0" class="center" src="cid:logo.png" + style="text-decoration: none; -ms-interpolation-mode: bicubic; border: 0; height: 80px; width: auto; max-height: 80px; display: block;" + title="Image" height="80" /> + <!--[if mso]></td></tr></table><![endif]--> + </div> + <!--[if (!mso)&(!IE)]><!--> + </div> + <!--<![endif]--> + </div> + </div> + <!--[if (mso)|(IE)]></td></tr></table><![endif]--> + <!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]--> + </div> + </div> + </div> + <div style="background-color:transparent;"> + <div class="block-grid" + style="Margin: 0 auto; min-width: 320px; max-width: 590px; overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; background-color: <%= @styling.content_background_color%>;"> + <div style="border-collapse: collapse;display: table;width: 100%;background-color:<%= @styling.content_background_color%>;"> + <!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:transparent;"><tr><td align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:590px"><tr class="layout-full-width" style="background-color:<%= @styling.content_background_color%>"><![endif]--> + <!--[if (mso)|(IE)]><td align="center" width="590" style="background-color:<%= @styling.content_background_color%>;width:590px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 0px; padding-left: 0px; padding-top:5px; padding-bottom:5px;"><![endif]--> + <div class="col num12" + style="min-width: 320px; max-width: 590px; display: table-cell; vertical-align: top; width: 590px;"> + <div style="width:100% !important;"> + <!--[if (!mso)&(!IE)]><!--> + <div + style="border-top:0px solid transparent; border-left:0px solid transparent; border-bottom:0px solid transparent; border-right:0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 0px; padding-left: 0px;"> + <!--<![endif]--> + <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif"><![endif]--> + <div + style="line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:10px;"> + <div + style="font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif;line-height: 14px; color: <%= @styling.header_color %>;"> + <p style="line-height: 36px; text-align: center; margin: 0;"><span + style="font-size: 30px; color: <%= @styling.header_color %>;">Hey <%= @user.nickname %>, here is what you've missed!</span></p> + </div> + </div> + <!--[if mso]></td></tr></table><![endif]--> + <!--[if (!mso)&(!IE)]><!--> + </div> + <!--<![endif]--> + </div> + </div> + <!--[if (mso)|(IE)]></td></tr></table><![endif]--> + <!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]--> + </div> + </div> + </div> + <div style="background-color:transparent;"> + <div class="block-grid" + style="Margin: 0 auto; min-width: 320px; max-width: 590px; overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; background-color: <%= @styling.content_background_color%>;"> + <div style="border-collapse: collapse;display: table;width: 100%;background-color:<%= @styling.content_background_color%>;"> + <!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:transparent;"><tr><td align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:590px"><tr class="layout-full-width" style="background-color:<%= @styling.content_background_color%>"><![endif]--> + <!--[if (mso)|(IE)]><td align="center" width="590" style="background-color:<%= @styling.content_background_color%>;width:590px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 15px; padding-left: 15px; padding-top:5px; padding-bottom:5px;"><![endif]--> + <div class="col num12" + style="min-width: 320px; max-width: 590px; display: table-cell; vertical-align: top; width: 590px;"> + <div style="width:100% !important;"> + <!--[if (!mso)&(!IE)]><!--> + <div + style="border-top:0px solid transparent; border-left:0px solid transparent; border-bottom:0px solid transparent; border-right:0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 15px; padding-left: 15px;"> + <!--<![endif]--> + <table border="0" cellpadding="0" cellspacing="0" class="divider" role="presentation" + style="table-layout: fixed; vertical-align: top; border-spacing: 0; border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; min-width: 100%; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;" + valign="top" width="100%"> + <tbody> + <tr style="vertical-align: top;" valign="top"> + <td class="divider_inner" + style="word-break: break-word; vertical-align: top; min-width: 100%; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; padding-top: 10px; padding-right: 10px; padding-bottom: 10px; padding-left: 10px;" + valign="top"> + <table align="center" border="0" cellpadding="0" cellspacing="0" class="divider_content" + height="0" role="presentation" + style="table-layout: fixed; vertical-align: top; border-spacing: 0; border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; border-top: 1px solid <%= @styling.text_color %>; height: 0px;" + valign="top" width="100%"> + <tbody> + <tr style="vertical-align: top;" valign="top"> + <td height="0" + style="word-break: break-word; vertical-align: top; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;" + valign="top"><span></span></td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif"><![endif]--> + <div + style="color:<%= @styling.text_color %>;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif;line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:10px;"> + <p + style="font-size: 12px; line-height: 24px; text-align: center; color: <%= @styling.text_color %>; font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif; margin: 0;"> + <span style="font-size: 20px;">Mentions</span></p> + </div> + <!--[if mso]></td></tr></table><![endif]--> + <!--[if (!mso)&(!IE)]><!--> + </div> + <!--<![endif]--> + </div> + </div> + <!--[if (mso)|(IE)]></td></tr></table><![endif]--> + <!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]--> + </div> + </div> + </div> + + <%= for %{data: mention, object: object, from: from} <- @mentions do %> + <%# mention START %> + <%# user card START %> + <div style="background-color:transparent;"> + <div class="block-grid mixed-two-up no-stack" + style="Margin: 0 auto; min-width: 320px; max-width: 590px; overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; background-color: <%= @styling.content_background_color%>;"> + <div style="border-collapse: collapse;display: table;width: 100%;background-color:<%= @styling.content_background_color%>;"> + <!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:transparent;"><tr><td align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:590px"><tr class="layout-full-width" style="background-color:<%= @styling.content_background_color%>"><![endif]--> + <!--[if (mso)|(IE)]><td align="center" width="147" style="background-color:<%= @styling.content_background_color%>;width:76px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 0px; padding-left: 20px; padding-top:5px; padding-bottom:5px;"><![endif]--> + <div class="col num3" + style="display: table-cell; vertical-align: top; max-width: 320px; min-width: 76px; width: 76px;"> + <div style="width:100% !important;"> + <!--[if (!mso)&(!IE)]><!--> + <div + style="border-top:0px solid transparent; border-left:0px solid transparent; border-bottom:0px solid transparent; border-right:0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 0px; padding-left: 20px;"> + <!--<![endif]--> + <div align="left" class="img-container left " + style="padding-right: 0px;padding-left: 0px;"> + <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr style="line-height:0px"><td style="padding-right: 0px;padding-left: 0px;" align="left"><![endif]--><img + alt="<%= from.name %>" border="0" class="left " src="<%= avatar_url(from) %>" + style="text-decoration: none; -ms-interpolation-mode: bicubic; border: 0; height: auto; width: 100%; max-width: 76px; display: block;" + title="<%= from.name %>" width="76" /> + <!--[if mso]></td></tr></table><![endif]--> + </div> + <!--[if (!mso)&(!IE)]><!--> + </div> + <!--<![endif]--> + </div> + </div> + + <!--[if (mso)|(IE)]></td></tr></table><![endif]--> + <!--[if (mso)|(IE)]></td><td align="center" width="442" style="background-color:<%= @styling.content_background_color%>;width:442px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 0px; padding-left: 0px; padding-top:5px; padding-bottom:5px;"><![endif]--> + <div class="col num9" + style="display: table-cell; vertical-align: top; min-width: 320px; max-width: 441px; width: 442px;"> + <div style="width:100% !important;"> + <!--[if (!mso)&(!IE)]><!--> + <div + style="border-top:0px solid transparent; border-left:0px solid transparent; border-bottom:0px solid transparent; border-right:0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 0px; padding-left: 0px;"> + <!--<![endif]--> + <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif"><![endif]--> + <div + style="color:<%= @styling.text_color %>;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif;line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:10px;"> + <div + style="font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif; font-size: 12px; line-height: 14px; color: <%= @styling.text_color %>;"> + <p style="font-size: 14px; line-height: 19px; margin: 0;"><span + style="font-size: 16px; color: <%= @styling.text_color %>;"><%= from.name %></span></p> + <p style="font-size: 14px; line-height: 19px; margin: 0;"><span + style="font-size: 16px;"><%= link "@" <> from.nickname, style: "color: #{@styling.link_color};text-decoration: none;", to: mention.activity.actor %></span></p> + </div> + </div> + <!--[if mso]></td></tr></table><![endif]--> + <!--[if (!mso)&(!IE)]><!--> + </div> + <!--<![endif]--> + </div> + </div> + <!--[if (mso)|(IE)]></td></tr></table><![endif]--> + <!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]--> + </div> + </div> + </div> + <%# user card END %> + + <div style="background-color:transparent;"> + <div class="block-grid" + style="Margin: 0 auto; min-width: 320px; max-width: 590px; overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; background-color: <%= @styling.content_background_color%>;"> + <div style="border-collapse: collapse;display: table;width: 100%;background-color:<%= @styling.content_background_color%>;"> + <!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:transparent;"><tr><td align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:590px"><tr class="layout-full-width" style="background-color:<%= @styling.content_background_color%>"><![endif]--> + <!--[if (mso)|(IE)]><td align="center" width="590" style="background-color:<%= @styling.content_background_color%>;width:590px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 15px; padding-left: 15px; padding-top:5px; padding-bottom:5px;"><![endif]--> + <div class="col num12" + style="min-width: 320px; max-width: 590px; display: table-cell; vertical-align: top; width: 590px;"> + <div style="width:100% !important;"> + <!--[if (!mso)&(!IE)]><!--> + <div + style="border-top:0px solid transparent; border-left:0px solid transparent; border-bottom:0px solid transparent; border-right:0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 15px; padding-left: 15px;"> + <!--<![endif]--> + <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif"><![endif]--> + <div + style="color:<%= @styling.text_color %>;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif;line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:10px;"> + <div + style="font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif; font-size: 12px; line-height: 14px; color: <%= @styling.text_color %>;"> + <span style="font-size: 16px; line-height: 19px;"><%= raw object.data["content"] %></span></div> + </div> + <!--[if mso]></td></tr></table><![endif]--> + <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 10px; padding-left: 15px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif"><![endif]--> + <div + style="color:<%= @styling.text_muted_color %>;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif;line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:15px;"> + <div + style="font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif; font-size: 12px; line-height: 14px; color: <%= @styling.text_muted_color %>;"> + <p style="font-size: 14px; line-height: 16px; margin: 0;"><%= format_date object.data["published"] %></p> + </div> + </div> + <!--[if mso]></td></tr></table><![endif]--> + <!--[if (!mso)&(!IE)]><!--> + </div> + <!--<![endif]--> + </div> + </div> + <!--[if (mso)|(IE)]></td></tr></table><![endif]--> + <!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]--> + </div> + </div> + </div> + <%# mention END %> + <% end %> + + <%= if @followers != [] do %> + + <%# new followers header START %> + <div style="background-color:transparent;"> + <div class="block-grid" + style="Margin: 0 auto; min-width: 320px; max-width: 590px; overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; background-color: <%= @styling.content_background_color%>;"> + <div style="border-collapse: collapse;display: table;width: 100%;background-color:<%= @styling.content_background_color%>;"> + <!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:transparent;"><tr><td align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:590px"><tr class="layout-full-width" style="background-color:<%= @styling.content_background_color%>"><![endif]--> + <!--[if (mso)|(IE)]><td align="center" width="590" style="background-color:<%= @styling.content_background_color%>;width:590px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 15px; padding-left: 15px; padding-top:5px; padding-bottom:5px;"><![endif]--> + <div class="col num12" + style="min-width: 320px; max-width: 590px; display: table-cell; vertical-align: top; width: 590px;"> + <div style="width:100% !important;"> + <!--[if (!mso)&(!IE)]><!--> + <div + style="border-top:0px solid transparent; border-left:0px solid transparent; border-bottom:0px solid transparent; border-right:0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 15px; padding-left: 15px;"> + <!--<![endif]--> + <table border="0" cellpadding="0" cellspacing="0" class="divider" role="presentation" + style="table-layout: fixed; vertical-align: top; border-spacing: 0; border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; min-width: 100%; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;" + valign="top" width="100%"> + <tbody> + <tr style="vertical-align: top;" valign="top"> + <td class="divider_inner" + style="word-break: break-word; vertical-align: top; min-width: 100%; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; padding-top: 10px; padding-right: 10px; padding-bottom: 10px; padding-left: 10px;" + valign="top"> + <table align="center" border="0" cellpadding="0" cellspacing="0" class="divider_content" + height="0" role="presentation" + style="table-layout: fixed; vertical-align: top; border-spacing: 0; border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; border-top: 1px solid <%= @styling.text_color %>; height: 0px;" + valign="top" width="100%"> + <tbody> + <tr style="vertical-align: top;" valign="top"> + <td height="0" + style="word-break: break-word; vertical-align: top; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;" + valign="top"><span></span></td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif"><![endif]--> + <div + style="color:<%= @styling.text_color %>;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif;line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:10px;"> + <div + style="font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif; font-size: 12px; line-height: 14px; color: <%= @styling.text_color %>;"> + <p style="font-size: 12px; line-height: 24px; text-align: center; margin: 0;"><span + style="font-size: 20px;"><%= length(@followers) %> New Followers</span><span + style="font-size: 20px; line-height: 24px;"></span></p> + </div> + </div> + <!--[if mso]></td></tr></table><![endif]--> + <!--[if (!mso)&(!IE)]><!--> + </div> + <!--<![endif]--> + </div> + </div> + <!--[if (mso)|(IE)]></td></tr></table><![endif]--> + <!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]--> + </div> + </div> + </div> + <%# new followers header END %> + + <%= for %{data: follow, from: from} <- @followers do %> + <%# user card START %> + <div style="background-color:transparent;"> + <div class="block-grid mixed-two-up no-stack" + style="Margin: 0 auto; min-width: 320px; max-width: 590px; overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; background-color: <%= @styling.content_background_color%>;"> + <div style="border-collapse: collapse;display: table;width: 100%;background-color:<%= @styling.content_background_color%>;"> + <!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:transparent;"><tr><td align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:590px"><tr class="layout-full-width" style="background-color:<%= @styling.content_background_color%>"><![endif]--> + <!--[if (mso)|(IE)]><td align="center" width="147" style="background-color:<%= @styling.content_background_color%>;width:76px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 0px; padding-left: 20px; padding-top:5px; padding-bottom:5px;"><![endif]--> + <div class="col num3" + style="display: table-cell; vertical-align: top; max-width: 320px; min-width: 76px; width: 76px;"> + <div style="width:100% !important;"> + <!--[if (!mso)&(!IE)]><!--> + <div + style="border-top:0px solid transparent; border-left:0px solid transparent; border-bottom:0px solid transparent; border-right:0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 0px; padding-left: 20px;"> + <!--<![endif]--> + <div align="left" class="img-container left " + style="padding-right: 0px;padding-left: 0px;"> + <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr style="line-height:0px"><td style="padding-right: 0px;padding-left: 0px;" align="left"><![endif]--><img + alt="<%= from.name %>" border="0" class="left " src="<%= avatar_url(from) %>" + style="text-decoration: none; -ms-interpolation-mode: bicubic; border: 0; height: auto; width: 100%; max-width: 76px; display: block;" + title="<%= from.name %>" width="76" /> + <!--[if mso]></td></tr></table><![endif]--> + </div> + <!--[if (!mso)&(!IE)]><!--> + </div> + <!--<![endif]--> + </div> + </div> + + <!--[if (mso)|(IE)]></td></tr></table><![endif]--> + <!--[if (mso)|(IE)]></td><td align="center" width="442" style="background-color:<%= @styling.content_background_color%>;width:442px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 0px; padding-left: 0px; padding-top:5px; padding-bottom:5px;"><![endif]--> + <div class="col num9" + style="display: table-cell; vertical-align: top; min-width: 320px; max-width: 441px; width: 442px;"> + <div style="width:100% !important;"> + <!--[if (!mso)&(!IE)]><!--> + <div + style="border-top:0px solid transparent; border-left:0px solid transparent; border-bottom:0px solid transparent; border-right:0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 0px; padding-left: 0px;"> + <!--<![endif]--> + <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif"><![endif]--> + <div + style="color:<%= @styling.text_color %>;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif;line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:10px;"> + <div + style="font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif; font-size: 12px; line-height: 14px; color: <%= @styling.text_color %>;"> + <p style="font-size: 14px; line-height: 19px; margin: 0;"><span + style="font-size: 16px; color: <%= @styling.text_color %>;"><%= from.name %></span></p> + <p style="font-size: 14px; line-height: 19px; margin: 0;"><span + style="font-size: 16px;"><%= link "@" <> from.nickname, style: "color: #{@styling.link_color};text-decoration: none;", to: follow.activity.actor %></span></p> + </div> + </div> + <!--[if mso]></td></tr></table><![endif]--> + <!--[if (!mso)&(!IE)]><!--> + </div> + <!--<![endif]--> + </div> + </div> + <!--[if (mso)|(IE)]></td></tr></table><![endif]--> + <!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]--> + </div> + </div> + </div> + <%# user card END %> + <% end %> + + + <% end %> + + <%# divider start %> + <div style="background-color:transparent;"> + <div class="block-grid" + style="Margin: 0 auto; min-width: 320px; max-width: 590px; overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; background-color: <%= @styling.content_background_color%>;"> + <div style="border-collapse: collapse;display: table;width: 100%;background-color:<%= @styling.content_background_color%>;"> + <!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:transparent;"><tr><td align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:590px"><tr class="layout-full-width" style="background-color:<%= @styling.content_background_color%>"><![endif]--> + <!--[if (mso)|(IE)]><td align="center" width="590" style="background-color:<%= @styling.content_background_color%>;width:590px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 0px; padding-left: 0px; padding-top:5px; padding-bottom:5px;"><![endif]--> + <div class="col num12" + style="min-width: 320px; max-width: 590px; display: table-cell; vertical-align: top; width: 590px;"> + <div style="width:100% !important;"> + <!--[if (!mso)&(!IE)]><!--> + <div + style="border-top:0px solid transparent; border-left:0px solid transparent; border-bottom:0px solid transparent; border-right:0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 0px; padding-left: 0px;"> + <!--<![endif]--> + <table border="0" cellpadding="0" cellspacing="0" class="divider" role="presentation" + style="table-layout: fixed; vertical-align: top; border-spacing: 0; border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; min-width: 100%; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;" + valign="top" width="100%"> + <tbody> + <tr style="vertical-align: top;" valign="top"> + <td class="divider_inner" + style="word-break: break-word; vertical-align: top; min-width: 100%; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; padding-top: 10px; padding-right: 10px; padding-bottom: 10px; padding-left: 10px;" + valign="top"> + <table align="center" border="0" cellpadding="0" cellspacing="0" class="divider_content" + height="0" role="presentation" + style="table-layout: fixed; vertical-align: top; border-spacing: 0; border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; border-top: 1px solid <%= @styling.text_color %>; height: 0px;" + valign="top" width="100%"> + <tbody> + <tr style="vertical-align: top;" valign="top"> + <td height="0" + style="word-break: break-word; vertical-align: top; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;" + valign="top"><span></span></td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + <!--[if (!mso)&(!IE)]><!--> + </div> + <!--<![endif]--> + </div> + </div> + <!--[if (mso)|(IE)]></td></tr></table><![endif]--> + <!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]--> + </div> + </div> + </div> + + <%# divider end %> + + + <div style="background-color:transparent;"> + <div class="block-grid" + style="Margin: 0 auto; min-width: 320px; max-width: 590px; overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; background-color: <%= @styling.content_background_color%>;"> + <div style="border-collapse: collapse;display: table;width: 100%;background-color:<%= @styling.content_background_color%>;"> + <!--[if (mso)|(IE)]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:transparent;"><tr><td align="center"><table cellpadding="0" cellspacing="0" border="0" style="width:590px"><tr class="layout-full-width" style="background-color:<%= @styling.content_background_color%>"><![endif]--> + <!--[if (mso)|(IE)]><td align="center" width="590" style="background-color:<%= @styling.content_background_color%>;width:590px; border-top: 0px solid transparent; border-left: 0px solid transparent; border-bottom: 0px solid transparent; border-right: 0px solid transparent;" valign="top"><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 0px; padding-left: 0px; padding-top:5px; padding-bottom:5px;"><![endif]--> + <div class="col num12" + style="min-width: 320px; max-width: 590px; display: table-cell; vertical-align: top; width: 590px;"> + <div style="width:100% !important;"> + <!--[if (!mso)&(!IE)]><!--> + <div + style="border-top:0px solid transparent; border-left:0px solid transparent; border-bottom:0px solid transparent; border-right:0px solid transparent; padding-top:5px; padding-bottom:5px; padding-right: 0px; padding-left: 0px;"> + <!--<![endif]--> + <!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding-right: 10px; padding-left: 10px; padding-top: 10px; padding-bottom: 10px; font-family: Arial, sans-serif"><![endif]--> + <div + style="color:<%= @styling.text_color %>;font-family:Arial, 'Helvetica Neue', Helvetica, sans-serif;line-height:120%;padding-top:10px;padding-right:10px;padding-bottom:10px;padding-left:10px;"> + <p + style="font-size: 12px; line-height: 16px; text-align: center; color: <%= @styling.text_color %>; font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif; margin: 0;"> + <span style="font-size: 14px;">You have received this email because you have signed up to receive digest emails from <b><%= @instance %></b> Pleroma instance.</span></p> + <p + style="font-size: 12px; line-height: 14px; text-align: center; color: <%= @styling.text_color %>; font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif; margin: 0;"> + </p> + <p + style="font-size: 12px; line-height: 16px; text-align: center; color: <%= @styling.text_color %>; font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif; margin: 0;"> + <span style="font-size: 14px;">The email address you are subscribed as is <a href="mailto:<%= @user.email %>" style="color: <%= @styling.link_color %>;text-decoration: none;"><%= @user.email %></a>. </span></p> + <p + style="font-size: 12px; line-height: 16px; text-align: center; color: <%= @styling.text_color %>; font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif; margin: 0;"> + <span style="font-size: 14px;">To unsubscribe, please go <%= link "here", style: "color: #{@styling.link_color};text-decoration: none;", to: @unsubscribe_link %>.</span></p> + </div> + <!--[if mso]></td></tr></table><![endif]--> + <!--[if (!mso)&(!IE)]><!--> + </div> + <!--<![endif]--> + </div> + </div> + <!--[if (mso)|(IE)]></td></tr></table><![endif]--> + <!--[if (mso)|(IE)]></td></tr></table></td></tr></table><![endif]--> + </div> + </div> + </div> + <!--[if (mso)|(IE)]></td></tr></table><![endif]--> + </td> + </tr> + </tbody> + </table> + <!--[if (IE)]></div><![endif]--> +</body> + +</html> diff --git a/lib/pleroma/web/twitter_api/views/user_view.ex b/lib/pleroma/web/twitter_api/views/user_view.ex index 8d8892068..8a7d2fc72 100644 --- a/lib/pleroma/web/twitter_api/views/user_view.ex +++ b/lib/pleroma/web/twitter_api/views/user_view.ex @@ -74,12 +74,15 @@ defmodule Pleroma.Web.TwitterAPI.UserView do |> HTML.filter_tags(User.html_filter_policy(for_user)) |> Formatter.emojify(emoji) - # ``fields`` is an array of mastodon profile field, containing ``{"name": "…", "value": "…"}``. - # For example: [{"name": "Pronoun", "value": "she/her"}, …] fields = - (user.info.source_data["attachment"] || []) - |> Enum.filter(fn %{"type" => t} -> t == "PropertyValue" end) - |> Enum.map(fn fields -> Map.take(fields, ["name", "value"]) end) + user.info + |> User.Info.fields() + |> Enum.map(fn %{"name" => name, "value" => value} -> + %{ + "name" => Pleroma.HTML.strip_tags(name), + "value" => Pleroma.HTML.filter_tags(value, Pleroma.HTML.Scrubber.LinksOnly) + } + end) data = %{ diff --git a/lib/pleroma/web/views/email_view.ex b/lib/pleroma/web/views/email_view.ex index b63eb162c..b506a234b 100644 --- a/lib/pleroma/web/views/email_view.ex +++ b/lib/pleroma/web/views/email_view.ex @@ -2,4 +2,14 @@ defmodule Pleroma.Web.EmailView do use Pleroma.Web, :view import Phoenix.HTML import Phoenix.HTML.Link + + def avatar_url(user) do + Pleroma.User.avatar_url(user) + end + + def format_date(date) when is_binary(date) do + date + |> Timex.parse!("{ISO:Extended:Z}") + |> Timex.format!("{Mshort} {D}, {YYYY} {h24}:{m}") + end end |