diff options
Diffstat (limited to 'lib')
38 files changed, 916 insertions, 327 deletions
diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index f2b234022..75154f94c 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -156,6 +156,7 @@ defmodule Pleroma.Application do build_cachex("web_resp", limit: 2500), build_cachex("emoji_packs", expiration: emoji_packs_expiration(), limit: 10), build_cachex("failed_proxy_url", limit: 2500), + build_cachex("failed_media_helper_url", default_ttl: :timer.minutes(15), limit: 2_500), build_cachex("banned_urls", default_ttl: :timer.hours(24 * 30), limit: 5_000), build_cachex("chat_message_id_idempotency_key", expiration: chat_message_id_idempotency_key_expiration(), diff --git a/lib/pleroma/application_requirements.ex b/lib/pleroma/application_requirements.ex index 819245481..8c0df64fc 100644 --- a/lib/pleroma/application_requirements.ex +++ b/lib/pleroma/application_requirements.ex @@ -28,6 +28,7 @@ defmodule Pleroma.ApplicationRequirements do |> check_welcome_message_config!() |> check_rum!() |> check_repo_pool_size!() + |> check_mrfs() |> handle_result() end @@ -234,4 +235,25 @@ defmodule Pleroma.ApplicationRequirements do true end end + + defp check_mrfs(:ok) do + mrfs = Config.get!([:mrf, :policies]) + + missing_mrfs = + Enum.reduce(mrfs, [], fn x, acc -> + if Code.ensure_compiled(x) do + acc + else + acc ++ [x] + end + end) + + if Enum.empty?(missing_mrfs) do + :ok + else + {:error, "The following MRF modules are configured but missing: #{inspect(missing_mrfs)}"} + end + end + + defp check_mrfs(result), do: result end diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index d814b4931..3a5e35301 100644 --- a/lib/pleroma/constants.ex +++ b/lib/pleroma/constants.ex @@ -19,7 +19,8 @@ defmodule Pleroma.Constants do "context_id", "deleted_activity_id", "pleroma_internal", - "generator" + "generator", + "rules" ] ) diff --git a/lib/pleroma/helpers/media_helper.ex b/lib/pleroma/helpers/media_helper.ex index 7864296fa..e44114d9d 100644 --- a/lib/pleroma/helpers/media_helper.ex +++ b/lib/pleroma/helpers/media_helper.ex @@ -12,6 +12,8 @@ defmodule Pleroma.Helpers.MediaHelper do require Logger + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + def missing_dependencies do Enum.reduce([ffmpeg: "ffmpeg"], [], fn {sym, executable}, acc -> if Pleroma.Utils.command_available?(executable) do @@ -43,29 +45,40 @@ defmodule Pleroma.Helpers.MediaHelper do @spec video_framegrab(String.t()) :: {:ok, binary()} | {:error, any()} def video_framegrab(url) do with executable when is_binary(executable) <- System.find_executable("ffmpeg"), + false <- @cachex.exists?(:failed_media_helper_cache, url), {:ok, env} <- HTTP.get(url, [], pool: :media), {:ok, pid} <- StringIO.open(env.body) do body_stream = IO.binstream(pid, 1) - result = - Exile.stream!( - [ - executable, - "-i", - "pipe:0", - "-vframes", - "1", - "-f", - "mjpeg", - "pipe:1" - ], - input: body_stream, - ignore_epipe: true, - stderr: :disable - ) - |> Enum.into(<<>>) + task = + Task.async(fn -> + Exile.stream!( + [ + executable, + "-i", + "pipe:0", + "-vframes", + "1", + "-f", + "mjpeg", + "pipe:1" + ], + input: body_stream, + ignore_epipe: true, + stderr: :disable + ) + |> Enum.into(<<>>) + end) + + case Task.yield(task, 5_000) do + nil -> + Task.shutdown(task) + @cachex.put(:failed_media_helper_cache, url, nil) + {:error, {:ffmpeg, :timeout}} - {:ok, result} + result -> + {:ok, result} + end else nil -> {:error, {:ffmpeg, :command_not_found}} {:error, _} = error -> error diff --git a/lib/pleroma/html.ex b/lib/pleroma/html.ex index 84ff2f129..4de7cbb76 100644 --- a/lib/pleroma/html.ex +++ b/lib/pleroma/html.ex @@ -65,20 +65,16 @@ defmodule Pleroma.HTML do end end - @spec extract_first_external_url_from_object(Pleroma.Object.t()) :: - {:ok, String.t()} | {:error, :no_content} + @spec extract_first_external_url_from_object(Pleroma.Object.t()) :: String.t() | nil def extract_first_external_url_from_object(%{data: %{"content" => content}}) when is_binary(content) do - url = - content - |> Floki.parse_fragment!() - |> Floki.find("a:not(.mention,.hashtag,.attachment,[rel~=\"tag\"])") - |> Enum.take(1) - |> Floki.attribute("href") - |> Enum.at(0) - - {:ok, url} + content + |> Floki.parse_fragment!() + |> Floki.find("a:not(.mention,.hashtag,.attachment,[rel~=\"tag\"])") + |> Enum.take(1) + |> Floki.attribute("href") + |> Enum.at(0) end - def extract_first_external_url_from_object(_), do: {:error, :no_content} + def extract_first_external_url_from_object(_), do: nil end diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index 710b19866..cb9bd92b8 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -280,15 +280,10 @@ defmodule Pleroma.Notification do select: n.id ) - {:ok, %{ids: {_, notification_ids}}} = - Multi.new() - |> Multi.update_all(:ids, query, set: [seen: true, updated_at: NaiveDateTime.utc_now()]) - |> Marker.multi_set_last_read_id(user, "notifications") - |> Repo.transaction() - - for_user_query(user) - |> where([n], n.id in ^notification_ids) - |> Repo.all() + Multi.new() + |> Multi.update_all(:ids, query, set: [seen: true, updated_at: NaiveDateTime.utc_now()]) + |> Marker.multi_set_last_read_id(user, "notifications") + |> Repo.transaction() end @spec read_one(User.t(), String.t()) :: @@ -299,10 +294,6 @@ defmodule Pleroma.Notification do |> Multi.update(:update, changeset(notification, %{seen: true})) |> Marker.multi_set_last_read_id(user, "notifications") |> Repo.transaction() - |> case do - {:ok, %{update: notification}} -> {:ok, notification} - {:error, :update, changeset, _} -> {:error, changeset} - end end end @@ -361,36 +352,32 @@ defmodule Pleroma.Notification do end end - @spec create_notifications(Activity.t(), keyword()) :: {:ok, [Notification.t()] | []} - def create_notifications(activity, options \\ []) + @spec create_notifications(Activity.t()) :: {:ok, [Notification.t()] | []} + def create_notifications(activity) - def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = activity, options) do + def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = activity) do object = Object.normalize(activity, fetch: false) if object && object.data["type"] == "Answer" do {:ok, []} else - do_create_notifications(activity, options) + do_create_notifications(activity) end end - def create_notifications(%Activity{data: %{"type" => type}} = activity, options) + def create_notifications(%Activity{data: %{"type" => type}} = activity) when type in ["Follow", "Like", "Announce", "Move", "EmojiReact", "Flag", "Update"] do - do_create_notifications(activity, options) + do_create_notifications(activity) end - def create_notifications(_, _), do: {:ok, []} - - defp do_create_notifications(%Activity{} = activity, options) do - do_send = Keyword.get(options, :do_send, true) + def create_notifications(_), do: {:ok, []} - {enabled_receivers, disabled_receivers} = get_notified_from_activity(activity) - potential_receivers = enabled_receivers ++ disabled_receivers + defp do_create_notifications(%Activity{} = activity) do + enabled_receivers = get_notified_from_activity(activity) notifications = - Enum.map(potential_receivers, fn user -> - do_send = do_send && user in enabled_receivers - create_notification(activity, user, do_send: do_send) + Enum.map(enabled_receivers, fn user -> + create_notification(activity, user) end) |> Enum.reject(&is_nil/1) @@ -450,7 +437,6 @@ defmodule Pleroma.Notification do # TODO move to sql, too. def create_notification(%Activity{} = activity, %User{} = user, opts \\ []) do - do_send = Keyword.get(opts, :do_send, true) type = Keyword.get(opts, :type, type_from_activity(activity)) unless skip?(activity, user, opts) do @@ -465,11 +451,6 @@ defmodule Pleroma.Notification do |> Marker.multi_set_last_read_id(user, "notifications") |> Repo.transaction() - if do_send do - Streamer.stream(["user", "user:notification"], notification) - Push.send(notification) - end - notification end end @@ -527,10 +508,7 @@ defmodule Pleroma.Notification do |> exclude_relationship_restricted_ap_ids(activity) |> exclude_thread_muter_ap_ids(activity) - notification_enabled_users = - Enum.filter(potential_receivers, fn u -> u.ap_id in notification_enabled_ap_ids end) - - {notification_enabled_users, potential_receivers -- notification_enabled_users} + Enum.filter(potential_receivers, fn u -> u.ap_id in notification_enabled_ap_ids end) end def get_notified_from_activity(_, _local_only), do: {[], []} @@ -643,6 +621,7 @@ defmodule Pleroma.Notification do def skip?(%Activity{} = activity, %User{} = user, opts) do [ :self, + :internal, :invisible, :block_from_strangers, :recently_followed, @@ -662,6 +641,12 @@ defmodule Pleroma.Notification do end end + def skip?(:internal, %Activity{} = activity, _user, _opts) do + actor = activity.data["actor"] + user = User.get_cached_by_ap_id(actor) + User.internal?(user) + end + def skip?(:invisible, %Activity{} = activity, _user, _opts) do actor = activity.data["actor"] user = User.get_cached_by_ap_id(actor) @@ -748,4 +733,12 @@ defmodule Pleroma.Notification do ) |> Repo.update_all(set: [seen: true]) end + + @spec send(list(Notification.t())) :: :ok + def send(notifications) do + Enum.each(notifications, fn notification -> + Streamer.stream(["user", "user:notification"], notification) + Push.send(notification) + end) + end end diff --git a/lib/pleroma/rule.ex b/lib/pleroma/rule.ex new file mode 100644 index 000000000..3ba413214 --- /dev/null +++ b/lib/pleroma/rule.ex @@ -0,0 +1,68 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Rule do + use Ecto.Schema + + import Ecto.Changeset + import Ecto.Query + + alias Pleroma.Repo + alias Pleroma.Rule + + schema "rules" do + field(:priority, :integer, default: 0) + field(:text, :string) + field(:hint, :string) + + timestamps() + end + + def changeset(%Rule{} = rule, params \\ %{}) do + rule + |> cast(params, [:priority, :text, :hint]) + |> validate_required([:text]) + end + + def query do + Rule + |> order_by(asc: :priority) + |> order_by(asc: :id) + end + + def get(ids) when is_list(ids) do + from(r in __MODULE__, where: r.id in ^ids) + |> Repo.all() + end + + def get(id), do: Repo.get(__MODULE__, id) + + def exists?(id) do + from(r in __MODULE__, where: r.id == ^id) + |> Repo.exists?() + end + + def create(params) do + {:ok, rule} = + %Rule{} + |> changeset(params) + |> Repo.insert() + + rule + end + + def update(params, id) do + {:ok, rule} = + get(id) + |> changeset(params) + |> Repo.update() + + rule + end + + def delete(id) do + get(id) + |> Repo.delete() + end +end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 2017c696d..643877268 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -147,9 +147,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do # Splice in the child object if we have one. activity = Maps.put_if_present(activity, :object, object) - ConcurrentLimiter.limit(Pleroma.Web.RichMedia.Helpers, fn -> - Task.start(fn -> Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) end) - end) + Pleroma.Web.RichMedia.Card.get_by_activity(activity) # Add local posts to search index if local, do: Pleroma.Search.add_to_index(activity) @@ -177,7 +175,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do id: "pleroma:fakeid" } - Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) + Pleroma.Web.RichMedia.Card.get_by_activity(activity) {:ok, activity} {:remote_limit_pass, _} -> @@ -202,7 +200,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do end def notify_and_stream(activity) do - Notification.create_notifications(activity) + {:ok, notifications} = Notification.create_notifications(activity) + Notification.send(notifications) original_activity = case activity do @@ -1261,6 +1260,15 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp restrict_quote_url(query, _), do: query + defp restrict_rule(query, %{rule_id: rule_id}) do + from( + activity in query, + where: fragment("(?)->'rules' \\? (?)", activity.data, ^rule_id) + ) + end + + defp restrict_rule(query, _), do: query + defp exclude_poll_votes(query, %{include_poll_votes: true}), do: query defp exclude_poll_votes(query, _) do @@ -1423,6 +1431,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do |> restrict_instance(opts) |> restrict_announce_object_actor(opts) |> restrict_filtered(opts) + |> restrict_rule(opts) |> restrict_quote_url(opts) |> maybe_restrict_deactivated_users(opts) |> exclude_poll_votes(opts) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 5cb8a9700..60b4d5f1b 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -21,7 +21,6 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils - alias Pleroma.Web.Push alias Pleroma.Web.Streamer alias Pleroma.Workers.PollWorker @@ -125,7 +124,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do nil end - {:ok, notifications} = Notification.create_notifications(object, do_send: false) + {:ok, notifications} = Notification.create_notifications(object) meta = meta @@ -184,7 +183,11 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do liked_object = Object.get_by_ap_id(object.data["object"]) Utils.add_like_to_object(object, liked_object) - Notification.create_notifications(object) + {:ok, notifications} = Notification.create_notifications(object) + + meta = + meta + |> add_notifications(notifications) {:ok, object, meta} end @@ -202,7 +205,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do def handle(%{data: %{"type" => "Create"}} = activity, meta) do with {:ok, object, meta} <- handle_object_creation(meta[:object_data], activity, meta), %User{} = user <- User.get_cached_by_ap_id(activity.data["actor"]) do - {:ok, notifications} = Notification.create_notifications(activity, do_send: false) + {:ok, notifications} = Notification.create_notifications(activity) {:ok, _user} = ActivityPub.increase_note_count_if_public(user, object) {:ok, _user} = ActivityPub.update_last_status_at_if_public(user, object) @@ -227,9 +230,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do end end - ConcurrentLimiter.limit(Pleroma.Web.RichMedia.Helpers, fn -> - Task.start(fn -> Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) end) - end) + Pleroma.Web.RichMedia.Card.get_by_activity(activity) Pleroma.Search.add_to_index(Map.put(activity, :object, object)) @@ -258,11 +259,13 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do Utils.add_announce_to_object(object, announced_object) - if !User.internal?(user) do - Notification.create_notifications(object) + {:ok, notifications} = Notification.create_notifications(object) - ap_streamer().stream_out(object) - end + if !User.internal?(user), do: ap_streamer().stream_out(object) + + meta = + meta + |> add_notifications(notifications) {:ok, object, meta} end @@ -283,7 +286,11 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do reacted_object = Object.get_by_ap_id(object.data["object"]) Utils.add_emoji_reaction_to_object(object, reacted_object) - Notification.create_notifications(object) + {:ok, notifications} = Notification.create_notifications(object) + + meta = + meta + |> add_notifications(notifications) {:ok, object, meta} end @@ -587,10 +594,7 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do defp send_notifications(meta) do Keyword.get(meta, :notifications, []) - |> Enum.each(fn notification -> - Streamer.stream(["user", "user:notification"], notification) - Push.send(notification) - end) + |> Notification.send() meta end diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index 52cb64fc5..797e79dda 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -721,14 +721,18 @@ defmodule Pleroma.Web.ActivityPub.Utils do #### Flag-related helpers @spec make_flag_data(map(), map()) :: map() - def make_flag_data(%{actor: actor, context: context, content: content} = params, additional) do + def make_flag_data( + %{actor: actor, context: context, content: content} = params, + additional + ) do %{ "type" => "Flag", "actor" => actor.ap_id, "content" => content, "object" => build_flag_object(params), "context" => context, - "state" => "open" + "state" => "open", + "rules" => Map.get(params, :rules, nil) } |> Map.merge(additional) end diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index 24ee683ae..937e4fd67 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -67,8 +67,13 @@ defmodule Pleroma.Web.ActivityPub.UserView do def render("user.json", %{user: %User{nickname: nil} = user}), do: render("service.json", %{user: user}) - def render("user.json", %{user: %User{nickname: "internal." <> _} = user}), - do: render("service.json", %{user: user}) |> Map.put("preferredUsername", user.nickname) + def render("user.json", %{user: %User{nickname: "internal." <> _} = user}) do + render("service.json", %{user: user}) + |> Map.merge(%{ + "preferredUsername" => user.nickname, + "webfinger" => "acct:#{User.full_nickname(user)}" + }) + end def render("user.json", %{user: user}) do {:ok, _, public_key} = Keys.keys_from_pem(user.keys) @@ -121,7 +126,8 @@ defmodule Pleroma.Web.ActivityPub.UserView do "discoverable" => user.is_discoverable, "capabilities" => capabilities, "alsoKnownAs" => user.also_known_as, - "vcard:bday" => birthday + "vcard:bday" => birthday, + "webfinger" => "acct:#{User.full_nickname(user)}" } |> Map.merge(maybe_make_image(&User.avatar_url/2, "icon", user)) |> Map.merge(maybe_make_image(&User.banner_url/2, "image", user)) diff --git a/lib/pleroma/web/admin_api/controllers/rule_controller.ex b/lib/pleroma/web/admin_api/controllers/rule_controller.ex new file mode 100644 index 000000000..43b2f209a --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/rule_controller.ex @@ -0,0 +1,62 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.RuleController do + use Pleroma.Web, :controller + + alias Pleroma.Repo + alias Pleroma.Rule + alias Pleroma.Web.Plugs.OAuthScopesPlug + + import Pleroma.Web.ControllerHelper, + only: [ + json_response: 3 + ] + + plug(Pleroma.Web.ApiSpec.CastAndValidate) + + plug( + OAuthScopesPlug, + %{scopes: ["admin:write"]} + when action in [:create, :update, :delete] + ) + + plug(OAuthScopesPlug, %{scopes: ["admin:read"]} when action == :index) + + action_fallback(AdminAPI.FallbackController) + + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.RuleOperation + + def index(conn, _) do + rules = + Rule.query() + |> Repo.all() + + render(conn, "index.json", rules: rules) + end + + def create(%{body_params: params} = conn, _) do + rule = + params + |> Rule.create() + + render(conn, "show.json", rule: rule) + end + + def update(%{body_params: params} = conn, %{id: id}) do + rule = + params + |> Rule.update(id) + + render(conn, "show.json", rule: rule) + end + + def delete(conn, %{id: id}) do + with {:ok, _} <- Rule.delete(id) do + json(conn, %{}) + else + _ -> json_response(conn, :bad_request, "") + end + end +end diff --git a/lib/pleroma/web/admin_api/views/report_view.ex b/lib/pleroma/web/admin_api/views/report_view.ex index b761dbb22..b4b0be267 100644 --- a/lib/pleroma/web/admin_api/views/report_view.ex +++ b/lib/pleroma/web/admin_api/views/report_view.ex @@ -6,9 +6,11 @@ defmodule Pleroma.Web.AdminAPI.ReportView do use Pleroma.Web, :view alias Pleroma.HTML + alias Pleroma.Rule alias Pleroma.User alias Pleroma.Web.AdminAPI alias Pleroma.Web.AdminAPI.Report + alias Pleroma.Web.AdminAPI.RuleView alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.StatusView @@ -46,7 +48,8 @@ defmodule Pleroma.Web.AdminAPI.ReportView do as: :activity }), state: report.data["state"], - notes: render(__MODULE__, "index_notes.json", %{notes: report.report_notes}) + notes: render(__MODULE__, "index_notes.json", %{notes: report.report_notes}), + rules: rules(Map.get(report.data, "rules", nil)) } end @@ -71,4 +74,16 @@ defmodule Pleroma.Web.AdminAPI.ReportView do created_at: Utils.to_masto_date(inserted_at) } end + + defp rules(nil) do + [] + end + + defp rules(rule_ids) do + rules = + rule_ids + |> Rule.get() + + render(RuleView, "index.json", rules: rules) + end end diff --git a/lib/pleroma/web/admin_api/views/rule_view.ex b/lib/pleroma/web/admin_api/views/rule_view.ex new file mode 100644 index 000000000..606443f05 --- /dev/null +++ b/lib/pleroma/web/admin_api/views/rule_view.ex @@ -0,0 +1,22 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.RuleView do + use Pleroma.Web, :view + + require Pleroma.Constants + + def render("index.json", %{rules: rules} = _opts) do + render_many(rules, __MODULE__, "show.json") + end + + def render("show.json", %{rule: rule} = _opts) do + %{ + id: to_string(rule.id), + priority: rule.priority, + text: rule.text, + hint: rule.hint + } + end +end diff --git a/lib/pleroma/web/api_spec.ex b/lib/pleroma/web/api_spec.ex index 10d221571..314782818 100644 --- a/lib/pleroma/web/api_spec.ex +++ b/lib/pleroma/web/api_spec.ex @@ -97,6 +97,7 @@ defmodule Pleroma.Web.ApiSpec do "Frontend management", "Instance configuration", "Instance documents", + "Instance rule managment", "Invites", "MediaProxy cache", "OAuth application management", diff --git a/lib/pleroma/web/api_spec/operations/admin/report_operation.ex b/lib/pleroma/web/api_spec/operations/admin/report_operation.ex index fbb6896a9..25a604beb 100644 --- a/lib/pleroma/web/api_spec/operations/admin/report_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/report_operation.ex @@ -31,6 +31,12 @@ defmodule Pleroma.Web.ApiSpec.Admin.ReportOperation do "Filter by report state" ), Operation.parameter( + :rule_id, + :query, + %Schema{type: :string}, + "Filter by selected rule id" + ), + Operation.parameter( :limit, :query, %Schema{type: :integer}, @@ -169,6 +175,17 @@ defmodule Pleroma.Web.ApiSpec.Admin.ReportOperation do inserted_at: %Schema{type: :string, format: :"date-time"} } } + }, + rules: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string}, + text: %Schema{type: :string}, + hint: %Schema{type: :string, nullable: true} + } + } } } } diff --git a/lib/pleroma/web/api_spec/operations/admin/rule_operation.ex b/lib/pleroma/web/api_spec/operations/admin/rule_operation.ex new file mode 100644 index 000000000..c3a3ecc7c --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/rule_operation.ex @@ -0,0 +1,115 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.RuleOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + + import Pleroma.Web.ApiSpec.Helpers + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def index_operation do + %Operation{ + tags: ["Instance rule managment"], + summary: "Retrieve list of instance rules", + operationId: "AdminAPI.RuleController.index", + security: [%{"oAuth" => ["admin:read"]}], + responses: %{ + 200 => + Operation.response("Response", "application/json", %Schema{ + type: :array, + items: rule() + }), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def create_operation do + %Operation{ + tags: ["Instance rule managment"], + summary: "Create new rule", + operationId: "AdminAPI.RuleController.create", + security: [%{"oAuth" => ["admin:write"]}], + parameters: admin_api_params(), + requestBody: request_body("Parameters", create_request(), required: true), + responses: %{ + 200 => Operation.response("Response", "application/json", rule()), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def update_operation do + %Operation{ + tags: ["Instance rule managment"], + summary: "Modify existing rule", + operationId: "AdminAPI.RuleController.update", + security: [%{"oAuth" => ["admin:write"]}], + parameters: [Operation.parameter(:id, :path, :string, "Rule ID")], + requestBody: request_body("Parameters", update_request(), required: true), + responses: %{ + 200 => Operation.response("Response", "application/json", rule()), + 400 => Operation.response("Bad Request", "application/json", ApiError), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Instance rule managment"], + summary: "Delete rule", + operationId: "AdminAPI.RuleController.delete", + parameters: [Operation.parameter(:id, :path, :string, "Rule ID")], + security: [%{"oAuth" => ["admin:write"]}], + responses: %{ + 200 => empty_object_response(), + 404 => Operation.response("Not Found", "application/json", ApiError), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + defp create_request do + %Schema{ + type: :object, + required: [:text], + properties: %{ + priority: %Schema{type: :integer}, + text: %Schema{type: :string}, + hint: %Schema{type: :string} + } + } + end + + defp update_request do + %Schema{ + type: :object, + properties: %{ + priority: %Schema{type: :integer}, + text: %Schema{type: :string}, + hint: %Schema{type: :string} + } + } + end + + defp rule do + %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string}, + priority: %Schema{type: :integer}, + text: %Schema{type: :string}, + hint: %Schema{type: :string, nullable: true} + } + } + end +end diff --git a/lib/pleroma/web/api_spec/operations/instance_operation.ex b/lib/pleroma/web/api_spec/operations/instance_operation.ex index 708b74b12..7d7a5ecc1 100644 --- a/lib/pleroma/web/api_spec/operations/instance_operation.ex +++ b/lib/pleroma/web/api_spec/operations/instance_operation.ex @@ -46,10 +46,30 @@ defmodule Pleroma.Web.ApiSpec.InstanceOperation do } end + def rules_operation do + %Operation{ + tags: ["Instance misc"], + summary: "Retrieve list of instance rules", + operationId: "InstanceController.rules", + responses: %{ + 200 => Operation.response("Array of domains", "application/json", array_of_rules()) + } + } + end + defp instance do %Schema{ type: :object, properties: %{ + accounts: %Schema{ + type: :object, + properties: %{ + max_featured_tags: %Schema{ + type: :integer, + description: "The maximum number of featured tags allowed for each account." + } + } + }, uri: %Schema{type: :string, description: "The domain name of the instance"}, title: %Schema{type: :string, description: "The title of the website"}, description: %Schema{ @@ -172,7 +192,8 @@ defmodule Pleroma.Web.ApiSpec.InstanceOperation do "urls" => %{ "streaming_api" => "wss://lain.com" }, - "version" => "2.7.2 (compatible; Pleroma 2.0.50-536-g25eec6d7-develop)" + "version" => "2.7.2 (compatible; Pleroma 2.0.50-536-g25eec6d7-develop)", + "rules" => array_of_rules() } } end @@ -272,6 +293,19 @@ defmodule Pleroma.Web.ApiSpec.InstanceOperation do type: :object, description: "Instance configuration", properties: %{ + accounts: %Schema{ + type: :object, + properties: %{ + max_featured_tags: %Schema{ + type: :integer, + description: "The maximum number of featured tags allowed for each account." + }, + max_pinned_statuses: %Schema{ + type: :integer, + description: "The maximum number of pinned statuses for each account." + } + } + }, urls: %Schema{ type: :object, properties: %{ @@ -285,6 +319,11 @@ defmodule Pleroma.Web.ApiSpec.InstanceOperation do type: :object, description: "A map with poll limits for local statuses", properties: %{ + characters_reserved_per_url: %Schema{ + type: :integer, + description: + "Each URL in a status will be assumed to be exactly this many characters." + }, max_characters: %Schema{ type: :integer, description: "Posts character limit (CW/Subject included in the counter)" @@ -344,4 +383,18 @@ defmodule Pleroma.Web.ApiSpec.InstanceOperation do example: ["pleroma.site", "lain.com", "bikeshed.party"] } end + + defp array_of_rules do + %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string}, + text: %Schema{type: :string}, + hint: %Schema{type: :string} + } + } + } + end end diff --git a/lib/pleroma/web/api_spec/operations/pleroma_notification_operation.ex b/lib/pleroma/web/api_spec/operations/pleroma_notification_operation.ex index a994345db..0e2865191 100644 --- a/lib/pleroma/web/api_spec/operations/pleroma_notification_operation.ex +++ b/lib/pleroma/web/api_spec/operations/pleroma_notification_operation.ex @@ -5,7 +5,6 @@ defmodule Pleroma.Web.ApiSpec.PleromaNotificationOperation do alias OpenApiSpex.Operation alias OpenApiSpex.Schema - alias Pleroma.Web.ApiSpec.NotificationOperation alias Pleroma.Web.ApiSpec.Schemas.ApiError import Pleroma.Web.ApiSpec.Helpers @@ -35,12 +34,7 @@ defmodule Pleroma.Web.ApiSpec.PleromaNotificationOperation do Operation.response( "A Notification or array of Notifications", "application/json", - %Schema{ - anyOf: [ - %Schema{type: :array, items: NotificationOperation.notification()}, - NotificationOperation.notification() - ] - } + %Schema{type: :string} ), 400 => Operation.response("Bad Request", "application/json", ApiError) } diff --git a/lib/pleroma/web/api_spec/operations/report_operation.ex b/lib/pleroma/web/api_spec/operations/report_operation.ex index c74ac7d5f..f5f88974c 100644 --- a/lib/pleroma/web/api_spec/operations/report_operation.ex +++ b/lib/pleroma/web/api_spec/operations/report_operation.ex @@ -53,6 +53,12 @@ defmodule Pleroma.Web.ApiSpec.ReportOperation do default: false, description: "If the account is remote, should the report be forwarded to the remote admin?" + }, + rule_ids: %Schema{ + type: :array, + nullable: true, + items: %Schema{type: :string}, + description: "Array of rules" } }, required: [:account_id], @@ -60,7 +66,8 @@ defmodule Pleroma.Web.ApiSpec.ReportOperation do "account_id" => "123", "status_ids" => ["1337"], "comment" => "bad status!", - "forward" => "false" + "forward" => "false", + "rule_ids" => ["3"] } } end diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index a4052803b..6e537b5da 100644 --- a/lib/pleroma/web/api_spec/schemas/status.ex +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -58,6 +58,10 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do format: :uri, description: "Preview thumbnail" }, + image_description: %Schema{ + type: :string, + description: "Alternate text that describes what is in the thumbnail" + }, title: %Schema{type: :string, description: "Title of linked resource"}, description: %Schema{type: :string, description: "Description of preview"} } diff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex index 27e82ecc8..34e480d73 100644 --- a/lib/pleroma/web/common_api.ex +++ b/lib/pleroma/web/common_api.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.CommonAPI do alias Pleroma.Formatter alias Pleroma.ModerationLog alias Pleroma.Object + alias Pleroma.Rule alias Pleroma.ThreadMute alias Pleroma.User alias Pleroma.UserRelationship @@ -568,14 +569,16 @@ defmodule Pleroma.Web.CommonAPI do def report(user, data) do with {:ok, account} <- get_reported_account(data.account_id), {:ok, {content_html, _, _}} <- make_report_content_html(data[:comment]), - {:ok, statuses} <- get_report_statuses(account, data) do + {:ok, statuses} <- get_report_statuses(account, data), + rules <- get_report_rules(Map.get(data, :rule_ids, nil)) do ActivityPub.flag(%{ context: Utils.generate_context_id(), actor: user, account: account, statuses: statuses, content: content_html, - forward: Map.get(data, :forward, false) + forward: Map.get(data, :forward, false), + rules: rules }) end end @@ -587,6 +590,15 @@ defmodule Pleroma.Web.CommonAPI do end end + defp get_report_rules(nil) do + nil + end + + defp get_report_rules(rule_ids) do + rule_ids + |> Enum.filter(&Rule.exists?/1) + end + def update_report_state(activity_ids, state) when is_list(activity_ids) do case Utils.update_report_state(activity_ids, state) do :ok -> {:ok, activity_ids} diff --git a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex index 3e664903a..b97b0e476 100644 --- a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex @@ -25,4 +25,9 @@ defmodule Pleroma.Web.MastodonAPI.InstanceController do def peers(conn, _params) do json(conn, Pleroma.Stats.get_peers()) end + + @doc "GET /api/v1/instance/rules" + def rules(conn, _params) do + render(conn, "rules.json") + end end diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 4f6de8a00..83e1bee54 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -38,7 +38,6 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do when action in [ :index, :show, - :card, :context, :show_history, :show_source @@ -473,21 +472,6 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do end end - @doc "GET /api/v1/statuses/:id/card" - @deprecated "https://github.com/tootsuite/mastodon/pull/11213" - def card( - %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: status_id}}}} = conn, - _ - ) do - with %Activity{} = activity <- Activity.get_by_id(status_id), - true <- Visibility.visible_for_user?(activity, user) do - data = Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) - render(conn, "card.json", data) - else - _ -> render_error(conn, :not_found, "Record not found") - end - end - @doc "GET /api/v1/statuses/:id/favourited_by" def favourited_by( %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 210b46d2c..99fc6d0c3 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -76,12 +76,26 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do }) end + def render("rules.json", _) do + Pleroma.Rule.query() + |> Pleroma.Repo.all() + |> render_many(__MODULE__, "rule.json", as: :rule) + end + + def render("rule.json", %{rule: rule}) do + %{ + id: to_string(rule.id), + text: rule.text, + hint: rule.hint || "" + } + end + defp common_information(instance) do %{ - title: Keyword.get(instance, :name), - version: "#{@mastodon_api_level} (compatible; #{Pleroma.Application.named_version()})", languages: Keyword.get(instance, :languages, ["en"]), - rules: [] + rules: render(__MODULE__, "rules.json"), + title: Keyword.get(instance, :name), + version: "#{@mastodon_api_level} (compatible; #{Pleroma.Application.named_version()})" } end @@ -213,6 +227,8 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do defp configuration2 do configuration() + |> put_in([:accounts, :max_pinned_statuses], Config.get([:instance, :max_pinned_statuses], 0)) + |> put_in([:statuses, :characters_reserved_per_url], 0) |> Map.merge(%{ urls: %{ streaming: Pleroma.Web.Endpoint.websocket_url(), diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index e464f60dc..c945290c1 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -21,6 +21,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MediaProxy alias Pleroma.Web.PleromaAPI.EmojiReactionController + alias Pleroma.Web.RichMedia.Card import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1, visible_for_user?: 2] @@ -29,9 +30,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do # pagination is restricted to 40 activities at a time defp fetch_rich_media_for_activities(activities) do Enum.each(activities, fn activity -> - spawn(fn -> - Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) - end) + spawn(fn -> Card.get_by_activity(activity) end) end) end @@ -113,9 +112,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list activities = Enum.filter(opts.activities, & &1) - # Start fetching rich media before doing anything else, so that later calls to get the cards - # only block for timeout in the worst case, as opposed to - # length(activities_with_links) * timeout + # Start prefetching rich media before doing anything else fetch_rich_media_for_activities(activities) replied_to_activities = get_replied_to_activities(activities) quoted_activities = get_quoted_activities(activities) @@ -364,7 +361,11 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do summary = object.data["summary"] || "" - card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity)) + card = + case Card.get_by_activity(activity) do + %Card{} = result -> render("card.json", result) + _ -> nil + end url = if user.local do @@ -567,15 +568,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do } end - def render("card.json", %{rich_media: rich_media, page_url: page_url}) do - page_url_data = URI.parse(page_url) - - page_url_data = - if is_binary(rich_media["url"]) do - URI.merge(page_url_data, URI.parse(rich_media["url"])) - else - page_url_data - end + def render("card.json", %Card{fields: rich_media}) do + page_url_data = URI.parse(rich_media["url"]) page_url = page_url_data |> to_string @@ -589,6 +583,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do provider_url: page_url_data.scheme <> "://" <> page_url_data.host, url: page_url, image: image_url, + image_description: rich_media["image:alt"] || "", title: rich_media["title"] || "", description: rich_media["description"] || "", pleroma: %{ diff --git a/lib/pleroma/web/pleroma_api/controllers/notification_controller.ex b/lib/pleroma/web/pleroma_api/controllers/notification_controller.ex index f860eaf7e..435ccfabe 100644 --- a/lib/pleroma/web/pleroma_api/controllers/notification_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/notification_controller.ex @@ -23,8 +23,9 @@ defmodule Pleroma.Web.PleromaAPI.NotificationController do } = conn, _ ) do - with {:ok, notification} <- Notification.read_one(user, notification_id) do - render(conn, "show.json", notification: notification, for: user) + with {:ok, _} <- Notification.read_one(user, notification_id) do + conn + |> json("ok") else {:error, message} -> conn @@ -38,11 +39,14 @@ defmodule Pleroma.Web.PleromaAPI.NotificationController do conn, _ ) do - notifications = - user - |> Notification.set_read_up_to(max_id) - |> Enum.take(80) - - render(conn, "index.json", notifications: notifications, for: user) + with {:ok, _} <- Notification.set_read_up_to(user, max_id) do + conn + |> json("ok") + else + {:error, message} -> + conn + |> put_status(:bad_request) + |> json(%{"error" => message}) + end end end diff --git a/lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex b/lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex index 241bf0010..a1c88d075 100644 --- a/lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex +++ b/lib/pleroma/web/pleroma_api/views/chat/message_reference_view.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.PleromaAPI.Chat.MessageReferenceView do alias Pleroma.User alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.MastodonAPI.StatusView + alias Pleroma.Web.RichMedia.Card @cachex Pleroma.Config.get([:cachex, :provider], Cachex) @@ -23,6 +24,12 @@ defmodule Pleroma.Web.PleromaAPI.Chat.MessageReferenceView do } } ) do + card = + case Card.get_by_object(object) do + %Card{} = card_data -> StatusView.render("card.json", card_data) + _ -> nil + end + %{ id: id |> to_string(), content: chat_message["content"], @@ -34,11 +41,7 @@ defmodule Pleroma.Web.PleromaAPI.Chat.MessageReferenceView do chat_message["attachment"] && StatusView.render("attachment.json", attachment: chat_message["attachment"]), unread: unread, - card: - StatusView.render( - "card.json", - Pleroma.Web.RichMedia.Helpers.fetch_data_for_object(object) - ) + card: card } |> put_idempotency_key() end diff --git a/lib/pleroma/web/rich_media/backfill.ex b/lib/pleroma/web/rich_media/backfill.ex new file mode 100644 index 000000000..4ec50e132 --- /dev/null +++ b/lib/pleroma/web/rich_media/backfill.ex @@ -0,0 +1,101 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.RichMedia.Backfill do + alias Pleroma.Web.RichMedia.Card + alias Pleroma.Web.RichMedia.Parser + alias Pleroma.Web.RichMedia.Parser.TTL + alias Pleroma.Workers.RichMediaExpirationWorker + + require Logger + + @backfiller Pleroma.Config.get([__MODULE__, :provider], Pleroma.Web.RichMedia.Backfill.Task) + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + @max_attempts 3 + @retry 5_000 + + def start(%{url: url} = args) when is_binary(url) do + url_hash = Card.url_to_hash(url) + + args = + args + |> Map.put(:attempt, 1) + |> Map.put(:url_hash, url_hash) + + @backfiller.run(args) + end + + def run(%{url: url, url_hash: url_hash, attempt: attempt} = args) + when attempt <= @max_attempts do + case Parser.parse(url) do + {:ok, fields} -> + {:ok, card} = Card.create(url, fields) + + maybe_schedule_expiration(url, fields) + + if Map.has_key?(args, :activity_id) do + stream_update(args) + end + + warm_cache(url_hash, card) + + {:error, {:invalid_metadata, fields}} -> + Logger.debug("Rich media incomplete or invalid metadata for #{url}: #{inspect(fields)}") + negative_cache(url_hash) + + {:error, :body_too_large} -> + Logger.error("Rich media error for #{url}: :body_too_large") + negative_cache(url_hash) + + {:error, {:content_type, type}} -> + Logger.debug("Rich media error for #{url}: :content_type is #{type}") + negative_cache(url_hash) + + e -> + Logger.debug("Rich media error for #{url}: #{inspect(e)}") + + :timer.sleep(@retry * attempt) + + run(%{args | attempt: attempt + 1}) + end + end + + def run(%{url: url, url_hash: url_hash}) do + Logger.debug("Rich media failure for #{url}") + + negative_cache(url_hash, :timer.minutes(15)) + end + + defp maybe_schedule_expiration(url, fields) do + case TTL.process(fields, url) do + {:ok, ttl} when is_number(ttl) -> + timestamp = DateTime.from_unix!(ttl) + + RichMediaExpirationWorker.new(%{"url" => url}, scheduled_at: timestamp) + |> Oban.insert() + + _ -> + :ok + end + end + + defp stream_update(%{activity_id: activity_id}) do + Pleroma.Activity.get_by_id(activity_id) + |> Pleroma.Activity.normalize() + |> Pleroma.Web.ActivityPub.ActivityPub.stream_out() + end + + defp warm_cache(key, val), do: @cachex.put(:rich_media_cache, key, val) + defp negative_cache(key, ttl \\ nil), do: @cachex.put(:rich_media_cache, key, nil, ttl: ttl) +end + +defmodule Pleroma.Web.RichMedia.Backfill.Task do + alias Pleroma.Web.RichMedia.Backfill + + def run(args) do + Task.Supervisor.start_child(Pleroma.TaskSupervisor, Backfill, :run, [args], + name: {:global, {:rich_media, args.url_hash}} + ) + end +end diff --git a/lib/pleroma/web/rich_media/card.ex b/lib/pleroma/web/rich_media/card.ex new file mode 100644 index 000000000..36a1ae44a --- /dev/null +++ b/lib/pleroma/web/rich_media/card.ex @@ -0,0 +1,157 @@ +defmodule Pleroma.Web.RichMedia.Card do + use Ecto.Schema + import Ecto.Changeset + import Ecto.Query + + alias Pleroma.Activity + alias Pleroma.HTML + alias Pleroma.Object + alias Pleroma.Repo + alias Pleroma.Web.RichMedia.Backfill + alias Pleroma.Web.RichMedia.Parser + + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + @config_impl Application.compile_env(:pleroma, [__MODULE__, :config_impl], Pleroma.Config) + + @type t :: %__MODULE__{} + + schema "rich_media_card" do + field(:url_hash, :binary) + field(:fields, :map) + + timestamps() + end + + @doc false + def changeset(card, attrs) do + card + |> cast(attrs, [:url_hash, :fields]) + |> validate_required([:url_hash, :fields]) + |> unique_constraint(:url_hash) + end + + @spec create(String.t(), map()) :: {:ok, t()} + def create(url, fields) do + url_hash = url_to_hash(url) + + fields = Map.put_new(fields, "url", url) + + %__MODULE__{} + |> changeset(%{url_hash: url_hash, fields: fields}) + |> Repo.insert(on_conflict: {:replace, [:fields]}, conflict_target: :url_hash) + end + + @spec delete(String.t()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} | :ok + def delete(url) do + url_hash = url_to_hash(url) + @cachex.del(:rich_media_cache, url_hash) + + case get_by_url(url) do + %__MODULE__{} = card -> Repo.delete(card) + nil -> :ok + end + end + + @spec get_by_url(String.t() | nil) :: t() | nil | :error + def get_by_url(url) when is_binary(url) do + if @config_impl.get([:rich_media, :enabled]) do + url_hash = url_to_hash(url) + + @cachex.fetch!(:rich_media_cache, url_hash, fn _ -> + result = + __MODULE__ + |> where(url_hash: ^url_hash) + |> Repo.one() + + case result do + %__MODULE__{} = card -> {:commit, card} + _ -> {:ignore, nil} + end + end) + else + :error + end + end + + def get_by_url(nil), do: nil + + @spec get_or_backfill_by_url(String.t(), map()) :: t() | nil + def get_or_backfill_by_url(url, backfill_opts \\ %{}) do + case get_by_url(url) do + %__MODULE__{} = card -> + card + + nil -> + backfill_opts = Map.put(backfill_opts, :url, url) + + Backfill.start(backfill_opts) + + nil + + :error -> + nil + end + end + + @spec get_by_object(Object.t()) :: t() | nil | :error + def get_by_object(object) do + case HTML.extract_first_external_url_from_object(object) do + nil -> nil + url -> get_or_backfill_by_url(url) + end + end + + @spec get_by_activity(Activity.t()) :: t() | nil | :error + # Fake/Draft activity + def get_by_activity(%Activity{id: "pleroma:fakeid"} = activity) do + with %Object{} = object <- Object.normalize(activity, fetch: false), + url when not is_nil(url) <- HTML.extract_first_external_url_from_object(object) do + case get_by_url(url) do + # Cache hit + %__MODULE__{} = card -> + card + + # Cache miss, but fetch for rendering the Draft + _ -> + with {:ok, fields} <- Parser.parse(url), + {:ok, card} <- create(url, fields) do + card + else + _ -> nil + end + end + else + _ -> + nil + end + end + + def get_by_activity(activity) do + with %Object{} = object <- Object.normalize(activity, fetch: false), + {_, nil} <- {:cached, get_cached_url(object, activity.id)} do + nil + else + {:cached, url} -> + get_or_backfill_by_url(url, %{activity_id: activity.id}) + + _ -> + :error + end + end + + @spec url_to_hash(String.t()) :: String.t() + def url_to_hash(url) do + :crypto.hash(:sha256, url) |> Base.encode16(case: :lower) + end + + defp get_cached_url(object, activity_id) do + key = "URL|#{activity_id}" + + @cachex.fetch!(:scrubber_cache, key, fn _ -> + url = HTML.extract_first_external_url_from_object(object) + Activity.HTML.add_cache_key_for(activity_id, key) + + {:commit, url} + end) + end +end diff --git a/lib/pleroma/web/rich_media/helpers.ex b/lib/pleroma/web/rich_media/helpers.ex index a711dc436..119994458 100644 --- a/lib/pleroma/web/rich_media/helpers.ex +++ b/lib/pleroma/web/rich_media/helpers.ex @@ -3,65 +3,13 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.RichMedia.Helpers do - alias Pleroma.Activity - alias Pleroma.HTML - alias Pleroma.Object - alias Pleroma.Web.RichMedia.Parser - - @cachex Pleroma.Config.get([:cachex, :provider], Cachex) - - @config_impl Application.compile_env(:pleroma, [__MODULE__, :config_impl], Pleroma.Config) - - @options [ - pool: :media, - max_body: 2_000_000, - recv_timeout: 2_000 - ] - - def fetch_data_for_object(object) do - with true <- @config_impl.get([:rich_media, :enabled]), - {:ok, page_url} <- - HTML.extract_first_external_url_from_object(object), - {:ok, rich_media} <- Parser.parse(page_url) do - %{page_url: page_url, rich_media: rich_media} - else - _ -> %{} - end - end - - def fetch_data_for_activity(%Activity{data: %{"type" => "Create"}} = activity) do - with true <- @config_impl.get([:rich_media, :enabled]), - %Object{} = object <- Object.normalize(activity, fetch: false) do - if object.data["fake"] do - fetch_data_for_object(object) - else - key = "URL|#{activity.id}" - - @cachex.fetch!(:scrubber_cache, key, fn _ -> - result = fetch_data_for_object(object) - - cond do - match?(%{page_url: _, rich_media: _}, result) -> - Activity.HTML.add_cache_key_for(activity.id, key) - {:commit, result} - - true -> - {:ignore, %{}} - end - end) - end - else - _ -> %{} - end - end - - def fetch_data_for_activity(_), do: %{} + alias Pleroma.Config def rich_media_get(url) do headers = [{"user-agent", Pleroma.Application.user_agent() <> "; Bot"}] head_check = - case Pleroma.HTTP.head(url, headers, @options) do + case Pleroma.HTTP.head(url, headers, http_options()) do # If the HEAD request didn't reach the server for whatever reason, # we assume the GET that comes right after won't either {:error, _} = e -> @@ -76,7 +24,7 @@ defmodule Pleroma.Web.RichMedia.Helpers do :ok end - with :ok <- head_check, do: Pleroma.HTTP.get(url, headers, @options) + with :ok <- head_check, do: Pleroma.HTTP.get(url, headers, http_options()) end defp check_content_type(headers) do @@ -92,12 +40,13 @@ defmodule Pleroma.Web.RichMedia.Helpers do end end - @max_body @options[:max_body] defp check_content_length(headers) do + max_body = Keyword.get(http_options(), :max_body) + case List.keyfind(headers, "content-length", 0) do {_, maybe_content_length} -> case Integer.parse(maybe_content_length) do - {content_length, ""} when content_length <= @max_body -> :ok + {content_length, ""} when content_length <= max_body -> :ok {_, ""} -> {:error, :body_too_large} _ -> :ok end @@ -106,4 +55,11 @@ defmodule Pleroma.Web.RichMedia.Helpers do :ok end end + + defp http_options do + [ + pool: :media, + max_body: Config.get([:rich_media, :max_body], 5_000_000) + ] + end end diff --git a/lib/pleroma/web/rich_media/parser.ex b/lib/pleroma/web/rich_media/parser.ex index a73fbc4b9..37cf29029 100644 --- a/lib/pleroma/web/rich_media/parser.ex +++ b/lib/pleroma/web/rich_media/parser.ex @@ -5,134 +5,28 @@ defmodule Pleroma.Web.RichMedia.Parser do require Logger - @cachex Pleroma.Config.get([:cachex, :provider], Cachex) @config_impl Application.compile_env(:pleroma, [__MODULE__, :config_impl], Pleroma.Config) defp parsers do Pleroma.Config.get([:rich_media, :parsers]) end - def parse(nil), do: {:error, "No URL provided"} + def parse(nil), do: nil @spec parse(String.t()) :: {:ok, map()} | {:error, any()} def parse(url) do with :ok <- validate_page_url(url), - {:ok, data} <- get_cached_or_parse(url), - {:ok, _} <- set_ttl_based_on_image(data, url) do + {:ok, data} <- parse_url(url) do + data = Map.put(data, "url", url) {:ok, data} end end - defp get_cached_or_parse(url) do - case @cachex.fetch(:rich_media_cache, url, fn -> - case parse_url(url) do - {:ok, _} = res -> - {:commit, res} - - {:error, reason} = e -> - # Unfortunately we have to log errors here, instead of doing that - # along with ttl setting at the bottom. Otherwise we can get log spam - # if more than one process was waiting for the rich media card - # while it was generated. Ideally we would set ttl here as well, - # so we don't override it number_of_waiters_on_generation - # times, but one, obviously, can't set ttl for not-yet-created entry - # and Cachex doesn't support returning ttl from the fetch callback. - log_error(url, reason) - {:commit, e} - end - end) do - {action, res} when action in [:commit, :ok] -> - case res do - {:ok, _data} = res -> - res - - {:error, reason} = e -> - if action == :commit, do: set_error_ttl(url, reason) - e - end - - {:error, e} -> - {:error, {:cachex_error, e}} - end - end - - defp set_error_ttl(_url, :body_too_large), do: :ok - defp set_error_ttl(_url, {:content_type, _}), do: :ok - - # The TTL is not set for the errors above, since they are unlikely to change - # with time - - defp set_error_ttl(url, _reason) do - ttl = Pleroma.Config.get([:rich_media, :failure_backoff], 60_000) - @cachex.expire(:rich_media_cache, url, ttl) - :ok - end - - defp log_error(url, {:invalid_metadata, data}) do - Logger.debug(fn -> "Incomplete or invalid metadata for #{url}: #{inspect(data)}" end) - end - - defp log_error(url, reason) do - Logger.warning(fn -> "Rich media error for #{url}: #{inspect(reason)}" end) - end - - @doc """ - Set the rich media cache based on the expiration time of image. - - Adopt behaviour `Pleroma.Web.RichMedia.Parser.TTL` - - ## Example - - defmodule MyModule do - @behaviour Pleroma.Web.RichMedia.Parser.TTL - def ttl(data, url) do - image_url = Map.get(data, :image) - # do some parsing in the url and get the ttl of the image - # and return ttl is unix time - parse_ttl_from_url(image_url) - end - end - - Define the module in the config - - config :pleroma, :rich_media, - ttl_setters: [MyModule] - """ - @spec set_ttl_based_on_image(map(), String.t()) :: - {:ok, integer() | :noop} | {:error, :no_key} - def set_ttl_based_on_image(data, url) do - case get_ttl_from_image(data, url) do - ttl when is_number(ttl) -> - ttl = ttl * 1000 - - case @cachex.expire_at(:rich_media_cache, url, ttl) do - {:ok, true} -> {:ok, ttl} - {:ok, false} -> {:error, :no_key} - end - - _ -> - {:ok, :noop} - end - end - - defp get_ttl_from_image(data, url) do - [:rich_media, :ttl_setters] - |> Pleroma.Config.get() - |> Enum.reduce({:ok, nil}, fn - module, {:ok, _ttl} -> - module.ttl(data, url) - - _, error -> - error - end) - end - - def parse_url(url) do + defp parse_url(url) do with {:ok, %Tesla.Env{body: html}} <- Pleroma.Web.RichMedia.Helpers.rich_media_get(url), {:ok, html} <- Floki.parse_document(html) do html |> maybe_parse() - |> Map.put("url", url) |> clean_parsed_data() |> check_parsed_data() end diff --git a/lib/pleroma/web/rich_media/parser/ttl.ex b/lib/pleroma/web/rich_media/parser/ttl.ex index b51298bd8..7e56375ff 100644 --- a/lib/pleroma/web/rich_media/parser/ttl.ex +++ b/lib/pleroma/web/rich_media/parser/ttl.ex @@ -4,4 +4,17 @@ defmodule Pleroma.Web.RichMedia.Parser.TTL do @callback ttl(map(), String.t()) :: integer() | nil + + @spec process(map(), String.t()) :: {:ok, integer() | nil} + def process(data, url) do + [:rich_media, :ttl_setters] + |> Pleroma.Config.get() + |> Enum.reduce_while({:ok, nil}, fn + module, acc -> + case module.ttl(data, url) do + ttl when is_number(ttl) -> {:halt, {:ok, ttl}} + _ -> {:cont, acc} + end + end) + end end diff --git a/lib/pleroma/web/rich_media/parser/ttl/aws_signed_url.ex b/lib/pleroma/web/rich_media/parser/ttl/aws_signed_url.ex index a0d567c42..948c727e1 100644 --- a/lib/pleroma/web/rich_media/parser/ttl/aws_signed_url.ex +++ b/lib/pleroma/web/rich_media/parser/ttl/aws_signed_url.ex @@ -7,7 +7,7 @@ defmodule Pleroma.Web.RichMedia.Parser.TTL.AwsSignedUrl do @impl true def ttl(data, _url) do - image = Map.get(data, :image) + image = Map.get(data, "image") if aws_signed_url?(image) do image @@ -15,14 +15,15 @@ defmodule Pleroma.Web.RichMedia.Parser.TTL.AwsSignedUrl do |> format_query_params() |> get_expiration_timestamp() else - {:error, "Not aws signed url #{inspect(image)}"} + nil end end defp aws_signed_url?(image) when is_binary(image) and image != "" do %URI{host: host, query: query} = URI.parse(image) - String.contains?(host, "amazonaws.com") and String.contains?(query, "X-Amz-Expires") + is_binary(host) and String.contains?(host, "amazonaws.com") and + String.contains?(query, "X-Amz-Expires") end defp aws_signed_url?(_), do: nil diff --git a/lib/pleroma/web/rich_media/parser/ttl/opengraph.ex b/lib/pleroma/web/rich_media/parser/ttl/opengraph.ex new file mode 100644 index 000000000..b06889669 --- /dev/null +++ b/lib/pleroma/web/rich_media/parser/ttl/opengraph.ex @@ -0,0 +1,20 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.RichMedia.Parser.TTL.Opengraph do + @behaviour Pleroma.Web.RichMedia.Parser.TTL + + @impl true + def ttl(%{"ttl" => ttl_string}, _url) when is_binary(ttl_string) do + try do + ttl = String.to_integer(ttl_string) + now = DateTime.utc_now() |> DateTime.to_unix() + now + ttl + rescue + _ -> nil + end + end + + def ttl(_, _), do: nil +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 644f6cc81..368a04df0 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -292,6 +292,11 @@ defmodule Pleroma.Web.Router do post("/frontends/install", FrontendController, :install) post("/backups", AdminAPIController, :create_backup) + + get("/rules", RuleController, :index) + post("/rules", RuleController, :create) + patch("/rules/:id", RuleController, :update) + delete("/rules/:id", RuleController, :delete) end # AdminAPI: admins and mods (staff) can perform these actions (if privileged by role) @@ -765,11 +770,11 @@ defmodule Pleroma.Web.Router do get("/instance", InstanceController, :show) get("/instance/peers", InstanceController, :peers) + get("/instance/rules", InstanceController, :rules) get("/statuses", StatusController, :index) get("/statuses/:id", StatusController, :show) get("/statuses/:id/context", StatusController, :context) - get("/statuses/:id/card", StatusController, :card) get("/statuses/:id/favourited_by", StatusController, :favourited_by) get("/statuses/:id/reblogged_by", StatusController, :reblogged_by) get("/statuses/:id/history", StatusController, :show_history) diff --git a/lib/pleroma/workers/receiver_worker.ex b/lib/pleroma/workers/receiver_worker.ex index 1dddd8d2e..8b2052c23 100644 --- a/lib/pleroma/workers/receiver_worker.ex +++ b/lib/pleroma/workers/receiver_worker.ex @@ -52,7 +52,8 @@ defmodule Pleroma.Workers.ReceiverWorker do {:error, {:reject, reason}} -> {:cancel, reason} {:signature, false} -> {:cancel, :invalid_signature} {:error, {:error, reason = "Object has been deleted"}} -> {:cancel, reason} - e -> e + {:error, _} = e -> e + e -> {:error, e} end end end diff --git a/lib/pleroma/workers/rich_media_expiration_worker.ex b/lib/pleroma/workers/rich_media_expiration_worker.ex new file mode 100644 index 000000000..d7ae497a7 --- /dev/null +++ b/lib/pleroma/workers/rich_media_expiration_worker.ex @@ -0,0 +1,15 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.RichMediaExpirationWorker do + alias Pleroma.Web.RichMedia.Card + + use Oban.Worker, + queue: :rich_media_expiration + + @impl Oban.Worker + def perform(%Job{args: %{"url" => url} = _args}) do + Card.delete(url) + end +end |