From e9e17e5df34051bce60232890ea042582af31f8c Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Tue, 13 Oct 2020 00:27:51 -0500 Subject: [PATCH 001/339] Upgrade Earmark to v1.4.10 --- lib/pleroma/earmark_renderer.ex | 256 ------------------ lib/pleroma/formatter.ex | 8 + .../audio_video_validator.ex | 3 +- lib/pleroma/web/common_api/utils.ex | 3 +- mix.exs | 2 +- mix.lock | 2 +- test/pleroma/formatter_test.exs | 7 + test/pleroma/web/common_api/utils_test.exs | 75 +++++ 8 files changed, 95 insertions(+), 261 deletions(-) delete mode 100644 lib/pleroma/earmark_renderer.ex diff --git a/lib/pleroma/earmark_renderer.ex b/lib/pleroma/earmark_renderer.ex deleted file mode 100644 index 6211a3b4a..000000000 --- a/lib/pleroma/earmark_renderer.ex +++ /dev/null @@ -1,256 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2020 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only -# -# This file is derived from Earmark, under the following copyright: -# Copyright © 2014 Dave Thomas, The Pragmatic Programmers -# SPDX-License-Identifier: Apache-2.0 -# Upstream: https://github.com/pragdave/earmark/blob/master/lib/earmark/html_renderer.ex -defmodule Pleroma.EarmarkRenderer do - @moduledoc false - - alias Earmark.Block - alias Earmark.Context - alias Earmark.HtmlRenderer - alias Earmark.Options - - import Earmark.Inline, only: [convert: 3] - import Earmark.Helpers.HtmlHelpers - import Earmark.Message, only: [add_messages_from: 2, get_messages: 1, set_messages: 2] - import Earmark.Context, only: [append: 2, set_value: 2] - import Earmark.Options, only: [get_mapper: 1] - - @doc false - def render(blocks, %Context{options: %Options{}} = context) do - messages = get_messages(context) - - {contexts, html} = - get_mapper(context.options).( - blocks, - &render_block(&1, put_in(context.options.messages, [])) - ) - |> Enum.unzip() - - all_messages = - contexts - |> Enum.reduce(messages, fn ctx, messages1 -> messages1 ++ get_messages(ctx) end) - - {put_in(context.options.messages, all_messages), html |> IO.iodata_to_binary()} - end - - ############# - # Paragraph # - ############# - defp render_block(%Block.Para{lnb: lnb, lines: lines, attrs: attrs}, context) do - lines = convert(lines, lnb, context) - add_attrs(lines, "<p>#{lines.value}</p>", attrs, [], lnb) - end - - ######## - # Html # - ######## - defp render_block(%Block.Html{html: html}, context) do - {context, html} - end - - defp render_block(%Block.HtmlComment{lines: lines}, context) do - {context, lines} - end - - defp render_block(%Block.HtmlOneline{html: html}, context) do - {context, html} - end - - ######### - # Ruler # - ######### - defp render_block(%Block.Ruler{lnb: lnb, attrs: attrs}, context) do - add_attrs(context, "<hr />", attrs, [], lnb) - end - - ########### - # Heading # - ########### - defp render_block( - %Block.Heading{lnb: lnb, level: level, content: content, attrs: attrs}, - context - ) do - converted = convert(content, lnb, context) - html = "<h#{level}>#{converted.value}</h#{level}>" - add_attrs(converted, html, attrs, [], lnb) - end - - ############## - # Blockquote # - ############## - - defp render_block(%Block.BlockQuote{lnb: lnb, blocks: blocks, attrs: attrs}, context) do - {context1, body} = render(blocks, context) - html = "<blockquote>#{body}</blockquote>" - add_attrs(context1, html, attrs, [], lnb) - end - - ######### - # Table # - ######### - - defp render_block( - %Block.Table{lnb: lnb, header: header, rows: rows, alignments: aligns, attrs: attrs}, - context - ) do - {context1, html} = add_attrs(context, "<table>", attrs, [], lnb) - context2 = set_value(context1, html) - - context3 = - if header do - append(add_trs(append(context2, "<thead>"), [header], "th", aligns, lnb), "</thead>") - else - # Maybe an error, needed append(context, html) - context2 - end - - context4 = append(add_trs(append(context3, "<tbody>"), rows, "td", aligns, lnb), "</tbody>") - - {context4, [context4.value, "</table>"]} - end - - ######## - # Code # - ######## - - defp render_block( - %Block.Code{lnb: lnb, language: language, attrs: attrs} = block, - %Context{options: options} = context - ) do - class = - if language, do: ~s{ class="#{code_classes(language, options.code_class_prefix)}"}, else: "" - - tag = ~s[<pre><code#{class}>] - lines = options.render_code.(block) - html = ~s[#{tag}#{lines}</code></pre>] - add_attrs(context, html, attrs, [], lnb) - end - - ######### - # Lists # - ######### - - defp render_block( - %Block.List{lnb: lnb, type: type, blocks: items, attrs: attrs, start: start}, - context - ) do - {context1, content} = render(items, context) - html = "<#{type}#{start}>#{content}</#{type}>" - add_attrs(context1, html, attrs, [], lnb) - end - - # format a single paragraph list item, and remove the para tags - defp render_block( - %Block.ListItem{lnb: lnb, blocks: blocks, spaced: false, attrs: attrs}, - context - ) - when length(blocks) == 1 do - {context1, content} = render(blocks, context) - content = Regex.replace(~r{</?p>}, content, "") - html = "<li>#{content}</li>" - add_attrs(context1, html, attrs, [], lnb) - end - - # format a spaced list item - defp render_block(%Block.ListItem{lnb: lnb, blocks: blocks, attrs: attrs}, context) do - {context1, content} = render(blocks, context) - html = "<li>#{content}</li>" - add_attrs(context1, html, attrs, [], lnb) - end - - ################## - # Footnote Block # - ################## - - defp render_block(%Block.FnList{blocks: footnotes}, context) do - items = - Enum.map(footnotes, fn note -> - blocks = append_footnote_link(note) - %Block.ListItem{attrs: "#fn:#{note.number}", type: :ol, blocks: blocks} - end) - - {context1, html} = render_block(%Block.List{type: :ol, blocks: items}, context) - {context1, Enum.join([~s[<div class="footnotes">], "<hr />", html, "</div>"])} - end - - ####################################### - # Isolated IALs are rendered as paras # - ####################################### - - defp render_block(%Block.Ial{verbatim: verbatim}, context) do - {context, "<p>{:#{verbatim}}</p>"} - end - - #################### - # IDDef is ignored # - #################### - - defp render_block(%Block.IdDef{}, context), do: {context, ""} - - ##################################### - # And here are the inline renderers # - ##################################### - - defdelegate br, to: HtmlRenderer - defdelegate codespan(text), to: HtmlRenderer - defdelegate em(text), to: HtmlRenderer - defdelegate strong(text), to: HtmlRenderer - defdelegate strikethrough(text), to: HtmlRenderer - - defdelegate link(url, text), to: HtmlRenderer - defdelegate link(url, text, title), to: HtmlRenderer - - defdelegate image(path, alt, title), to: HtmlRenderer - - defdelegate footnote_link(ref, backref, number), to: HtmlRenderer - - # Table rows - defp add_trs(context, rows, tag, aligns, lnb) do - numbered_rows = - rows - |> Enum.zip(Stream.iterate(lnb, &(&1 + 1))) - - numbered_rows - |> Enum.reduce(context, fn {row, lnb}, ctx -> - append(add_tds(append(ctx, "<tr>"), row, tag, aligns, lnb), "</tr>") - end) - end - - defp add_tds(context, row, tag, aligns, lnb) do - Enum.reduce(1..length(row), context, add_td_fn(row, tag, aligns, lnb)) - end - - defp add_td_fn(row, tag, aligns, lnb) do - fn n, ctx -> - style = - case Enum.at(aligns, n - 1, :default) do - :default -> "" - align -> " style=\"text-align: #{align}\"" - end - - col = Enum.at(row, n - 1) - converted = convert(col, lnb, set_messages(ctx, [])) - append(add_messages_from(ctx, converted), "<#{tag}#{style}>#{converted.value}</#{tag}>") - end - end - - ############################### - # Append Footnote Return Link # - ############################### - - defdelegate append_footnote_link(note), to: HtmlRenderer - defdelegate append_footnote_link(note, fnlink), to: HtmlRenderer - - defdelegate render_code(lines), to: HtmlRenderer - - defp code_classes(language, prefix) do - ["" | String.split(prefix || "")] - |> Enum.map(fn pfx -> "#{pfx}#{language}" end) - |> Enum.join(" ") - end -end diff --git a/lib/pleroma/formatter.ex b/lib/pleroma/formatter.ex index 0c450eae4..b0e4a84ae 100644 --- a/lib/pleroma/formatter.ex +++ b/lib/pleroma/formatter.ex @@ -138,6 +138,14 @@ def html_escape(text, "text/plain") do |> Enum.join("") end + def minify({text, mentions, hashtags}, type) do + {minify(text, type), mentions, hashtags} + end + + def minify(text, "text/html") do + String.replace(text, "\n", "") + end + def truncate(text, max_length \\ 200, omission \\ "...") do # Remove trailing whitespace text = Regex.replace(~r/([^ \t\r\n])([ \t]+$)/u, text, "\\g{1}") diff --git a/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex b/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex index 16973e5db..eaf94797a 100644 --- a/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex @@ -5,7 +5,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AudioVideoValidator do use Ecto.Schema - alias Pleroma.EarmarkRenderer alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes @@ -96,7 +95,7 @@ defp fix_content(%{"mediaType" => "text/markdown", "content" => content} = data) when is_binary(content) do content = content - |> Earmark.as_html!(%Earmark.Options{renderer: EarmarkRenderer}) + |> Earmark.as_html!() |> Pleroma.HTML.filter_tags() Map.put(data, "content", content) diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 1c74ea787..b434a069e 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -294,8 +294,9 @@ def format_input(text, "text/html", options) do def format_input(text, "text/markdown", options) do text |> Formatter.mentions_escape(options) - |> Earmark.as_html!(%Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + |> Earmark.as_html!() |> Formatter.linkify(options) + |> Formatter.minify("text/html") |> Formatter.html_escape("text/html") end diff --git a/mix.exs b/mix.exs index 72a6346b5..feb7eefa3 100644 --- a/mix.exs +++ b/mix.exs @@ -144,7 +144,7 @@ defp deps do {:ex_aws, "~> 2.1.6"}, {:ex_aws_s3, "~> 2.0"}, {:sweet_xml, "~> 0.6.6"}, - {:earmark, "1.4.3"}, + {:earmark, "1.4.10"}, {:bbcode_pleroma, "~> 0.2.0"}, {:crypt, git: "https://github.com/msantos/crypt.git", diff --git a/mix.lock b/mix.lock index 6b551a012..29439a438 100644 --- a/mix.lock +++ b/mix.lock @@ -27,7 +27,7 @@ "db_connection": {:hex, :db_connection, "2.2.2", "3bbca41b199e1598245b716248964926303b5d4609ff065125ce98bcd368939e", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "642af240d8a8affb93b4ba5a6fcd2bbcbdc327e1a524b825d383711536f8070c"}, "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, - "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, + "earmark": {:hex, :earmark, "1.4.10", "bddce5e8ea37712a5bfb01541be8ba57d3b171d3fa4f80a0be9bcf1db417bcaf", [:mix], [{:earmark_parser, ">= 1.4.10", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "12dbfa80810478e521d3ffb941ad9fbfcbbd7debe94e1341b4c4a1b2411c1c27"}, "earmark_parser": {:hex, :earmark_parser, "1.4.10", "6603d7a603b9c18d3d20db69921527f82ef09990885ed7525003c7fe7dc86c56", [:mix], [], "hexpm", "8e2d5370b732385db2c9b22215c3f59c84ac7dda7ed7e544d7c459496ae519c0"}, "ecto": {:hex, :ecto, "3.4.6", "08f7afad3257d6eb8613309af31037e16c36808dfda5a3cd0cb4e9738db030e4", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6f13a9e2a62e75c2dcfc7207bfc65645ab387af8360db4c89fee8b5a4bf3f70b"}, "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, diff --git a/test/pleroma/formatter_test.exs b/test/pleroma/formatter_test.exs index 5781a3f01..ceedd1b6d 100644 --- a/test/pleroma/formatter_test.exs +++ b/test/pleroma/formatter_test.exs @@ -307,4 +307,11 @@ test "it escapes HTML in plain text" do assert Formatter.html_escape(text, "text/plain") == expected end + + test "it minifies html" do + text = "<p>\nhello</p>\n<p>\nworld</p>\n" + expected = "<p>hello</p><p>world</p>" + + assert Formatter.minify(text, "text/html") == expected + end end diff --git a/test/pleroma/web/common_api/utils_test.exs b/test/pleroma/web/common_api/utils_test.exs index 4d6c9ea26..39ea08ca8 100644 --- a/test/pleroma/web/common_api/utils_test.exs +++ b/test/pleroma/web/common_api/utils_test.exs @@ -168,6 +168,81 @@ test "works for text/markdown with mentions" do end end + describe "format_input/3 with markdown" do + test "Paragraph" do + code = ~s[Hello\n\nWorld!] + {result, [], []} = Utils.format_input(code, "text/markdown") + assert result == "<p>Hello</p><p>World!</p>" + end + + test "raw HTML" do + code = ~s[<a href="http://example.org/">OwO</a><!-- what's this?-->] + {result, [], []} = Utils.format_input(code, "text/markdown") + assert result == "<p>#{code}</p>" + end + + test "rulers" do + code = ~s[before\n\n-----\n\nafter] + {result, [], []} = Utils.format_input(code, "text/markdown") + assert result == "<p>before</p><hr /><p>after</p>" + end + + test "headings" do + code = ~s[# h1\n## h2\n### h3\n] + {result, [], []} = Utils.format_input(code, "text/markdown") + assert result == ~s[<h1>h1</h1><h2>h2</h2><h3>h3</h3>] + end + + test "blockquote" do + code = ~s[> whoms't are you quoting?] + {result, [], []} = Utils.format_input(code, "text/markdown") + assert result == "<blockquote><p>whoms’t are you quoting?</p></blockquote>" + end + + test "code" do + code = ~s[`mix`] + {result, [], []} = Utils.format_input(code, "text/markdown") + assert result == ~s[<p><code class="inline">mix</code></p>] + + code = ~s[``mix``] + {result, [], []} = Utils.format_input(code, "text/markdown") + assert result == ~s[<p><code class="inline">mix</code></p>] + + code = ~s[```\nputs "Hello World"\n```] + {result, [], []} = Utils.format_input(code, "text/markdown") + assert result == ~s[<pre><code class="">puts "Hello World"</code></pre>] + end + + test "lists" do + code = ~s[- one\n- two\n- three\n- four] + {result, [], []} = Utils.format_input(code, "text/markdown") + assert result == "<ul><li>one</li><li>two</li><li>three</li><li>four</li></ul>" + + code = ~s[1. one\n2. two\n3. three\n4. four\n] + {result, [], []} = Utils.format_input(code, "text/markdown") + assert result == "<ol><li>one</li><li>two</li><li>three</li><li>four</li></ol>" + end + + test "delegated renderers" do + code = ~s[a<br/>b] + {result, [], []} = Utils.format_input(code, "text/markdown") + assert result == "<p>#{code}</p>" + + code = ~s[*aaaa~*] + {result, [], []} = Utils.format_input(code, "text/markdown") + assert result == ~s[<p><em>aaaa~</em></p>] + + code = ~s[**aaaa~**] + {result, [], []} = Utils.format_input(code, "text/markdown") + assert result == ~s[<p><strong>aaaa~</strong></p>] + + # strikethrought + code = ~s[<del>aaaa~</del>] + {result, [], []} = Utils.format_input(code, "text/markdown") + assert result == ~s[<p><del>aaaa~</del></p>] + end + end + describe "context_to_conversation_id" do test "creates a mapping object" do conversation_id = Utils.context_to_conversation_id("random context") From ba71bbf6101847292346ba3b1fbe78ce4c385919 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Tue, 13 Oct 2020 01:53:25 -0500 Subject: [PATCH 002/339] Improve Formatter.minify/2 --- lib/pleroma/formatter.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/formatter.ex b/lib/pleroma/formatter.ex index b0e4a84ae..61906dda6 100644 --- a/lib/pleroma/formatter.ex +++ b/lib/pleroma/formatter.ex @@ -143,7 +143,10 @@ def minify({text, mentions, hashtags}, type) do end def minify(text, "text/html") do - String.replace(text, "\n", "") + text + |> String.replace(">\n", ">") + |> String.replace("> ", ">") + |> String.replace(" <", "<") end def truncate(text, max_length \\ 200, omission \\ "...") do From c4f4e48e574362d1ec86eaf11a382e81ca97cb35 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Tue, 13 Oct 2020 02:08:41 -0500 Subject: [PATCH 003/339] Remove some N/A tests --- test/pleroma/web/common_api/utils_test.exs | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/test/pleroma/web/common_api/utils_test.exs b/test/pleroma/web/common_api/utils_test.exs index 39ea08ca8..c6abbbe84 100644 --- a/test/pleroma/web/common_api/utils_test.exs +++ b/test/pleroma/web/common_api/utils_test.exs @@ -187,12 +187,6 @@ test "rulers" do assert result == "<p>before</p><hr /><p>after</p>" end - test "headings" do - code = ~s[# h1\n## h2\n### h3\n] - {result, [], []} = Utils.format_input(code, "text/markdown") - assert result == ~s[<h1>h1</h1><h2>h2</h2><h3>h3</h3>] - end - test "blockquote" do code = ~s[> whoms't are you quoting?] {result, [], []} = Utils.format_input(code, "text/markdown") @@ -224,10 +218,6 @@ test "lists" do end test "delegated renderers" do - code = ~s[a<br/>b] - {result, [], []} = Utils.format_input(code, "text/markdown") - assert result == "<p>#{code}</p>" - code = ~s[*aaaa~*] {result, [], []} = Utils.format_input(code, "text/markdown") assert result == ~s[<p><em>aaaa~</em></p>] @@ -236,7 +226,7 @@ test "delegated renderers" do {result, [], []} = Utils.format_input(code, "text/markdown") assert result == ~s[<p><strong>aaaa~</strong></p>] - # strikethrought + # strikethrough code = ~s[<del>aaaa~</del>] {result, [], []} = Utils.format_input(code, "text/markdown") assert result == ~s[<p><del>aaaa~</del></p>] From b2548cfcdabdcb90bfcc9f4022c0b1cff9157a4a Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Tue, 13 Oct 2020 13:54:53 -0500 Subject: [PATCH 004/339] Sanitizer: allow <hr> tags --- priv/scrubbers/default.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/priv/scrubbers/default.ex b/priv/scrubbers/default.ex index 7b06994de..0893b17e5 100644 --- a/priv/scrubbers/default.ex +++ b/priv/scrubbers/default.ex @@ -39,6 +39,7 @@ defmodule Pleroma.HTML.Scrubber.Default do Meta.allow_tag_with_these_attributes(:code, []) Meta.allow_tag_with_these_attributes(:del, []) Meta.allow_tag_with_these_attributes(:em, []) + Meta.allow_tag_with_these_attributes(:hr, []) Meta.allow_tag_with_these_attributes(:i, []) Meta.allow_tag_with_these_attributes(:li, []) Meta.allow_tag_with_these_attributes(:ol, []) From f8c93246d69a193ead81248879ba260e98673b3d Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Tue, 13 Oct 2020 14:27:50 -0500 Subject: [PATCH 005/339] Refactor Earmark code, fix tests --- lib/pleroma/formatter.ex | 4 ++++ .../object_validators/audio_video_validator.ex | 2 +- lib/pleroma/web/common_api/utils.ex | 2 +- priv/scrubbers/default.ex | 2 ++ test/pleroma/web/common_api/utils_test.exs | 10 +++++----- test/pleroma/web/common_api_test.exs | 2 +- 6 files changed, 14 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/formatter.ex b/lib/pleroma/formatter.ex index 61906dda6..1be12055f 100644 --- a/lib/pleroma/formatter.ex +++ b/lib/pleroma/formatter.ex @@ -121,6 +121,10 @@ def mentions_escape(text, options \\ []) do end end + def markdown_to_html(text) do + Earmark.as_html!(text) + end + def html_escape({text, mentions, hashtags}, type) do {html_escape(text, type), mentions, hashtags} end diff --git a/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex b/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex index eaf94797a..9b38aa4c2 100644 --- a/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex @@ -95,7 +95,7 @@ defp fix_content(%{"mediaType" => "text/markdown", "content" => content} = data) when is_binary(content) do content = content - |> Earmark.as_html!() + |> Pleroma.Formatter.markdown_to_html() |> Pleroma.HTML.filter_tags() Map.put(data, "content", content) diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index b434a069e..be86009af 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -294,7 +294,7 @@ def format_input(text, "text/html", options) do def format_input(text, "text/markdown", options) do text |> Formatter.mentions_escape(options) - |> Earmark.as_html!() + |> Formatter.markdown_to_html() |> Formatter.linkify(options) |> Formatter.minify("text/html") |> Formatter.html_escape("text/html") diff --git a/priv/scrubbers/default.ex b/priv/scrubbers/default.ex index 0893b17e5..4694a92a5 100644 --- a/priv/scrubbers/default.ex +++ b/priv/scrubbers/default.ex @@ -59,6 +59,8 @@ defmodule Pleroma.HTML.Scrubber.Default do Meta.allow_tag_with_this_attribute_values(:span, "class", ["h-card"]) Meta.allow_tag_with_these_attributes(:span, []) + Meta.allow_tag_with_this_attribute_values(:code, "class", ["inline"]) + @allow_inline_images Pleroma.Config.get([:markup, :allow_inline_images]) if @allow_inline_images do diff --git a/test/pleroma/web/common_api/utils_test.exs b/test/pleroma/web/common_api/utils_test.exs index c6abbbe84..ab6392b1f 100644 --- a/test/pleroma/web/common_api/utils_test.exs +++ b/test/pleroma/web/common_api/utils_test.exs @@ -178,13 +178,13 @@ test "Paragraph" do test "raw HTML" do code = ~s[<a href="http://example.org/">OwO</a><!-- what's this?-->] {result, [], []} = Utils.format_input(code, "text/markdown") - assert result == "<p>#{code}</p>" + assert result == ~s[<a href="http://example.org/">OwO</a>] end test "rulers" do code = ~s[before\n\n-----\n\nafter] {result, [], []} = Utils.format_input(code, "text/markdown") - assert result == "<p>before</p><hr /><p>after</p>" + assert result == "<p>before</p><hr/><p>after</p>" end test "blockquote" do @@ -204,7 +204,7 @@ test "code" do code = ~s[```\nputs "Hello World"\n```] {result, [], []} = Utils.format_input(code, "text/markdown") - assert result == ~s[<pre><code class="">puts "Hello World"</code></pre>] + assert result == ~s[<pre><code>puts "Hello World"</code></pre>] end test "lists" do @@ -227,9 +227,9 @@ test "delegated renderers" do assert result == ~s[<p><strong>aaaa~</strong></p>] # strikethrough - code = ~s[<del>aaaa~</del>] + code = ~s[~~aaaa~~~] {result, [], []} = Utils.format_input(code, "text/markdown") - assert result == ~s[<p><del>aaaa~</del></p>] + assert result == ~s[<p><del>aaaa</del>~</p>] end end diff --git a/test/pleroma/web/common_api_test.exs b/test/pleroma/web/common_api_test.exs index 585b2c174..c1b1af073 100644 --- a/test/pleroma/web/common_api_test.exs +++ b/test/pleroma/web/common_api_test.exs @@ -558,7 +558,7 @@ test "it filters out obviously bad tags when accepting a post as Markdown" do object = Object.normalize(activity) - assert object.data["content"] == "<p><b>2hu</b></p>alert('xss')" + assert object.data["content"] == "<p><b>2hu</b></p>" assert object.data["source"] == post end From f1c67115d89ddcc7b10b963579dd621fca2094db Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Tue, 13 Oct 2020 18:09:49 -0500 Subject: [PATCH 006/339] Upgrade linkify, test URL issues, fixes #2026 #1942 --- test/pleroma/web/common_api/utils_test.exs | 52 ++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/test/pleroma/web/common_api/utils_test.exs b/test/pleroma/web/common_api/utils_test.exs index ab6392b1f..28b05ed91 100644 --- a/test/pleroma/web/common_api/utils_test.exs +++ b/test/pleroma/web/common_api/utils_test.exs @@ -175,6 +175,54 @@ test "Paragraph" do assert result == "<p>Hello</p><p>World!</p>" end + test "links" do + code = "https://en.wikipedia.org/wiki/Animal_Crossing_(video_game)" + {result, [], []} = Utils.format_input(code, "text/markdown") + assert result == ~s[<p><a href="#{code}">#{code}</a></p>] + + code = "https://github.com/pragdave/earmark/" + {result, [], []} = Utils.format_input(code, "text/markdown") + assert result == ~s[<p><a href="#{code}">#{code}</a></p>] + end + + test "link with local mention" do + insert(:user, %{nickname: "lain"}) + + code = "https://example.com/@lain" + {result, [], []} = Utils.format_input(code, "text/markdown") + assert result == ~s[<p><a href="#{code}">#{code}</a></p>] + end + + test "local mentions" do + mario = insert(:user, %{nickname: "mario"}) + luigi = insert(:user, %{nickname: "luigi"}) + + code = "@mario @luigi yo what's up?" + {result, _, []} = Utils.format_input(code, "text/markdown") + + assert result == + ~s[<p><span class="h-card"><a class="u-url mention" data-user="#{mario.id}" href="#{ + mario.ap_id + }" rel="ugc">@<span>mario</span></a></span> <span class="h-card"><a class="u-url mention" data-user="#{ + luigi.id + }" href="#{luigi.ap_id}" rel="ugc">@<span>luigi</span></a></span> yo what’s up?</p>] + end + + test "remote mentions" do + mario = insert(:user, %{nickname: "mario@mushroom.kingdom", local: false}) + luigi = insert(:user, %{nickname: "luigi@mushroom.kingdom", local: false}) + + code = "@mario@mushroom.kingdom @luigi@mushroom.kingdom yo what's up?" + {result, _, []} = Utils.format_input(code, "text/markdown") + + assert result == + ~s[<p><span class="h-card"><a class="u-url mention" data-user="#{mario.id}" href="#{ + mario.ap_id + }" rel="ugc">@<span>mario</span></a></span> <span class="h-card"><a class="u-url mention" data-user="#{ + luigi.id + }" href="#{luigi.ap_id}" rel="ugc">@<span>luigi</span></a></span> yo what’s up?</p>] + end + test "raw HTML" do code = ~s[<a href="http://example.org/">OwO</a><!-- what's this?-->] {result, [], []} = Utils.format_input(code, "text/markdown") @@ -205,6 +253,10 @@ test "code" do code = ~s[```\nputs "Hello World"\n```] {result, [], []} = Utils.format_input(code, "text/markdown") assert result == ~s[<pre><code>puts "Hello World"</code></pre>] + + code = ~s[ <div>\n </div>] + {result, [], []} = Utils.format_input(code, "text/markdown") + assert result == ~s[<pre><code><div>\n</div></code></pre>] end test "lists" do From 642729b49fca41fb142c6121fedf35c96c03b018 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Tue, 13 Oct 2020 19:16:57 -0500 Subject: [PATCH 007/339] Fix AudioVideoValidator markdown --- .../web/activity_pub/object_validators/audio_video_validator.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex b/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex index 9b38aa4c2..fa3e2c026 100644 --- a/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex @@ -96,6 +96,7 @@ defp fix_content(%{"mediaType" => "text/markdown", "content" => content} = data) content = content |> Pleroma.Formatter.markdown_to_html() + |> Pleroma.Formatter.minify("text/html") |> Pleroma.HTML.filter_tags() Map.put(data, "content", content) From 6520599b7deac56780e1496c969cc45ff2e9f5da Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Fri, 11 Dec 2020 13:43:40 -0600 Subject: [PATCH 008/339] Update Earmark to 1.4.13, use the new compact_output mode --- lib/pleroma/formatter.ex | 2 +- mix.exs | 2 +- mix.lock | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/formatter.ex b/lib/pleroma/formatter.ex index 1be12055f..2aa236ca9 100644 --- a/lib/pleroma/formatter.ex +++ b/lib/pleroma/formatter.ex @@ -122,7 +122,7 @@ def mentions_escape(text, options \\ []) do end def markdown_to_html(text) do - Earmark.as_html!(text) + Earmark.as_html!(text, %Earmark.Options{compact_output: true}) end def html_escape({text, mentions, hashtags}, type) do diff --git a/mix.exs b/mix.exs index feb7eefa3..06d77edb7 100644 --- a/mix.exs +++ b/mix.exs @@ -144,7 +144,7 @@ defp deps do {:ex_aws, "~> 2.1.6"}, {:ex_aws_s3, "~> 2.0"}, {:sweet_xml, "~> 0.6.6"}, - {:earmark, "1.4.10"}, + {:earmark, "1.4.13"}, {:bbcode_pleroma, "~> 0.2.0"}, {:crypt, git: "https://github.com/msantos/crypt.git", diff --git a/mix.lock b/mix.lock index 29439a438..e4dd32c83 100644 --- a/mix.lock +++ b/mix.lock @@ -27,8 +27,8 @@ "db_connection": {:hex, :db_connection, "2.2.2", "3bbca41b199e1598245b716248964926303b5d4609ff065125ce98bcd368939e", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "642af240d8a8affb93b4ba5a6fcd2bbcbdc327e1a524b825d383711536f8070c"}, "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, - "earmark": {:hex, :earmark, "1.4.10", "bddce5e8ea37712a5bfb01541be8ba57d3b171d3fa4f80a0be9bcf1db417bcaf", [:mix], [{:earmark_parser, ">= 1.4.10", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "12dbfa80810478e521d3ffb941ad9fbfcbbd7debe94e1341b4c4a1b2411c1c27"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.10", "6603d7a603b9c18d3d20db69921527f82ef09990885ed7525003c7fe7dc86c56", [:mix], [], "hexpm", "8e2d5370b732385db2c9b22215c3f59c84ac7dda7ed7e544d7c459496ae519c0"}, + "earmark": {:hex, :earmark, "1.4.13", "2c6ce9768fc9fdbf4046f457e207df6360ee6c91ee1ecb8e9a139f96a4289d91", [:mix], [{:earmark_parser, ">= 1.4.12", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "a0cf3ed88ef2b1964df408889b5ecb886d1a048edde53497fc935ccd15af3403"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.12", "b245e875ec0a311a342320da0551da407d9d2b65d98f7a9597ae078615af3449", [:mix], [], "hexpm", "711e2cc4d64abb7d566d43f54b78f7dc129308a63bc103fbd88550d2174b3160"}, "ecto": {:hex, :ecto, "3.4.6", "08f7afad3257d6eb8613309af31037e16c36808dfda5a3cd0cb4e9738db030e4", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6f13a9e2a62e75c2dcfc7207bfc65645ab387af8360db4c89fee8b5a4bf3f70b"}, "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, "ecto_sql": {:hex, :ecto_sql, "3.4.5", "30161f81b167d561a9a2df4329c10ae05ff36eca7ccc84628f2c8b9fa1e43323", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.0", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "31990c6a3579b36a3c0841d34a94c275e727de8b84f58509da5f1b2032c98ac2"}, From f318d8e56df1e30f41c7ddf2e306b3552034921f Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Fri, 11 Dec 2020 17:28:00 -0600 Subject: [PATCH 009/339] Use Pleroma.Formatter.markdown_to_html/1 in the tests --- test/pleroma/earmark_renderer_test.exs | 28 +++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/test/pleroma/earmark_renderer_test.exs b/test/pleroma/earmark_renderer_test.exs index 220d97d16..3adbefc1e 100644 --- a/test/pleroma/earmark_renderer_test.exs +++ b/test/pleroma/earmark_renderer_test.exs @@ -6,74 +6,74 @@ defmodule Pleroma.EarmarkRendererTest do test "Paragraph" do code = ~s[Hello\n\nWorld!] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + result = Pleroma.Formatter.markdown_to_html(code) assert result == "<p>Hello</p><p>World!</p>" end test "raw HTML" do code = ~s[<a href="http://example.org/">OwO</a><!-- what's this?-->] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + result = Pleroma.Formatter.markdown_to_html(code) assert result == "<p>#{code}</p>" end test "rulers" do code = ~s[before\n\n-----\n\nafter] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + result = Pleroma.Formatter.markdown_to_html(code) assert result == "<p>before</p><hr /><p>after</p>" end test "headings" do code = ~s[# h1\n## h2\n### h3\n] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + result = Pleroma.Formatter.markdown_to_html(code) assert result == ~s[<h1>h1</h1><h2>h2</h2><h3>h3</h3>] end test "blockquote" do code = ~s[> whoms't are you quoting?] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + result = Pleroma.Formatter.markdown_to_html(code) assert result == "<blockquote><p>whoms’t are you quoting?</p></blockquote>" end test "code" do code = ~s[`mix`] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + result = Pleroma.Formatter.markdown_to_html(code) assert result == ~s[<p><code class="inline">mix</code></p>] code = ~s[``mix``] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + result = Pleroma.Formatter.markdown_to_html(code) assert result == ~s[<p><code class="inline">mix</code></p>] code = ~s[```\nputs "Hello World"\n```] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + result = Pleroma.Formatter.markdown_to_html(code) assert result == ~s[<pre><code class="">puts "Hello World"</code></pre>] end test "lists" do code = ~s[- one\n- two\n- three\n- four] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + result = Pleroma.Formatter.markdown_to_html(code) assert result == "<ul><li>one</li><li>two</li><li>three</li><li>four</li></ul>" code = ~s[1. one\n2. two\n3. three\n4. four\n] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + result = Pleroma.Formatter.markdown_to_html(code) assert result == "<ol><li>one</li><li>two</li><li>three</li><li>four</li></ol>" end test "delegated renderers" do code = ~s[a<br/>b] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + result = Pleroma.Formatter.markdown_to_html(code) assert result == "<p>#{code}</p>" code = ~s[*aaaa~*] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + result = Pleroma.Formatter.markdown_to_html(code) assert result == ~s[<p><em>aaaa~</em></p>] code = ~s[**aaaa~**] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + result = Pleroma.Formatter.markdown_to_html(code) assert result == ~s[<p><strong>aaaa~</strong></p>] # strikethrought code = ~s[<del>aaaa~</del>] - result = Earmark.as_html!(code, %Earmark.Options{renderer: Pleroma.EarmarkRenderer}) + result = Pleroma.Formatter.markdown_to_html(code) assert result == ~s[<p><del>aaaa~</del></p>] end end From ee221277b05d2f682c340c1e1b81fbce4931735a Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Mon, 21 Dec 2020 22:54:26 +0300 Subject: [PATCH 010/339] Encapsulation of tags / hashtags fetching from objects. --- lib/pleroma/activity/ir/topics.ex | 10 ++--- lib/pleroma/object.ex | 45 ++++++++++++++++--- .../web/activity_pub/mrf/simple_policy.ex | 8 ++-- .../web/activity_pub/transmogrifier.ex | 29 ++++++------ lib/pleroma/web/feed/feed_view.ex | 1 + .../web/mastodon_api/views/status_view.ex | 6 ++- .../templates/feed/feed/_activity.atom.eex | 2 +- .../web/templates/feed/feed/_activity.rss.eex | 2 +- .../feed/feed/_tag_activity.atom.eex | 2 +- .../transmogrifier/note_handling_test.exs | 6 ++- test/pleroma/web/common_api_test.exs | 2 +- .../mastodon_api/views/status_view_test.exs | 4 +- 12 files changed, 78 insertions(+), 39 deletions(-) diff --git a/lib/pleroma/activity/ir/topics.ex b/lib/pleroma/activity/ir/topics.ex index fe2e8cb5c..2c74ac2bf 100644 --- a/lib/pleroma/activity/ir/topics.ex +++ b/lib/pleroma/activity/ir/topics.ex @@ -48,14 +48,12 @@ defp item_creation_tags(tags, _, _) do tags end - defp hashtags_to_topics(%{data: %{"tag" => tags}}) do - tags - |> Enum.filter(&is_bitstring(&1)) - |> Enum.map(fn tag -> "hashtag:" <> tag end) + defp hashtags_to_topics(object) do + object + |> Object.hashtags() + |> Enum.map(fn hashtag -> "hashtag:" <> hashtag end) end - defp hashtags_to_topics(_), do: [] - defp remote_topics(%{local: true}), do: [] defp remote_topics(%{actor: actor}) when is_binary(actor), diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index 052ad413b..2088c7656 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -47,17 +47,33 @@ def with_joined_activity(query, activity_type \\ "Create", join_type \\ :inner) end def create(data) do - Object.change(%Object{}, %{data: data}) + %Object{} + |> Object.change(%{data: data}) |> Repo.insert() end def change(struct, params \\ %{}) do - struct - |> cast(params, [:data]) - |> validate_required([:data]) - |> unique_constraint(:ap_id, name: :objects_unique_apid_index) + changeset = + struct + |> cast(params, [:data]) + |> validate_required([:data]) + |> unique_constraint(:ap_id, name: :objects_unique_apid_index) + + if hashtags_changed?(struct, get_change(changeset, :data)) do + # TODO: modify assoc once it's introduced + changeset + else + changeset + end end + defp hashtags_changed?(%Object{} = struct, %{"tag" => _} = data) do + Enum.sort(embedded_hashtags(struct)) != + Enum.sort(object_data_hashtags(data)) + end + + defp hashtags_changed?(_, _), do: false + def get_by_id(nil), do: nil def get_by_id(id), do: Repo.get(Object, id) @@ -344,4 +360,23 @@ def replies(object, opts \\ []) do def self_replies(object, opts \\ []), do: replies(object, Keyword.put(opts, :self_only, true)) + + def tags(%Object{data: %{"tag" => tags}}) when is_list(tags), do: tags + + def tags(_), do: [] + + def hashtags(object), do: embedded_hashtags(object) + + defp embedded_hashtags(%Object{data: data}) do + object_data_hashtags(data) + end + + defp embedded_hashtags(_), do: [] + + defp object_data_hashtags(%{"tag" => tags}) when is_list(tags) do + # Note: AS2 map-type elements are ignored + Enum.filter(tags, &is_bitstring(&1)) + end + + defp object_data_hashtags(_), do: [] end diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index 6cd91826d..e92091d66 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -74,9 +74,11 @@ defp check_media_nsfw( object = if MRF.subdomain_match?(media_nsfw, actor_host) do - tags = (child_object["tag"] || []) ++ ["nsfw"] - child_object = Map.put(child_object, "tag", tags) - child_object = Map.put(child_object, "sensitive", true) + child_object = + child_object + |> Map.put("tag", (child_object["tag"] || []) ++ ["nsfw"]) + |> Map.put("sensitive", true) + Map.put(object, "object", child_object) else object diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 565d32433..fd17793d0 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -32,18 +32,18 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do """ def fix_object(object, options \\ []) do object - |> strip_internal_fields - |> fix_actor - |> fix_url - |> fix_attachments - |> fix_context + |> strip_internal_fields() + |> fix_actor() + |> fix_url() + |> fix_attachments() + |> fix_context() |> fix_in_reply_to(options) - |> fix_emoji - |> fix_tag - |> set_sensitive - |> fix_content_map - |> fix_addressing - |> fix_summary + |> fix_emoji() + |> fix_tag() + |> set_sensitive() + |> fix_content_map() + |> fix_addressing() + |> fix_summary() |> fix_type(options) end @@ -315,10 +315,9 @@ def fix_tag(%{"tag" => tag} = object) when is_list(tag) do tags = tag |> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end) - |> Enum.map(fn %{"name" => name} -> - name - |> String.slice(1..-1) - |> String.downcase() + |> Enum.map(fn + %{"name" => "#" <> hashtag} -> String.downcase(hashtag) + %{"name" => hashtag} -> String.downcase(hashtag) end) Map.put(object, "tag", tag ++ tags) diff --git a/lib/pleroma/web/feed/feed_view.ex b/lib/pleroma/web/feed/feed_view.ex index 30e0a2a55..1155c6a39 100644 --- a/lib/pleroma/web/feed/feed_view.ex +++ b/lib/pleroma/web/feed/feed_view.ex @@ -32,6 +32,7 @@ def prepare_activity(activity, opts \\ []) do %{ activity: activity, + object: object, data: Map.get(object, :data), actor: actor } diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 2301e21cf..bd08aa203 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -201,8 +201,10 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} like_count = object.data["like_count"] || 0 announcement_count = object.data["announcement_count"] || 0 - tags = object.data["tag"] || [] - sensitive = object.data["sensitive"] || Enum.member?(tags, "nsfw") + hashtags = Object.hashtags(object) + sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw") + + tags = Object.tags(object) tag_mentions = tags diff --git a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex index 3fd150c4e..6688830ba 100644 --- a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex @@ -22,7 +22,7 @@ <link type="text/html" href='<%= @data["external_url"] %>' rel="alternate"/> <% end %> - <%= for tag <- @data["tag"] || [] do %> + <%= for tag <- Pleroma.Object.hashtags(@object) do %> <category term="<%= tag %>"></category> <% end %> diff --git a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex index 42960de7d..fc6d74b42 100644 --- a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex +++ b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex @@ -21,7 +21,7 @@ <link><%= @data["external_url"] %></link> <% end %> - <%= for tag <- @data["tag"] || [] do %> + <%= for tag <- Pleroma.Object.hashtags(@object) do %> <category term="<%= tag %>"></category> <% end %> diff --git a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex index cf5874a91..c2de28fe4 100644 --- a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex @@ -41,7 +41,7 @@ <% end %> <% end %> - <%= for tag <- @data["tag"] || [] do %> + <%= for tag <- Pleroma.Object.hashtags(@object) do %> <category term="<%= tag %>"></category> <% end %> diff --git a/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs index b4a006aec..a33959d9f 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs @@ -39,7 +39,8 @@ test "it works for incoming notices with tag not being an array (kroeg)" do {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) object = Object.normalize(data["object"]) - assert "test" in object.data["tag"] + assert "test" in Object.tags(object) + assert Object.hashtags(object) == ["test"] end test "it cleans up incoming notices which are not really DMs" do @@ -220,7 +221,8 @@ test "it works for incoming notices with hashtags" do {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) object = Object.normalize(data["object"]) - assert Enum.at(object.data["tag"], 2) == "moo" + assert Enum.at(Object.tags(object), 2) == "moo" + assert Object.hashtags(object) == ["moo"] end test "it works for incoming notices with contentMap" do diff --git a/test/pleroma/web/common_api_test.exs b/test/pleroma/web/common_api_test.exs index 585b2c174..1e98208fb 100644 --- a/test/pleroma/web/common_api_test.exs +++ b/test/pleroma/web/common_api_test.exs @@ -493,7 +493,7 @@ test "it de-duplicates tags" do object = Object.normalize(activity) - assert object.data["tag"] == ["2hu"] + assert Object.tags(object) == ["2hu"] end test "it adds emoji in the object" do diff --git a/test/pleroma/web/mastodon_api/views/status_view_test.exs b/test/pleroma/web/mastodon_api/views/status_view_test.exs index f2a7469ed..6b8afc960 100644 --- a/test/pleroma/web/mastodon_api/views/status_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/status_view_test.exs @@ -262,8 +262,8 @@ test "a note activity" do mentions: [], tags: [ %{ - name: "#{object_data["tag"]}", - url: "/tag/#{object_data["tag"]}" + name: "#{hd(object_data["tag"])}", + url: "/tag/#{hd(object_data["tag"])}" } ], application: %{ From e369b1306b2f8b9732c21333b9957f7e4e408f90 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Tue, 22 Dec 2020 22:04:33 +0300 Subject: [PATCH 011/339] Added Hashtag entity and objects-hashtags association with auto-sync with `data.tag` on Object update. --- lib/pleroma/hashtag.ex | 58 +++++++++++++++++++ lib/pleroma/object.ex | 35 ++++++++--- .../20201221202251_create_hashtags.exs | 14 +++++ ...20201221203824_create_hashtags_objects.exs | 13 +++++ test/pleroma/object_test.exs | 27 +++++++++ .../web/activity_pub/activity_pub_test.exs | 5 ++ 6 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 lib/pleroma/hashtag.ex create mode 100644 priv/repo/migrations/20201221202251_create_hashtags.exs create mode 100644 priv/repo/migrations/20201221203824_create_hashtags_objects.exs diff --git a/lib/pleroma/hashtag.ex b/lib/pleroma/hashtag.ex new file mode 100644 index 000000000..b05927563 --- /dev/null +++ b/lib/pleroma/hashtag.ex @@ -0,0 +1,58 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Hashtag do + use Ecto.Schema + + import Ecto.Changeset + + alias Pleroma.Hashtag + alias Pleroma.Repo + + @derive {Jason.Encoder, only: [:data]} + + schema "hashtags" do + field(:name, :string) + field(:data, :map, default: %{}) + + many_to_many(:objects, Pleroma.Object, join_through: "hashtags_objects", on_replace: :delete) + + timestamps() + end + + def get_by_name(name) do + Repo.get_by(Hashtag, name: name) + end + + def get_or_create_by_name(name) when is_bitstring(name) do + with %Hashtag{} = hashtag <- get_by_name(name) do + {:ok, hashtag} + else + _ -> + %Hashtag{} + |> changeset(%{name: name}) + |> Repo.insert() + end + end + + def get_or_create_by_names(names) when is_list(names) do + Enum.reduce_while(names, {:ok, []}, fn name, {:ok, list} -> + case get_or_create_by_name(name) do + {:ok, %Hashtag{} = hashtag} -> + {:cont, {:ok, list ++ [hashtag]}} + + error -> + {:halt, error} + end + end) + end + + def changeset(%Hashtag{} = struct, params) do + struct + |> cast(params, [:name, :data]) + |> update_change(:name, &String.downcase/1) + |> validate_required([:name]) + |> unique_constraint(:name) + end +end diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index 2088c7656..357a3b504 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Object do alias Pleroma.Activity alias Pleroma.Config + alias Pleroma.Hashtag alias Pleroma.Object alias Pleroma.Object.Fetcher alias Pleroma.ObjectTombstone @@ -26,6 +27,8 @@ defmodule Pleroma.Object do schema "objects" do field(:data, :map) + many_to_many(:hashtags, Hashtag, join_through: "hashtags_objects", on_replace: :delete) + timestamps() end @@ -53,17 +56,31 @@ def create(data) do end def change(struct, params \\ %{}) do - changeset = - struct - |> cast(params, [:data]) - |> validate_required([:data]) - |> unique_constraint(:ap_id, name: :objects_unique_apid_index) + struct + |> cast(params, [:data]) + |> validate_required([:data]) + |> unique_constraint(:ap_id, name: :objects_unique_apid_index) + |> maybe_handle_hashtags_change(struct) + end - if hashtags_changed?(struct, get_change(changeset, :data)) do - # TODO: modify assoc once it's introduced - changeset + defp maybe_handle_hashtags_change(changeset, struct) do + with data_hashtags_change = get_change(changeset, :data), + true <- hashtags_changed?(struct, data_hashtags_change), + {:ok, hashtag_records} <- + data_hashtags_change + |> object_data_hashtags() + |> Hashtag.get_or_create_by_names() do + put_assoc(changeset, :hashtags, hashtag_records) else - changeset + false -> + changeset + + {:error, hashtag_changeset} -> + failed_hashtag = get_field(hashtag_changeset, :name) + + validate_change(changeset, :data, fn _, _ -> + [data: "error referencing hashtag: #{failed_hashtag}"] + end) end end diff --git a/priv/repo/migrations/20201221202251_create_hashtags.exs b/priv/repo/migrations/20201221202251_create_hashtags.exs new file mode 100644 index 000000000..afc522002 --- /dev/null +++ b/priv/repo/migrations/20201221202251_create_hashtags.exs @@ -0,0 +1,14 @@ +defmodule Pleroma.Repo.Migrations.CreateHashtags do + use Ecto.Migration + + def change do + create_if_not_exists table(:hashtags) do + add(:name, :citext, null: false) + add(:data, :map, default: %{}) + + timestamps() + end + + create_if_not_exists(unique_index(:hashtags, [:name])) + end +end diff --git a/priv/repo/migrations/20201221203824_create_hashtags_objects.exs b/priv/repo/migrations/20201221203824_create_hashtags_objects.exs new file mode 100644 index 000000000..b2649b4fb --- /dev/null +++ b/priv/repo/migrations/20201221203824_create_hashtags_objects.exs @@ -0,0 +1,13 @@ +defmodule Pleroma.Repo.Migrations.CreateHashtagsObjects do + use Ecto.Migration + + def change do + create_if_not_exists table(:hashtags_objects) do + add(:hashtag_id, references(:hashtags), null: false) + add(:object_id, references(:objects), null: false) + end + + create_if_not_exists(unique_index(:hashtags_objects, [:hashtag_id, :object_id])) + create_if_not_exists(index(:hashtags_objects, [:object_id])) + end +end diff --git a/test/pleroma/object_test.exs b/test/pleroma/object_test.exs index 5d4e6fb84..819ecd210 100644 --- a/test/pleroma/object_test.exs +++ b/test/pleroma/object_test.exs @@ -5,10 +5,13 @@ defmodule Pleroma.ObjectTest do use Pleroma.DataCase use Oban.Testing, repo: Pleroma.Repo + import ExUnit.CaptureLog import Pleroma.Factory import Tesla.Mock + alias Pleroma.Activity + alias Pleroma.Hashtag alias Pleroma.Object alias Pleroma.Repo alias Pleroma.Tests.ObanHelpers @@ -406,4 +409,28 @@ test "preserves internal fields on refetch", %{mock_modified: mock_modified} do assert updated_object.data["like_count"] == 1 end end + + describe ":hashtags association" do + test "Hashtag records are created with Object record and updated on its change" do + user = insert(:user) + + {:ok, %{object: object}} = + CommonAPI.post(user, %{status: "some text #hashtag1 #hashtag2 ..."}) + + assert [%Hashtag{name: "hashtag1"}, %Hashtag{name: "hashtag2"}] = + Enum.sort_by(object.hashtags, & &1.name) + + {:ok, object} = Object.update_data(object, %{"tag" => []}) + + assert [] = object.hashtags + + object = Object.get_by_id(object.id) |> Repo.preload(:hashtags) + assert [] = object.hashtags + + {:ok, object} = Object.update_data(object, %{"tag" => ["abc", "def"]}) + + assert [%Hashtag{name: "abc"}, %Hashtag{name: "def"}] = + Enum.sort_by(object.hashtags, & &1.name) + end + end end diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index 9eb7ae86b..bfec32042 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -217,6 +217,11 @@ test "it fetches the appropriate tag-restricted posts" do tag_all: ["test", "reject"] }) + [fetch_one, fetch_two, fetch_three, fetch_four] = + Enum.map([fetch_one, fetch_two, fetch_three, fetch_four], fn statuses -> + Enum.map(statuses, fn s -> Repo.preload(s, object: :hashtags) end) + end) + assert fetch_one == [status_one, status_three] assert fetch_two == [status_one, status_two, status_three] assert fetch_three == [status_one, status_two] From cbb19d0e1882f5ce641f30b51d7156336f81aba9 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Sat, 26 Dec 2020 22:20:55 +0300 Subject: [PATCH 012/339] [#3213] Hashtag-filtering functions in ActivityPub. Mix task for migrating hashtags to `hashtags` table. --- lib/mix/tasks/pleroma/database.ex | 64 +++++++ lib/pleroma/web/activity_pub/activity_pub.ex | 171 +++++++++++++----- .../web/activity_pub/activity_pub_test.exs | 48 ++--- 3 files changed, 218 insertions(+), 65 deletions(-) diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index 22151ce08..093c7dd30 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -4,14 +4,18 @@ defmodule Mix.Tasks.Pleroma.Database do alias Pleroma.Conversation + alias Pleroma.Hashtag alias Pleroma.Maintenance alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User + require Logger require Pleroma.Constants + import Ecto.Query import Mix.Pleroma + use Mix.Task @shortdoc "A collection of database related tasks" @@ -128,6 +132,66 @@ def run(["fix_likes_collections"]) do |> Stream.run() end + def run(["transfer_hashtags"]) do + import Ecto.Query + + start_pleroma() + + from( + object in Object, + left_join: hashtag in assoc(object, :hashtags), + where: is_nil(hashtag.id), + where: fragment("(?)->>'tag' != '[]'", object.data), + select: %{ + id: object.id, + inserted_at: object.inserted_at, + tag: fragment("(?)->>'tag'", object.data) + }, + order_by: [desc: object.id] + ) + |> Pleroma.Repo.chunk_stream(100, :batches) + |> Stream.each(fn objects -> + chunk_start = List.first(objects) + chunk_end = List.last(objects) + + Logger.info( + "transfer_hashtags: " <> + "#{chunk_start.id} (#{chunk_start.inserted_at}) -- " <> + "#{chunk_end.id} (#{chunk_end.inserted_at})" + ) + + Enum.map( + objects, + fn object -> + hashtags = + object.tag + |> Jason.decode!() + |> Enum.filter(&is_bitstring(&1)) + + with {:ok, hashtag_records} <- Hashtag.get_or_create_by_names(hashtags) do + Repo.transaction(fn -> + for hashtag_record <- hashtag_records do + with {:error, _} <- + Ecto.Adapters.SQL.query( + Repo, + "insert into hashtags_objects(hashtag_id, object_id) values " <> + "(#{hashtag_record.id}, #{object.id});" + ) do + Logger.warn( + "ERROR: could not link object #{object.id} and hashtag #{hashtag_record.id}" + ) + end + end + end) + else + e -> Logger.warn("ERROR: could not process object #{object.id}: #{inspect(e)}") + end + end + ) + end) + |> Stream.run() + end + def run(["vacuum", args]) do start_pleroma() diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 1c91bc074..2e25412c6 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -660,33 +660,41 @@ defp restrict_since(query, %{since_id: since_id}) do defp restrict_since(query, _), do: query defp restrict_tag_reject(_query, %{tag_reject: _tag_reject, skip_preload: true}) do - raise "Can't use the child object without preloading!" + raise_on_missing_preload() end - defp restrict_tag_reject(query, %{tag_reject: [_ | _] = tag_reject}) do + defp restrict_tag_reject(query, %{tag_reject: tag_reject}) when is_list(tag_reject) do from( [_activity, object] in query, where: fragment("not (?)->'tag' \\?| (?)", object.data, ^tag_reject) ) end + defp restrict_tag_reject(query, %{tag_reject: tag_reject}) when is_binary(tag_reject) do + restrict_tag_reject(query, %{tag_reject: [tag_reject]}) + end + defp restrict_tag_reject(query, _), do: query defp restrict_tag_all(_query, %{tag_all: _tag_all, skip_preload: true}) do - raise "Can't use the child object without preloading!" + raise_on_missing_preload() end - defp restrict_tag_all(query, %{tag_all: [_ | _] = tag_all}) do + defp restrict_tag_all(query, %{tag_all: tag_all}) when is_list(tag_all) do from( [_activity, object] in query, where: fragment("(?)->'tag' \\?& (?)", object.data, ^tag_all) ) end + defp restrict_tag_all(query, %{tag_all: tag}) when is_binary(tag) do + restrict_tag(query, %{tag: tag}) + end + defp restrict_tag_all(query, _), do: query defp restrict_tag(_query, %{tag: _tag, skip_preload: true}) do - raise "Can't use the child object without preloading!" + raise_on_missing_preload() end defp restrict_tag(query, %{tag: tag}) when is_list(tag) do @@ -697,14 +705,80 @@ defp restrict_tag(query, %{tag: tag}) when is_list(tag) do end defp restrict_tag(query, %{tag: tag}) when is_binary(tag) do - from( - [_activity, object] in query, - where: fragment("(?)->'tag' \\? (?)", object.data, ^tag) - ) + restrict_tag(query, %{tag: [tag]}) end defp restrict_tag(query, _), do: query + defp restrict_hashtag_reject_any(_query, %{tag_reject: _tag_reject, skip_preload: true}) do + raise_on_missing_preload() + end + + defp restrict_hashtag_reject_any(query, %{tag_reject: tags_reject}) when is_list(tags_reject) do + if has_named_binding?(query, :thread_mute) do + from( + [activity, object, thread_mute] in query, + group_by: [activity.id, object.id, thread_mute.id] + ) + else + from( + [activity, object] in query, + group_by: [activity.id, object.id] + ) + end + |> join(:left, [_activity, object], hashtag in assoc(object, :hashtags), as: :hashtag) + |> having( + [hashtag: hashtag], + fragment("not(array_agg(?) && (?))", hashtag.name, ^tags_reject) + ) + end + + defp restrict_hashtag_reject_any(query, %{tag_reject: tag_reject}) when is_binary(tag_reject) do + restrict_hashtag_reject_any(query, %{tag_reject: [tag_reject]}) + end + + defp restrict_hashtag_reject_any(query, _), do: query + + defp restrict_hashtag_all(_query, %{tag_all: _tag, skip_preload: true}) do + raise_on_missing_preload() + end + + defp restrict_hashtag_all(query, %{tag_all: tags}) when is_list(tags) do + Enum.reduce( + tags, + query, + fn tag, acc -> restrict_hashtag_any(acc, %{tag: tag}) end + ) + end + + defp restrict_hashtag_all(query, %{tag_all: tag}) when is_binary(tag) do + restrict_hashtag_any(query, %{tag: tag}) + end + + defp restrict_hashtag_all(query, _), do: query + + defp restrict_hashtag_any(_query, %{tag: _tag, skip_preload: true}) do + raise_on_missing_preload() + end + + defp restrict_hashtag_any(query, %{tag: tags}) when is_list(tags) do + from( + [_activity, object] in query, + join: hashtag in assoc(object, :hashtags), + where: hashtag.name in ^tags + ) + end + + defp restrict_hashtag_any(query, %{tag: tag}) when is_binary(tag) do + restrict_hashtag_any(query, %{tag: [tag]}) + end + + defp restrict_hashtag_any(query, _), do: query + + defp raise_on_missing_preload do + raise "Can't use the child object without preloading!" + end + defp restrict_recipients(query, [], _user), do: query defp restrict_recipients(query, recipients, nil) do @@ -1088,40 +1162,51 @@ def fetch_activities_query(recipients, opts \\ %{}) do skip_thread_containment: Config.get([:instance, :skip_thread_containment]) } - Activity - |> maybe_preload_objects(opts) - |> maybe_preload_bookmarks(opts) - |> maybe_preload_report_notes(opts) - |> maybe_set_thread_muted_field(opts) - |> maybe_order(opts) - |> restrict_recipients(recipients, opts[:user]) - |> restrict_replies(opts) - |> restrict_tag(opts) - |> restrict_tag_reject(opts) - |> restrict_tag_all(opts) - |> restrict_since(opts) - |> restrict_local(opts) - |> restrict_actor(opts) - |> restrict_type(opts) - |> restrict_state(opts) - |> restrict_favorited_by(opts) - |> restrict_blocked(restrict_blocked_opts) - |> restrict_muted(restrict_muted_opts) - |> restrict_filtered(opts) - |> restrict_media(opts) - |> restrict_visibility(opts) - |> restrict_thread_visibility(opts, config) - |> restrict_reblogs(opts) - |> restrict_pinned(opts) - |> restrict_muted_reblogs(restrict_muted_reblogs_opts) - |> restrict_instance(opts) - |> restrict_announce_object_actor(opts) - |> restrict_filtered(opts) - |> Activity.restrict_deactivated_users() - |> exclude_poll_votes(opts) - |> exclude_chat_messages(opts) - |> exclude_invisible_actors(opts) - |> exclude_visibility(opts) + query = + Activity + |> distinct([a], true) + |> maybe_preload_objects(opts) + |> maybe_preload_bookmarks(opts) + |> maybe_preload_report_notes(opts) + |> maybe_set_thread_muted_field(opts) + |> maybe_order(opts) + |> restrict_recipients(recipients, opts[:user]) + |> restrict_replies(opts) + |> restrict_since(opts) + |> restrict_local(opts) + |> restrict_actor(opts) + |> restrict_type(opts) + |> restrict_state(opts) + |> restrict_favorited_by(opts) + |> restrict_blocked(restrict_blocked_opts) + |> restrict_muted(restrict_muted_opts) + |> restrict_filtered(opts) + |> restrict_media(opts) + |> restrict_visibility(opts) + |> restrict_thread_visibility(opts, config) + |> restrict_reblogs(opts) + |> restrict_pinned(opts) + |> restrict_muted_reblogs(restrict_muted_reblogs_opts) + |> restrict_instance(opts) + |> restrict_announce_object_actor(opts) + |> restrict_filtered(opts) + |> Activity.restrict_deactivated_users() + |> exclude_poll_votes(opts) + |> exclude_chat_messages(opts) + |> exclude_invisible_actors(opts) + |> exclude_visibility(opts) + + if Config.get([:instance, :improved_hashtag_timeline]) do + query + |> restrict_hashtag_any(opts) + |> restrict_hashtag_all(opts) + |> restrict_hashtag_reject_any(opts) + else + query + |> restrict_tag(opts) + |> restrict_tag_reject(opts) + |> restrict_tag_all(opts) + end end def fetch_activities(recipients, opts \\ %{}, pagination \\ :keyset) do diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index bfec32042..573b26d66 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -199,33 +199,37 @@ test "it fetches the appropriate tag-restricted posts" do {:ok, status_two} = CommonAPI.post(user, %{status: ". #essais"}) {:ok, status_three} = CommonAPI.post(user, %{status: ". #test #reject"}) - fetch_one = ActivityPub.fetch_activities([], %{type: "Create", tag: "test"}) + for new_timeline_enabled <- [true, false] do + clear_config([:instance, :improved_hashtag_timeline], new_timeline_enabled) - fetch_two = ActivityPub.fetch_activities([], %{type: "Create", tag: ["test", "essais"]}) + fetch_one = ActivityPub.fetch_activities([], %{type: "Create", tag: "test"}) - fetch_three = - ActivityPub.fetch_activities([], %{ - type: "Create", - tag: ["test", "essais"], - tag_reject: ["reject"] - }) + fetch_two = ActivityPub.fetch_activities([], %{type: "Create", tag: ["test", "essais"]}) - fetch_four = - ActivityPub.fetch_activities([], %{ - type: "Create", - tag: ["test"], - tag_all: ["test", "reject"] - }) + fetch_three = + ActivityPub.fetch_activities([], %{ + type: "Create", + tag: ["test", "essais"], + tag_reject: ["reject"] + }) - [fetch_one, fetch_two, fetch_three, fetch_four] = - Enum.map([fetch_one, fetch_two, fetch_three, fetch_four], fn statuses -> - Enum.map(statuses, fn s -> Repo.preload(s, object: :hashtags) end) - end) + fetch_four = + ActivityPub.fetch_activities([], %{ + type: "Create", + tag: ["test"], + tag_all: ["test", "reject"] + }) - assert fetch_one == [status_one, status_three] - assert fetch_two == [status_one, status_two, status_three] - assert fetch_three == [status_one, status_two] - assert fetch_four == [status_three] + [fetch_one, fetch_two, fetch_three, fetch_four] = + Enum.map([fetch_one, fetch_two, fetch_three, fetch_four], fn statuses -> + Enum.map(statuses, fn s -> Repo.preload(s, object: :hashtags) end) + end) + + assert fetch_one == [status_one, status_three] + assert fetch_two == [status_one, status_two, status_three] + assert fetch_three == [status_one, status_two] + assert fetch_four == [status_three] + end end describe "insertion" do From 14fae94c0e4b04123c7af148260d0a4a51042570 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Mon, 28 Dec 2020 00:08:09 +0300 Subject: [PATCH 013/339] [#3213] Made Object.hashtags/1 work with :hashtags assoc. Adjusted tests. --- lib/pleroma/config.ex | 2 ++ lib/pleroma/object.ex | 14 +++++++++++++- lib/pleroma/web/activity_pub/activity_pub.ex | 12 ++++++------ test/pleroma/activity/ir/topics_test.exs | 15 ++++++++------- 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/lib/pleroma/config.ex b/lib/pleroma/config.ex index 86d4f6b72..ee0167f4e 100644 --- a/lib/pleroma/config.ex +++ b/lib/pleroma/config.ex @@ -96,6 +96,8 @@ def restrict_unauthenticated_access?(resource, kind) do end end + def object_embedded_hashtags?, do: !get([:instance, :improved_hashtag_timeline]) + def oauth_consumer_strategies, do: get([:auth, :oauth_consumer_strategies], []) def oauth_consumer_enabled?, do: oauth_consumer_strategies() != [] diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index 1d756bcd1..08114d4f2 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -384,7 +384,19 @@ def tags(%Object{data: %{"tag" => tags}}) when is_list(tags), do: tags def tags(_), do: [] - def hashtags(object), do: embedded_hashtags(object) + def hashtags(%Object{} = object) do + cond do + Config.object_embedded_hashtags?() -> + embedded_hashtags(object) + + object.id == "pleroma:fake_object_id" -> + [] + + true -> + hashtag_records = Repo.preload(object, :hashtags).hashtags + Enum.map(hashtag_records, & &1.name) + end + end defp embedded_hashtags(%Object{data: data}) do object_data_hashtags(data) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 54d1a2350..626cad336 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1199,16 +1199,16 @@ def fetch_activities_query(recipients, opts \\ %{}) do |> exclude_invisible_actors(opts) |> exclude_visibility(opts) - if Config.get([:instance, :improved_hashtag_timeline]) do - query - |> restrict_hashtag_any(opts) - |> restrict_hashtag_all(opts) - |> restrict_hashtag_reject_any(opts) - else + if Config.object_embedded_hashtags?() do query |> restrict_tag(opts) |> restrict_tag_reject(opts) |> restrict_tag_all(opts) + else + query + |> restrict_hashtag_any(opts) + |> restrict_hashtag_all(opts) + |> restrict_hashtag_reject_any(opts) end end diff --git a/test/pleroma/activity/ir/topics_test.exs b/test/pleroma/activity/ir/topics_test.exs index b464822d9..984777bda 100644 --- a/test/pleroma/activity/ir/topics_test.exs +++ b/test/pleroma/activity/ir/topics_test.exs @@ -11,6 +11,8 @@ defmodule Pleroma.Activity.Ir.TopicsTest do require Pleroma.Constants + import Mock + describe "poll answer" do test "produce no topics" do activity = %Activity{object: %Object{data: %{"type" => "Answer"}}} @@ -77,14 +79,13 @@ test "with no attachments doesn't produce public:media topics", %{activity: acti refute Enum.member?(topics, "public:local:media") end - test "converts tags to hash tags", %{activity: %{object: %{data: data} = object} = activity} do - tagged_data = Map.put(data, "tag", ["foo", "bar"]) - activity = %{activity | object: %{object | data: tagged_data}} + test "converts tags to hash tags", %{activity: activity} do + with_mock(Object, [:passthrough], hashtags: fn _ -> ["foo", "bar"] end) do + topics = Topics.get_activity_topics(activity) - topics = Topics.get_activity_topics(activity) - - assert Enum.member?(topics, "hashtag:foo") - assert Enum.member?(topics, "hashtag:bar") + assert Enum.member?(topics, "hashtag:foo") + assert Enum.member?(topics, "hashtag:bar") + end end test "only converts strings to hash tags", %{ From a25c1e8ec0b6f4ef2e9f68c4ad5e48e18f5f01a7 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Wed, 30 Dec 2020 14:35:19 +0300 Subject: [PATCH 014/339] [#3213] Improved `database.transfer_hashtags` mix task: proper rollback, speedup. --- lib/mix/tasks/pleroma/database.ex | 46 +++++++++++++++++-------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index 093c7dd30..d44bd3478 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -137,6 +137,8 @@ def run(["transfer_hashtags"]) do start_pleroma() + Logger.info("Starting transferring object embedded hashtags to `hashtags` table...") + from( object in Object, left_join: hashtag in assoc(object, :hashtags), @@ -144,21 +146,12 @@ def run(["transfer_hashtags"]) do where: fragment("(?)->>'tag' != '[]'", object.data), select: %{ id: object.id, - inserted_at: object.inserted_at, tag: fragment("(?)->>'tag'", object.data) - }, - order_by: [desc: object.id] + } ) |> Pleroma.Repo.chunk_stream(100, :batches) |> Stream.each(fn objects -> - chunk_start = List.first(objects) - chunk_end = List.last(objects) - - Logger.info( - "transfer_hashtags: " <> - "#{chunk_start.id} (#{chunk_start.inserted_at}) -- " <> - "#{chunk_end.id} (#{chunk_end.inserted_at})" - ) + Logger.info("Processing #{length(objects)} objects...") Enum.map( objects, @@ -168,28 +161,39 @@ def run(["transfer_hashtags"]) do |> Jason.decode!() |> Enum.filter(&is_bitstring(&1)) - with {:ok, hashtag_records} <- Hashtag.get_or_create_by_names(hashtags) do - Repo.transaction(fn -> + Repo.transaction(fn -> + with {:ok, hashtag_records} <- Hashtag.get_or_create_by_names(hashtags) do for hashtag_record <- hashtag_records do - with {:error, _} <- + with {:ok, _} <- Ecto.Adapters.SQL.query( Repo, "insert into hashtags_objects(hashtag_id, object_id) values " <> "(#{hashtag_record.id}, #{object.id});" ) do - Logger.warn( - "ERROR: could not link object #{object.id} and hashtag #{hashtag_record.id}" - ) + :noop + else + {:error, e} -> + error = + "ERROR: could not link object #{object.id} and hashtag " <> + "#{hashtag_record.id}: #{inspect(e)}" + + Logger.error(error) + Repo.rollback(error) end end - end) - else - e -> Logger.warn("ERROR: could not process object #{object.id}: #{inspect(e)}") - end + else + e -> + error = "ERROR: could not create hashtags for object #{object.id}: #{inspect(e)}" + Logger.error(error) + Repo.rollback(error) + end + end) end ) end) |> Stream.run() + + Logger.info("Done transferring hashtags. Please check logs to ensure no errors.") end def run(["vacuum", args]) do From e0b5edb6d5a423bfd247e0774d2f5bc642b2fb80 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Wed, 30 Dec 2020 14:42:35 +0300 Subject: [PATCH 015/339] [#3213] Fixed Object.object_data_hashtags/1 to process only AS2 elements of `data.tag` (basing on #2984). --- lib/pleroma/object.ex | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index 08114d4f2..dad572f2b 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -405,8 +405,16 @@ defp embedded_hashtags(%Object{data: data}) do defp embedded_hashtags(_), do: [] defp object_data_hashtags(%{"tag" => tags}) when is_list(tags) do - # Note: AS2 map-type elements are ignored - Enum.filter(tags, &is_bitstring(&1)) + # Note: Old format with copy of hashtags as strings is ignored, using AS2 + tags + |> Enum.filter(fn + %{"type" => "Hashtag"} = data -> Map.has_key?(data, "name") + _ -> false + end) + |> Enum.map(fn + %{"name" => "#" <> hashtag} -> String.downcase(hashtag) + %{"name" => hashtag} -> String.downcase(hashtag) + end) end defp object_data_hashtags(_), do: [] From 8d1a0c1afd46f8683e9022523cecffb9b60c9f8c Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Wed, 30 Dec 2020 15:22:49 +0300 Subject: [PATCH 016/339] [#3213] Made Object.object_data_hashtags/1 handle both AS2 and plain text hashtags. --- lib/pleroma/object.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index dad572f2b..7e79e15ee 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -405,16 +405,18 @@ defp embedded_hashtags(%Object{data: data}) do defp embedded_hashtags(_), do: [] defp object_data_hashtags(%{"tag" => tags}) when is_list(tags) do - # Note: Old format with copy of hashtags as strings is ignored, using AS2 tags |> Enum.filter(fn %{"type" => "Hashtag"} = data -> Map.has_key?(data, "name") + plain_text when is_bitstring(plain_text) -> true _ -> false end) |> Enum.map(fn %{"name" => "#" <> hashtag} -> String.downcase(hashtag) %{"name" => hashtag} -> String.downcase(hashtag) + hashtag when is_bitstring(hashtag) -> String.downcase(hashtag) end) + |> Enum.uniq() end defp object_data_hashtags(_), do: [] From 367f0c31c3c15f75aed1d3ba66914e4197c19596 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Thu, 31 Dec 2020 09:36:26 +0300 Subject: [PATCH 017/339] [#3213] Added query options support for Repo.chunk_stream/4. Used infinite timeout in transfer_hashtags select query. --- lib/mix/tasks/pleroma/database.ex | 11 +++++------ lib/pleroma/repo.ex | 6 +++--- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index d44bd3478..f903cf75b 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -149,9 +149,9 @@ def run(["transfer_hashtags"]) do tag: fragment("(?)->>'tag'", object.data) } ) - |> Pleroma.Repo.chunk_stream(100, :batches) + |> Repo.chunk_stream(100, :batches, timeout: :infinity) |> Stream.each(fn objects -> - Logger.info("Processing #{length(objects)} objects...") + Logger.info("Processing #{length(objects)} objects starting from id #{hd(objects).id}...") Enum.map( objects, @@ -165,10 +165,9 @@ def run(["transfer_hashtags"]) do with {:ok, hashtag_records} <- Hashtag.get_or_create_by_names(hashtags) do for hashtag_record <- hashtag_records do with {:ok, _} <- - Ecto.Adapters.SQL.query( - Repo, - "insert into hashtags_objects(hashtag_id, object_id) values " <> - "(#{hashtag_record.id}, #{object.id});" + Repo.query( + "insert into hashtags_objects(hashtag_id, object_id) values ($1, $2);", + [hashtag_record.id, object.id] ) do :noop else diff --git a/lib/pleroma/repo.ex b/lib/pleroma/repo.ex index 4524bd5e2..78711e6ac 100644 --- a/lib/pleroma/repo.ex +++ b/lib/pleroma/repo.ex @@ -63,8 +63,8 @@ def get_assoc(resource, association) do iex> Pleroma.Repo.chunk_stream(Pleroma.Activity.Queries.by_actor(ap_id), 500, :batches) """ @spec chunk_stream(Ecto.Query.t(), integer(), atom()) :: Enumerable.t() - def chunk_stream(query, chunk_size, returns_as \\ :one) do - # We don't actually need start and end funcitons of resource streaming, + def chunk_stream(query, chunk_size, returns_as \\ :one, query_options \\ []) do + # We don't actually need start and end functions of resource streaming, # but it seems to be the only way to not fetch records one-by-one and # have individual records be the elements of the stream, instead of # lists of records @@ -76,7 +76,7 @@ def chunk_stream(query, chunk_size, returns_as \\ :one) do |> order_by(asc: :id) |> where([r], r.id > ^last_id) |> limit(^chunk_size) - |> all() + |> all(query_options) |> case do [] -> {:halt, last_id} From 303055456f19152821ec5ec1df88d60c03f60905 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Thu, 31 Dec 2020 12:45:23 +0300 Subject: [PATCH 018/339] Alternative implementation of hashtag-filtering queries in ActivityPub. Fixed GROUP BY clause for aggregation on hashtags. --- lib/pleroma/activity.ex | 2 + lib/pleroma/web/activity_pub/activity_pub.ex | 120 +++++++++++++++---- 2 files changed, 100 insertions(+), 22 deletions(-) diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 9d970a808..df216e4de 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -113,6 +113,7 @@ def with_preloaded_bookmark(query, %User{} = user) do from([a] in query, left_join: b in Bookmark, on: b.user_id == ^user.id and b.activity_id == a.id, + as: :bookmark, preload: [bookmark: b] ) end @@ -123,6 +124,7 @@ def with_preloaded_report_notes(query) do from([a] in query, left_join: r in ReportNote, on: a.id == r.activity_id, + as: :report_note, preload: [report_notes: r] ) end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 626cad336..339843330 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -713,22 +713,92 @@ defp restrict_tag(query, %{tag: tag}) when is_binary(tag) do defp restrict_tag(query, _), do: query + defp restrict_hashtag(query, opts) do + [tag_any, tag_all, tag_reject] = + [:tag, :tag_all, :tag_reject] + |> Enum.map(&opts[&1]) + |> Enum.map(&List.wrap(&1)) + + has_conditions = Enum.any?([tag_any, tag_all, tag_reject], &Enum.any?(&1)) + + cond do + !has_conditions -> + query + + opts[:skip_preload] -> + raise_on_missing_preload() + + true -> + query + |> group_by_all_bindings() + |> join(:left, [_activity, object], hashtag in assoc(object, :hashtags), as: :hashtag) + |> maybe_restrict_hashtag_any(tag_any) + |> maybe_restrict_hashtag_all(tag_all) + |> maybe_restrict_hashtag_reject_any(tag_reject) + end + end + + # Groups by all bindings to allow aggregation on hashtags + defp group_by_all_bindings(query) do + # Expecting named bindings: :object, :bookmark, :thread_mute, :report_note + cond do + Enum.count(query.aliases) == 4 -> + from([a, o, b3, b4, b5] in query, group_by: [a.id, o.id, b3.id, b4.id, b5.id]) + + Enum.count(query.aliases) == 3 -> + from([a, o, b3, b4] in query, group_by: [a.id, o.id, b3.id, b4.id]) + + Enum.count(query.aliases) == 2 -> + from([a, o, b3] in query, group_by: [a.id, o.id, b3.id]) + + true -> + from([a, o] in query, group_by: [a.id, o.id]) + end + end + + defp maybe_restrict_hashtag_any(query, []) do + query + end + + defp maybe_restrict_hashtag_any(query, tags) do + having( + query, + [hashtag: hashtag], + fragment("array_agg(?) && (?)", hashtag.name, ^tags) + ) + end + + defp maybe_restrict_hashtag_all(query, []) do + query + end + + defp maybe_restrict_hashtag_all(query, tags) do + having( + query, + [hashtag: hashtag], + fragment("array_agg(?) @> (?)", hashtag.name, ^tags) + ) + end + + defp maybe_restrict_hashtag_reject_any(query, []) do + query + end + + defp maybe_restrict_hashtag_reject_any(query, tags) do + having( + query, + [hashtag: hashtag], + fragment("not(array_agg(?) && (?))", hashtag.name, ^tags) + ) + end + defp restrict_hashtag_reject_any(_query, %{tag_reject: _tag_reject, skip_preload: true}) do raise_on_missing_preload() end defp restrict_hashtag_reject_any(query, %{tag_reject: tags_reject}) when is_list(tags_reject) do - if has_named_binding?(query, :thread_mute) do - from( - [activity, object, thread_mute] in query, - group_by: [activity.id, object.id, thread_mute.id] - ) - else - from( - [activity, object] in query, - group_by: [activity.id, object.id] - ) - end + query + |> group_by_all_bindings() |> join(:left, [_activity, object], hashtag in assoc(object, :hashtags), as: :hashtag) |> having( [hashtag: hashtag], @@ -1167,7 +1237,6 @@ def fetch_activities_query(recipients, opts \\ %{}) do query = Activity - |> distinct([a], true) |> maybe_preload_objects(opts) |> maybe_preload_bookmarks(opts) |> maybe_preload_report_notes(opts) @@ -1199,16 +1268,23 @@ def fetch_activities_query(recipients, opts \\ %{}) do |> exclude_invisible_actors(opts) |> exclude_visibility(opts) - if Config.object_embedded_hashtags?() do - query - |> restrict_tag(opts) - |> restrict_tag_reject(opts) - |> restrict_tag_all(opts) - else - query - |> restrict_hashtag_any(opts) - |> restrict_hashtag_all(opts) - |> restrict_hashtag_reject_any(opts) + cond do + Config.object_embedded_hashtags?() -> + query + |> restrict_tag(opts) + |> restrict_tag_reject(opts) + |> restrict_tag_all(opts) + + # TODO: benchmark (initial approach preferring non-aggregate ops when possible) + Config.get([:instance, :improved_hashtag_timeline]) == :join -> + query + |> distinct([activity], true) + |> restrict_hashtag_any(opts) + |> restrict_hashtag_all(opts) + |> restrict_hashtag_reject_any(opts) + + true -> + restrict_hashtag(query, opts) end end From 0d521022fe6157ce9a346c6915ce38292e653bb3 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Thu, 7 Jan 2021 12:20:29 +0300 Subject: [PATCH 019/339] [#3213] Removed PK from hashtags_objects table. Improved hashtags_transfer mix task (logging of failed ids). --- lib/mix/tasks/pleroma/database.ex | 29 +++++++++++-------- lib/pleroma/object.ex | 4 +-- ...20201221203824_create_hashtags_objects.exs | 2 +- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index f903cf75b..918752dc2 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -139,6 +139,7 @@ def run(["transfer_hashtags"]) do Logger.info("Starting transferring object embedded hashtags to `hashtags` table...") + # Note: most objects have Mention-type AS2 tags and no hashtags (but we can't filter them out) from( object in Object, left_join: hashtag in assoc(object, :hashtags), @@ -153,13 +154,10 @@ def run(["transfer_hashtags"]) do |> Stream.each(fn objects -> Logger.info("Processing #{length(objects)} objects starting from id #{hd(objects).id}...") - Enum.map( - objects, - fn object -> - hashtags = - object.tag - |> Jason.decode!() - |> Enum.filter(&is_bitstring(&1)) + failed_ids = + objects + |> Enum.map(fn object -> + hashtags = Object.object_data_hashtags(%{"tag" => Jason.decode!(object.tag)}) Repo.transaction(fn -> with {:ok, hashtag_records} <- Hashtag.get_or_create_by_names(hashtags) do @@ -169,7 +167,7 @@ def run(["transfer_hashtags"]) do "insert into hashtags_objects(hashtag_id, object_id) values ($1, $2);", [hashtag_record.id, object.id] ) do - :noop + nil else {:error, e} -> error = @@ -177,18 +175,25 @@ def run(["transfer_hashtags"]) do "#{hashtag_record.id}: #{inspect(e)}" Logger.error(error) - Repo.rollback(error) + Repo.rollback(object.id) end end + + object.id else e -> error = "ERROR: could not create hashtags for object #{object.id}: #{inspect(e)}" Logger.error(error) - Repo.rollback(error) + Repo.rollback(object.id) end end) - end - ) + end) + |> Enum.filter(&(elem(&1, 0) == :error)) + |> Enum.map(&elem(&1, 1)) + + if Enum.any?(failed_ids) do + Logger.error("ERROR: transfer_hashtags iteration failed for ids: #{inspect(failed_ids)}") + end end) |> Stream.run() diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index 7e79e15ee..61f2ffa19 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -404,7 +404,7 @@ defp embedded_hashtags(%Object{data: data}) do defp embedded_hashtags(_), do: [] - defp object_data_hashtags(%{"tag" => tags}) when is_list(tags) do + def object_data_hashtags(%{"tag" => tags}) when is_list(tags) do tags |> Enum.filter(fn %{"type" => "Hashtag"} = data -> Map.has_key?(data, "name") @@ -419,5 +419,5 @@ defp object_data_hashtags(%{"tag" => tags}) when is_list(tags) do |> Enum.uniq() end - defp object_data_hashtags(_), do: [] + def object_data_hashtags(_), do: [] end diff --git a/priv/repo/migrations/20201221203824_create_hashtags_objects.exs b/priv/repo/migrations/20201221203824_create_hashtags_objects.exs index b2649b4fb..214ea81c3 100644 --- a/priv/repo/migrations/20201221203824_create_hashtags_objects.exs +++ b/priv/repo/migrations/20201221203824_create_hashtags_objects.exs @@ -2,7 +2,7 @@ defmodule Pleroma.Repo.Migrations.CreateHashtagsObjects do use Ecto.Migration def change do - create_if_not_exists table(:hashtags_objects) do + create_if_not_exists table(:hashtags_objects, primary_key: false) do add(:hashtag_id, references(:hashtags), null: false) add(:object_id, references(:objects), null: false) end From 8c972de0457199098c5f3378313d08a9dd2d64ce Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Sun, 10 Jan 2021 11:44:39 +0300 Subject: [PATCH 020/339] [#3213] transfer_hashtags mix task refactoring. --- lib/mix/tasks/pleroma/database.ex | 127 ++++++++++++++---------------- 1 file changed, 59 insertions(+), 68 deletions(-) diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index 918752dc2..e9686fc1b 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -132,74 +132,6 @@ def run(["fix_likes_collections"]) do |> Stream.run() end - def run(["transfer_hashtags"]) do - import Ecto.Query - - start_pleroma() - - Logger.info("Starting transferring object embedded hashtags to `hashtags` table...") - - # Note: most objects have Mention-type AS2 tags and no hashtags (but we can't filter them out) - from( - object in Object, - left_join: hashtag in assoc(object, :hashtags), - where: is_nil(hashtag.id), - where: fragment("(?)->>'tag' != '[]'", object.data), - select: %{ - id: object.id, - tag: fragment("(?)->>'tag'", object.data) - } - ) - |> Repo.chunk_stream(100, :batches, timeout: :infinity) - |> Stream.each(fn objects -> - Logger.info("Processing #{length(objects)} objects starting from id #{hd(objects).id}...") - - failed_ids = - objects - |> Enum.map(fn object -> - hashtags = Object.object_data_hashtags(%{"tag" => Jason.decode!(object.tag)}) - - Repo.transaction(fn -> - with {:ok, hashtag_records} <- Hashtag.get_or_create_by_names(hashtags) do - for hashtag_record <- hashtag_records do - with {:ok, _} <- - Repo.query( - "insert into hashtags_objects(hashtag_id, object_id) values ($1, $2);", - [hashtag_record.id, object.id] - ) do - nil - else - {:error, e} -> - error = - "ERROR: could not link object #{object.id} and hashtag " <> - "#{hashtag_record.id}: #{inspect(e)}" - - Logger.error(error) - Repo.rollback(object.id) - end - end - - object.id - else - e -> - error = "ERROR: could not create hashtags for object #{object.id}: #{inspect(e)}" - Logger.error(error) - Repo.rollback(object.id) - end - end) - end) - |> Enum.filter(&(elem(&1, 0) == :error)) - |> Enum.map(&elem(&1, 1)) - - if Enum.any?(failed_ids) do - Logger.error("ERROR: transfer_hashtags iteration failed for ids: #{inspect(failed_ids)}") - end - end) - |> Stream.run() - - Logger.info("Done transferring hashtags. Please check logs to ensure no errors.") - end - def run(["vacuum", args]) do start_pleroma() @@ -239,4 +171,63 @@ def run(["ensure_expiration"]) do end) |> Stream.run() end + + def run(["transfer_hashtags"]) do + import Ecto.Query + + start_pleroma() + + Logger.info("Starting transferring object embedded hashtags to `hashtags` table...") + + # Note: most objects have Mention-type AS2 tags and no hashtags (but we can't filter them out) + from( + object in Object, + left_join: hashtag in assoc(object, :hashtags), + where: is_nil(hashtag.id), + where: + fragment("(?)->'tag' IS NOT NULL AND (?)->'tag' != '[]'::jsonb", object.data, object.data), + select: %{ + id: object.id, + tag: fragment("(?)->'tag'", object.data) + } + ) + |> Repo.chunk_stream(100, :one, timeout: :infinity) + |> Stream.each(&transfer_object_hashtags(&1)) + |> Stream.run() + + Logger.info("Done transferring hashtags. Please check logs to ensure no errors.") + end + + defp transfer_object_hashtags(object) do + hashtags = Object.object_data_hashtags(%{"tag" => object.tag}) + + Repo.transaction(fn -> + with {:ok, hashtag_records} <- Hashtag.get_or_create_by_names(hashtags) do + for hashtag_record <- hashtag_records do + with {:ok, _} <- + Repo.query( + "insert into hashtags_objects(hashtag_id, object_id) values ($1, $2);", + [hashtag_record.id, object.id] + ) do + nil + else + {:error, e} -> + error = + "ERROR: could not link object #{object.id} and hashtag " <> + "#{hashtag_record.id}: #{inspect(e)}" + + Logger.error(error) + Repo.rollback(object.id) + end + end + + object.id + else + e -> + error = "ERROR: could not create hashtags for object #{object.id}: #{inspect(e)}" + Logger.error(error) + Repo.rollback(object.id) + end + end) + end end From 3e4d84729a4ca8d9779d439a9aa2c8c23b3acd1d Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Wed, 13 Jan 2021 22:07:38 +0300 Subject: [PATCH 021/339] [#3213] Prototype of data migrations functionality / HashtagsTableMigrator. --- lib/mix/tasks/pleroma/database.ex | 60 ----- lib/pleroma/application.ex | 3 +- lib/pleroma/config.ex | 4 +- lib/pleroma/data_migration.ex | 46 ++++ lib/pleroma/delivery.ex | 1 - lib/pleroma/ecto_enums.ex | 8 + .../migrators/hashtags_table_migrator.ex | 211 ++++++++++++++++++ lib/pleroma/web/activity_pub/activity_pub.ex | 2 +- .../20210105195018_create_data_migrations.exs | 17 ++ ...gration_create_populate_hashtags_table.exs | 14 ++ ...72254_create_data_migration_failed_ids.exs | 14 ++ 11 files changed, 316 insertions(+), 64 deletions(-) create mode 100644 lib/pleroma/data_migration.ex create mode 100644 lib/pleroma/migrators/hashtags_table_migrator.ex create mode 100644 priv/repo/migrations/20210105195018_create_data_migrations.exs create mode 100644 priv/repo/migrations/20210106183301_data_migration_create_populate_hashtags_table.exs create mode 100644 priv/repo/migrations/20210111172254_create_data_migration_failed_ids.exs diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index e9686fc1b..08ede9eef 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -4,7 +4,6 @@ defmodule Mix.Tasks.Pleroma.Database do alias Pleroma.Conversation - alias Pleroma.Hashtag alias Pleroma.Maintenance alias Pleroma.Object alias Pleroma.Repo @@ -171,63 +170,4 @@ def run(["ensure_expiration"]) do end) |> Stream.run() end - - def run(["transfer_hashtags"]) do - import Ecto.Query - - start_pleroma() - - Logger.info("Starting transferring object embedded hashtags to `hashtags` table...") - - # Note: most objects have Mention-type AS2 tags and no hashtags (but we can't filter them out) - from( - object in Object, - left_join: hashtag in assoc(object, :hashtags), - where: is_nil(hashtag.id), - where: - fragment("(?)->'tag' IS NOT NULL AND (?)->'tag' != '[]'::jsonb", object.data, object.data), - select: %{ - id: object.id, - tag: fragment("(?)->'tag'", object.data) - } - ) - |> Repo.chunk_stream(100, :one, timeout: :infinity) - |> Stream.each(&transfer_object_hashtags(&1)) - |> Stream.run() - - Logger.info("Done transferring hashtags. Please check logs to ensure no errors.") - end - - defp transfer_object_hashtags(object) do - hashtags = Object.object_data_hashtags(%{"tag" => object.tag}) - - Repo.transaction(fn -> - with {:ok, hashtag_records} <- Hashtag.get_or_create_by_names(hashtags) do - for hashtag_record <- hashtag_records do - with {:ok, _} <- - Repo.query( - "insert into hashtags_objects(hashtag_id, object_id) values ($1, $2);", - [hashtag_record.id, object.id] - ) do - nil - else - {:error, e} -> - error = - "ERROR: could not link object #{object.id} and hashtag " <> - "#{hashtag_record.id}: #{inspect(e)}" - - Logger.error(error) - Repo.rollback(object.id) - end - end - - object.id - else - e -> - error = "ERROR: could not create hashtags for object #{object.id}: #{inspect(e)}" - Logger.error(error) - Repo.rollback(object.id) - end - end) - end end diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index bd568d858..962529dfd 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -104,7 +104,8 @@ def start(_type, _args) do chat_child(chat_enabled?()) ++ [ Pleroma.Web.Endpoint, - Pleroma.Gopher.Server + Pleroma.Gopher.Server, + Pleroma.Migrators.HashtagsTableMigrator ] # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html diff --git a/lib/pleroma/config.ex b/lib/pleroma/config.ex index ee0167f4e..dbfb114d6 100644 --- a/lib/pleroma/config.ex +++ b/lib/pleroma/config.ex @@ -96,7 +96,9 @@ def restrict_unauthenticated_access?(resource, kind) do end end - def object_embedded_hashtags?, do: !get([:instance, :improved_hashtag_timeline]) + def improved_hashtag_timeline_path, do: [:instance, :improved_hashtag_timeline] + def improved_hashtag_timeline, do: get(improved_hashtag_timeline_path()) + def object_embedded_hashtags?, do: !improved_hashtag_timeline() def oauth_consumer_strategies, do: get([:auth, :oauth_consumer_strategies], []) diff --git a/lib/pleroma/data_migration.ex b/lib/pleroma/data_migration.ex new file mode 100644 index 000000000..64fa155ff --- /dev/null +++ b/lib/pleroma/data_migration.ex @@ -0,0 +1,46 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.DataMigration do + use Ecto.Schema + + alias Pleroma.DataMigration + alias Pleroma.DataMigration.State + alias Pleroma.Repo + + import Ecto.Changeset + + schema "data_migrations" do + field(:name, :string) + field(:state, State, default: :pending) + field(:feature_lock, :boolean, default: false) + field(:params, :map, default: %{}) + field(:data, :map, default: %{}) + + timestamps() + end + + def changeset(data_migration, params \\ %{}) do + data_migration + |> cast(params, [:name, :state, :feature_lock, :params, :data]) + |> validate_required([:name]) + |> unique_constraint(:name) + end + + def update(data_migration, params \\ %{}) do + data_migration + |> changeset(params) + |> Repo.update() + end + + def update_state(data_migration, new_state) do + update(data_migration, %{state: new_state}) + end + + def get_by_name(name) do + Repo.get_by(DataMigration, name: name) + end + + def populate_hashtags_table, do: get_by_name("populate_hashtags_table") +end diff --git a/lib/pleroma/delivery.ex b/lib/pleroma/delivery.ex index 0ded2855c..baf79dda7 100644 --- a/lib/pleroma/delivery.ex +++ b/lib/pleroma/delivery.ex @@ -9,7 +9,6 @@ defmodule Pleroma.Delivery do alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User - alias Pleroma.User import Ecto.Changeset import Ecto.Query diff --git a/lib/pleroma/ecto_enums.ex b/lib/pleroma/ecto_enums.ex index 6fc47620c..f0ae658a4 100644 --- a/lib/pleroma/ecto_enums.ex +++ b/lib/pleroma/ecto_enums.ex @@ -17,3 +17,11 @@ follow_accept: 2, follow_reject: 3 ) + +defenum(Pleroma.DataMigration.State, + pending: 1, + running: 2, + complete: 3, + failed: 4, + manual: 5 +) diff --git a/lib/pleroma/migrators/hashtags_table_migrator.ex b/lib/pleroma/migrators/hashtags_table_migrator.ex new file mode 100644 index 000000000..a7e3de542 --- /dev/null +++ b/lib/pleroma/migrators/hashtags_table_migrator.ex @@ -0,0 +1,211 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Migrators.HashtagsTableMigrator do + defmodule State do + use Agent + + @init_state %{} + + def start_link(_) do + Agent.start_link(fn -> @init_state end, name: __MODULE__) + end + + def get do + Agent.get(__MODULE__, & &1) + end + + def put(key, value) do + Agent.update(__MODULE__, fn state -> + Map.put(state, key, value) + end) + end + + def increment(key, increment \\ 1) do + Agent.update(__MODULE__, fn state -> + updated_value = (state[key] || 0) + increment + Map.put(state, key, updated_value) + end) + end + end + + use GenServer + + require Logger + + import Ecto.Query + + alias Pleroma.Config + alias Pleroma.DataMigration + alias Pleroma.Hashtag + alias Pleroma.Object + alias Pleroma.Repo + + defdelegate state(), to: State, as: :get + defdelegate put_state(key, value), to: State, as: :put + defdelegate increment_state(key, increment), to: State, as: :increment + + defdelegate data_migration(), to: DataMigration, as: :populate_hashtags_table + + def start_link(_) do + GenServer.start_link(__MODULE__, nil, name: __MODULE__) + end + + @impl true + def init(_) do + {:ok, nil, {:continue, :init_state}} + end + + @impl true + def handle_continue(:init_state, _state) do + {:ok, _} = State.start_link(nil) + + put_state(:status, :init) + + dm = data_migration() + + cond do + Config.get(:env) == :test -> + put_state(:status, :noop) + + is_nil(dm) -> + put_state(:status, :halt) + put_state(:message, "Data migration does not exist.") + + dm.state == :manual -> + put_state(:status, :noop) + put_state(:message, "Data migration is in manual execution state.") + + dm.state == :complete -> + handle_success() + + true -> + send(self(), :migrate_hashtags) + end + + {:noreply, nil} + end + + @impl true + def handle_info(:migrate_hashtags, state) do + data_migration = data_migration() + + {:ok, data_migration} = DataMigration.update_state(data_migration, :running) + put_state(:status, :running) + + Logger.info("Starting transferring object embedded hashtags to `hashtags` table...") + + max_processed_id = data_migration.data["max_processed_id"] || 0 + + # Note: most objects have Mention-type AS2 tags and no hashtags (but we can't filter them out) + from( + object in Object, + left_join: hashtag in assoc(object, :hashtags), + where: object.id > ^max_processed_id, + where: is_nil(hashtag.id), + where: + fragment("(?)->'tag' IS NOT NULL AND (?)->'tag' != '[]'::jsonb", object.data, object.data), + select: %{ + id: object.id, + tag: fragment("(?)->'tag'", object.data) + } + ) + |> Repo.chunk_stream(100, :batches, timeout: :infinity) + |> Stream.each(fn objects -> + object_ids = Enum.map(objects, & &1.id) + + failed_ids = + objects + |> Enum.map(&transfer_object_hashtags(&1)) + |> Enum.filter(&(elem(&1, 0) == :error)) + |> Enum.map(&elem(&1, 1)) + + for failed_id <- failed_ids do + _ = + Repo.query( + "INSERT INTO data_migration_failed_ids(data_migration_id, record_id) " <> + "VALUES ($1, $2) ON CONFLICT DO NOTHING;", + [data_migration.id, failed_id] + ) + end + + _ = + Repo.query( + "DELETE FROM data_migration_failed_ids WHERE id = ANY($1)", + [object_ids -- failed_ids] + ) + + max_object_id = Enum.at(object_ids, -1) + _ = DataMigration.update(data_migration, %{data: %{"max_processed_id" => max_object_id}}) + + increment_state(:processed_count, length(object_ids)) + increment_state(:failed_count, length(failed_ids)) + + # A quick and dirty approach to controlling the load this background migration imposes + sleep_interval = Config.get([:populate_hashtags_table, :sleep_interval_ms], 0) + Process.sleep(sleep_interval) + end) + |> Stream.run() + + with {:ok, %{rows: [[0]]}} <- + Repo.query( + "SELECT COUNT(record_id) FROM data_migration_failed_ids WHERE data_migration_id = $1;", + [data_migration.id] + ) do + put_state(:status, :complete) + _ = DataMigration.update_state(data_migration, :complete) + + handle_success() + else + _ -> + put_state(:status, :failed) + put_state(:message, "Please check data_migration_failed_ids records.") + end + + {:noreply, state} + end + + defp transfer_object_hashtags(object) do + hashtags = Object.object_data_hashtags(%{"tag" => object.tag}) + + Repo.transaction(fn -> + with {:ok, hashtag_records} <- Hashtag.get_or_create_by_names(hashtags) do + for hashtag_record <- hashtag_records do + with {:ok, _} <- + Repo.query( + "insert into hashtags_objects(hashtag_id, object_id) values ($1, $2);", + [hashtag_record.id, object.id] + ) do + nil + else + {:error, e} -> + error = + "ERROR: could not link object #{object.id} and hashtag " <> + "#{hashtag_record.id}: #{inspect(e)}" + + Logger.error(error) + Repo.rollback(object.id) + end + end + + object.id + else + e -> + error = "ERROR: could not create hashtags for object #{object.id}: #{inspect(e)}" + Logger.error(error) + Repo.rollback(object.id) + end + end) + end + + defp handle_success do + put_state(:status, :complete) + + unless Config.improved_hashtag_timeline() do + Config.put(Config.improved_hashtag_timeline_path(), true) + end + + :ok + end +end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 339843330..6131ae85b 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1276,7 +1276,7 @@ def fetch_activities_query(recipients, opts \\ %{}) do |> restrict_tag_all(opts) # TODO: benchmark (initial approach preferring non-aggregate ops when possible) - Config.get([:instance, :improved_hashtag_timeline]) == :join -> + Config.improved_hashtag_timeline() == :join -> query |> distinct([activity], true) |> restrict_hashtag_any(opts) diff --git a/priv/repo/migrations/20210105195018_create_data_migrations.exs b/priv/repo/migrations/20210105195018_create_data_migrations.exs new file mode 100644 index 000000000..5f2e8d96c --- /dev/null +++ b/priv/repo/migrations/20210105195018_create_data_migrations.exs @@ -0,0 +1,17 @@ +defmodule Pleroma.Repo.Migrations.CreateDataMigrations do + use Ecto.Migration + + def change do + create_if_not_exists table(:data_migrations) do + add(:name, :string, null: false) + add(:state, :integer, default: 1) + add(:feature_lock, :boolean, default: false) + add(:params, :map, default: %{}) + add(:data, :map, default: %{}) + + timestamps() + end + + create_if_not_exists(unique_index(:data_migrations, [:name])) + end +end diff --git a/priv/repo/migrations/20210106183301_data_migration_create_populate_hashtags_table.exs b/priv/repo/migrations/20210106183301_data_migration_create_populate_hashtags_table.exs new file mode 100644 index 000000000..2a965f075 --- /dev/null +++ b/priv/repo/migrations/20210106183301_data_migration_create_populate_hashtags_table.exs @@ -0,0 +1,14 @@ +defmodule Pleroma.Repo.Migrations.DataMigrationCreatePopulateHashtagsTable do + use Ecto.Migration + + def up do + dt = NaiveDateTime.utc_now() + + execute( + "INSERT INTO data_migrations(name, inserted_at, updated_at) " <> + "VALUES ('populate_hashtags_table', '#{dt}', '#{dt}') ON CONFLICT DO NOTHING;" + ) + end + + def down, do: :ok +end diff --git a/priv/repo/migrations/20210111172254_create_data_migration_failed_ids.exs b/priv/repo/migrations/20210111172254_create_data_migration_failed_ids.exs new file mode 100644 index 000000000..ba0be98af --- /dev/null +++ b/priv/repo/migrations/20210111172254_create_data_migration_failed_ids.exs @@ -0,0 +1,14 @@ +defmodule Pleroma.Repo.Migrations.CreateDataMigrationFailedIds do + use Ecto.Migration + + def change do + create_if_not_exists table(:data_migration_failed_ids, primary_key: false) do + add(:data_migration_id, references(:data_migrations), null: false) + add(:record_id, :bigint, null: false) + end + + create_if_not_exists( + unique_index(:data_migration_failed_ids, [:data_migration_id, :record_id]) + ) + end +end From f5f267fa764f53ef617bc9504c7ecb68b5d3d7ab Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Thu, 14 Jan 2021 22:41:27 +0300 Subject: [PATCH 022/339] [#3213] Refactoring of HashtagsTableMigrator. --- .../migrators/hashtags_table_migrator.ex | 98 ++++++++++--------- .../hashtags_table_migrator/state.ex | 26 +++++ .../20190711042021_create_safe_jsonb_set.exs | 2 +- 3 files changed, 79 insertions(+), 47 deletions(-) create mode 100644 lib/pleroma/migrators/hashtags_table_migrator/state.ex diff --git a/lib/pleroma/migrators/hashtags_table_migrator.ex b/lib/pleroma/migrators/hashtags_table_migrator.ex index a7e3de542..9f1a00f9c 100644 --- a/lib/pleroma/migrators/hashtags_table_migrator.ex +++ b/lib/pleroma/migrators/hashtags_table_migrator.ex @@ -3,39 +3,13 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Migrators.HashtagsTableMigrator do - defmodule State do - use Agent - - @init_state %{} - - def start_link(_) do - Agent.start_link(fn -> @init_state end, name: __MODULE__) - end - - def get do - Agent.get(__MODULE__, & &1) - end - - def put(key, value) do - Agent.update(__MODULE__, fn state -> - Map.put(state, key, value) - end) - end - - def increment(key, increment \\ 1) do - Agent.update(__MODULE__, fn state -> - updated_value = (state[key] || 0) + increment - Map.put(state, key, updated_value) - end) - end - end - use GenServer require Logger import Ecto.Query + alias __MODULE__.State alias Pleroma.Config alias Pleroma.DataMigration alias Pleroma.Hashtag @@ -43,13 +17,23 @@ def increment(key, increment \\ 1) do alias Pleroma.Repo defdelegate state(), to: State, as: :get - defdelegate put_state(key, value), to: State, as: :put - defdelegate increment_state(key, increment), to: State, as: :increment + defdelegate put_stat(key, value), to: State, as: :put + defdelegate increment_stat(key, increment), to: State, as: :increment defdelegate data_migration(), to: DataMigration, as: :populate_hashtags_table + @reg_name {:global, __MODULE__} + + def whereis, do: GenServer.whereis(@reg_name) + def start_link(_) do - GenServer.start_link(__MODULE__, nil, name: __MODULE__) + case whereis() do + nil -> + GenServer.start_link(__MODULE__, nil, name: @reg_name) + + pid -> + {:ok, pid} + end end @impl true @@ -61,21 +45,22 @@ def init(_) do def handle_continue(:init_state, _state) do {:ok, _} = State.start_link(nil) - put_state(:status, :init) + put_stat(:status, :init) dm = data_migration() + manual_migrations = Config.get([:instance, :manual_data_migrations], []) cond do Config.get(:env) == :test -> - put_state(:status, :noop) + put_stat(:status, :noop) is_nil(dm) -> - put_state(:status, :halt) - put_state(:message, "Data migration does not exist.") + put_stat(:status, :halt) + put_stat(:message, "Data migration does not exist.") - dm.state == :manual -> - put_state(:status, :noop) - put_state(:message, "Data migration is in manual execution state.") + dm.state == :manual or dm.name in manual_migrations -> + put_stat(:status, :noop) + put_stat(:message, "Data migration is in manual execution state.") dm.state == :complete -> handle_success() @@ -91,8 +76,12 @@ def handle_continue(:init_state, _state) do def handle_info(:migrate_hashtags, state) do data_migration = data_migration() - {:ok, data_migration} = DataMigration.update_state(data_migration, :running) - put_state(:status, :running) + persistent_data = Map.take(data_migration.data, ["max_processed_id"]) + + {:ok, data_migration} = + DataMigration.update(data_migration, %{state: :running, data: persistent_data}) + + put_stat(:status, :running) Logger.info("Starting transferring object embedded hashtags to `hashtags` table...") @@ -137,10 +126,12 @@ def handle_info(:migrate_hashtags, state) do ) max_object_id = Enum.at(object_ids, -1) - _ = DataMigration.update(data_migration, %{data: %{"max_processed_id" => max_object_id}}) - increment_state(:processed_count, length(object_ids)) - increment_state(:failed_count, length(failed_ids)) + put_stat(:max_processed_id, max_object_id) + increment_stat(:processed_count, length(object_ids)) + increment_stat(:failed_count, length(failed_ids)) + + persist_stats(data_migration) # A quick and dirty approach to controlling the load this background migration imposes sleep_interval = Config.get([:populate_hashtags_table, :sleep_interval_ms], 0) @@ -153,14 +144,15 @@ def handle_info(:migrate_hashtags, state) do "SELECT COUNT(record_id) FROM data_migration_failed_ids WHERE data_migration_id = $1;", [data_migration.id] ) do - put_state(:status, :complete) _ = DataMigration.update_state(data_migration, :complete) handle_success() else _ -> - put_state(:status, :failed) - put_state(:message, "Please check data_migration_failed_ids records.") + _ = DataMigration.update_state(data_migration, :failed) + + put_stat(:status, :failed) + put_stat(:message, "Please check data_migration_failed_ids records.") end {:noreply, state} @@ -199,8 +191,13 @@ defp transfer_object_hashtags(object) do end) end + defp persist_stats(data_migration) do + runner_state = Map.drop(state(), [:status]) + _ = DataMigration.update(data_migration, %{data: runner_state}) + end + defp handle_success do - put_state(:status, :complete) + put_stat(:status, :complete) unless Config.improved_hashtag_timeline() do Config.put(Config.improved_hashtag_timeline_path(), true) @@ -208,4 +205,13 @@ defp handle_success do :ok end + + def force_continue do + send(whereis(), :migrate_hashtags) + end + + def force_restart do + {:ok, _} = DataMigration.update(data_migration(), %{state: :pending, data: %{}}) + force_continue() + end end diff --git a/lib/pleroma/migrators/hashtags_table_migrator/state.ex b/lib/pleroma/migrators/hashtags_table_migrator/state.ex new file mode 100644 index 000000000..79926892c --- /dev/null +++ b/lib/pleroma/migrators/hashtags_table_migrator/state.ex @@ -0,0 +1,26 @@ +defmodule Pleroma.Migrators.HashtagsTableMigrator.State do + use Agent + + @init_state %{} + + def start_link(_) do + Agent.start_link(fn -> @init_state end, name: __MODULE__) + end + + def get do + Agent.get(__MODULE__, & &1) + end + + def put(key, value) do + Agent.update(__MODULE__, fn state -> + Map.put(state, key, value) + end) + end + + def increment(key, increment \\ 1) do + Agent.update(__MODULE__, fn state -> + updated_value = (state[key] || 0) + increment + Map.put(state, key, updated_value) + end) + end +end diff --git a/priv/repo/migrations/20190711042021_create_safe_jsonb_set.exs b/priv/repo/migrations/20190711042021_create_safe_jsonb_set.exs index 43d616705..bfac09f9e 100644 --- a/priv/repo/migrations/20190711042021_create_safe_jsonb_set.exs +++ b/priv/repo/migrations/20190711042021_create_safe_jsonb_set.exs @@ -9,7 +9,7 @@ def change do begin result := jsonb_set(target, path, coalesce(new_value, 'null'::jsonb), create_missing); if result is NULL then - raise 'jsonb_set tried to wipe the object, please report this incindent to Pleroma bug tracker. https://git.pleroma.social/pleroma/pleroma/issues/new'; + raise 'jsonb_set tried to wipe the object, please report this incident to Pleroma bug tracker. https://git.pleroma.social/pleroma/pleroma/issues/new'; return target; else return result; From 48b399cedb7d46ea0f08181cfbe4df222861f65b Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Sat, 16 Jan 2021 20:22:14 +0300 Subject: [PATCH 023/339] [#3213] Refactoring of HashtagsTableMigrator. Hashtag timeline performance optimization (auto switch to non-aggregate join strategy when efficient). --- CHANGELOG.md | 1 + config/description.exs | 6 ++ .../migrators/hashtags_table_migrator.ex | 47 +++++++----- .../hashtags_table_migrator/state.ex | 9 +-- lib/pleroma/web/activity_pub/activity_pub.ex | 72 +++++++++++-------- .../web/activity_pub/activity_pub_test.exs | 4 +- 6 files changed, 86 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25b24bf07..9a053156f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Search: When using Postgres 11+, Pleroma will use the `websearch_to_tsvector` function to parse search queries. - Emoji: Support the full Unicode 13.1 set of Emoji for reactions, plus regional indicators. - Admin API: Reports now ordered by newest +- Extracted object hashtags into separate table in order to improve hashtag timeline performance (via background migration in `Pleroma.Migrators.HashtagsTableMigrator`). ### Added diff --git a/config/description.exs b/config/description.exs index f438a88ab..c73d50f7d 100644 --- a/config/description.exs +++ b/config/description.exs @@ -941,6 +941,12 @@ key: :show_reactions, type: :boolean, description: "Let favourites and emoji reactions be viewed through the API." + }, + %{ + key: :improved_hashtag_timeline, + type: :keyword, + description: + "If `true` / `:prefer_aggregation` / `:avoid_aggregation`, hashtags table and selected strategy will be used for hashtags timeline. When `false`, object-embedded hashtags will be used (slower). Is auto-set to `true` (unless overridden) when HashtagsTableMigrator completes." } ] }, diff --git a/lib/pleroma/migrators/hashtags_table_migrator.ex b/lib/pleroma/migrators/hashtags_table_migrator.ex index 9f1a00f9c..b40578d50 100644 --- a/lib/pleroma/migrators/hashtags_table_migrator.ex +++ b/lib/pleroma/migrators/hashtags_table_migrator.ex @@ -45,25 +45,23 @@ def init(_) do def handle_continue(:init_state, _state) do {:ok, _} = State.start_link(nil) - put_stat(:status, :init) + update_status(:init) - dm = data_migration() + data_migration = data_migration() manual_migrations = Config.get([:instance, :manual_data_migrations], []) cond do Config.get(:env) == :test -> - put_stat(:status, :noop) + update_status(:noop) - is_nil(dm) -> - put_stat(:status, :halt) - put_stat(:message, "Data migration does not exist.") + is_nil(data_migration) -> + update_status(:halt, "Data migration does not exist.") - dm.state == :manual or dm.name in manual_migrations -> - put_stat(:status, :noop) - put_stat(:message, "Data migration is in manual execution state.") + data_migration.state == :manual or data_migration.name in manual_migrations -> + update_status(:noop, "Data migration is in manual execution state.") - dm.state == :complete -> - handle_success() + data_migration.state == :complete -> + handle_success(data_migration) true -> send(self(), :migrate_hashtags) @@ -81,7 +79,7 @@ def handle_info(:migrate_hashtags, state) do {:ok, data_migration} = DataMigration.update(data_migration, %{state: :running, data: persistent_data}) - put_stat(:status, :running) + update_status(:running) Logger.info("Starting transferring object embedded hashtags to `hashtags` table...") @@ -146,13 +144,12 @@ def handle_info(:migrate_hashtags, state) do ) do _ = DataMigration.update_state(data_migration, :complete) - handle_success() + handle_success(data_migration) else _ -> _ = DataMigration.update_state(data_migration, :failed) - put_stat(:status, :failed) - put_stat(:message, "Please check data_migration_failed_ids records.") + update_status(:failed, "Please check data_migration_failed_ids records.") end {:noreply, state} @@ -196,16 +193,25 @@ defp persist_stats(data_migration) do _ = DataMigration.update(data_migration, %{data: runner_state}) end - defp handle_success do - put_stat(:status, :complete) + defp handle_success(data_migration) do + update_status(:complete) - unless Config.improved_hashtag_timeline() do + unless data_migration.feature_lock || Config.improved_hashtag_timeline() do Config.put(Config.improved_hashtag_timeline_path(), true) end :ok end + def failed_objects_query do + from(o in Object) + |> join(:inner, [o], dmf in fragment("SELECT * FROM data_migration_failed_ids"), + on: dmf.record_id == o.id + ) + |> where([_o, dmf], dmf.data_migration_id == ^data_migration().id) + |> order_by([o], asc: o.id) + end + def force_continue do send(whereis(), :migrate_hashtags) end @@ -214,4 +220,9 @@ def force_restart do {:ok, _} = DataMigration.update(data_migration(), %{state: :pending, data: %{}}) force_continue() end + + defp update_status(status, message \\ nil) do + put_stat(:status, status) + put_stat(:message, message) + end end diff --git a/lib/pleroma/migrators/hashtags_table_migrator/state.ex b/lib/pleroma/migrators/hashtags_table_migrator/state.ex index 79926892c..c1a2709fc 100644 --- a/lib/pleroma/migrators/hashtags_table_migrator/state.ex +++ b/lib/pleroma/migrators/hashtags_table_migrator/state.ex @@ -2,23 +2,24 @@ defmodule Pleroma.Migrators.HashtagsTableMigrator.State do use Agent @init_state %{} + @reg_name {:global, __MODULE__} def start_link(_) do - Agent.start_link(fn -> @init_state end, name: __MODULE__) + Agent.start_link(fn -> @init_state end, name: @reg_name) end def get do - Agent.get(__MODULE__, & &1) + Agent.get(@reg_name, & &1) end def put(key, value) do - Agent.update(__MODULE__, fn state -> + Agent.update(@reg_name, fn state -> Map.put(state, key, value) end) end def increment(key, increment \\ 1) do - Agent.update(__MODULE__, fn state -> + Agent.update(@reg_name, fn state -> updated_value = (state[key] || 0) + increment Map.put(state, key, updated_value) end) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index f5563b0fd..0609827ec 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -669,63 +669,66 @@ defp restrict_since(query, %{since_id: since_id}) do defp restrict_since(query, _), do: query - defp restrict_tag_reject(_query, %{tag_reject: _tag_reject, skip_preload: true}) do + defp restrict_embedded_tag_reject(_query, %{tag_reject: _tag_reject, skip_preload: true}) do raise_on_missing_preload() end - defp restrict_tag_reject(query, %{tag_reject: tag_reject}) when is_list(tag_reject) do + defp restrict_embedded_tag_reject(query, %{tag_reject: tag_reject}) when is_list(tag_reject) do from( [_activity, object] in query, where: fragment("not (?)->'tag' \\?| (?)", object.data, ^tag_reject) ) end - defp restrict_tag_reject(query, %{tag_reject: tag_reject}) when is_binary(tag_reject) do - restrict_tag_reject(query, %{tag_reject: [tag_reject]}) + defp restrict_embedded_tag_reject(query, %{tag_reject: tag_reject}) + when is_binary(tag_reject) do + restrict_embedded_tag_reject(query, %{tag_reject: [tag_reject]}) end - defp restrict_tag_reject(query, _), do: query + defp restrict_embedded_tag_reject(query, _), do: query - defp restrict_tag_all(_query, %{tag_all: _tag_all, skip_preload: true}) do + defp restrict_embedded_tag_all(_query, %{tag_all: _tag_all, skip_preload: true}) do raise_on_missing_preload() end - defp restrict_tag_all(query, %{tag_all: tag_all}) when is_list(tag_all) do + defp restrict_embedded_tag_all(query, %{tag_all: tag_all}) when is_list(tag_all) do from( [_activity, object] in query, where: fragment("(?)->'tag' \\?& (?)", object.data, ^tag_all) ) end - defp restrict_tag_all(query, %{tag_all: tag}) when is_binary(tag) do - restrict_tag(query, %{tag: tag}) + defp restrict_embedded_tag_all(query, %{tag_all: tag}) when is_binary(tag) do + restrict_embedded_tag(query, %{tag: tag}) end - defp restrict_tag_all(query, _), do: query + defp restrict_embedded_tag_all(query, _), do: query - defp restrict_tag(_query, %{tag: _tag, skip_preload: true}) do + defp restrict_embedded_tag(_query, %{tag: _tag, skip_preload: true}) do raise_on_missing_preload() end - defp restrict_tag(query, %{tag: tag}) when is_list(tag) do + defp restrict_embedded_tag(query, %{tag: tag}) when is_list(tag) do from( [_activity, object] in query, where: fragment("(?)->'tag' \\?| (?)", object.data, ^tag) ) end - defp restrict_tag(query, %{tag: tag}) when is_binary(tag) do - restrict_tag(query, %{tag: [tag]}) + defp restrict_embedded_tag(query, %{tag: tag}) when is_binary(tag) do + restrict_embedded_tag(query, %{tag: [tag]}) end - defp restrict_tag(query, _), do: query + defp restrict_embedded_tag(query, _), do: query - defp restrict_hashtag(query, opts) do - [tag_any, tag_all, tag_reject] = - [:tag, :tag_all, :tag_reject] - |> Enum.map(&opts[&1]) - |> Enum.map(&List.wrap(&1)) + defp hashtag_conditions(opts) do + [:tag, :tag_all, :tag_reject] + |> Enum.map(&opts[&1]) + |> Enum.map(&List.wrap(&1)) + end + defp restrict_hashtag_agg(query, opts) do + [tag_any, tag_all, tag_reject] = hashtag_conditions(opts) has_conditions = Enum.any?([tag_any, tag_all, tag_reject], &Enum.any?(&1)) cond do @@ -1275,15 +1278,19 @@ def fetch_activities_query(recipients, opts \\ %{}) do |> exclude_invisible_actors(opts) |> exclude_visibility(opts) - cond do - Config.object_embedded_hashtags?() -> - query - |> restrict_tag(opts) - |> restrict_tag_reject(opts) - |> restrict_tag_all(opts) + hashtag_timeline_strategy = Config.improved_hashtag_timeline() - # TODO: benchmark (initial approach preferring non-aggregate ops when possible) - Config.improved_hashtag_timeline() == :join -> + cond do + !hashtag_timeline_strategy -> + query + |> restrict_embedded_tag(opts) + |> restrict_embedded_tag_reject(opts) + |> restrict_embedded_tag_all(opts) + + hashtag_timeline_strategy == :prefer_aggregation -> + restrict_hashtag_agg(query, opts) + + hashtag_timeline_strategy == :avoid_aggregation or avoid_hashtags_aggregation?(opts) -> query |> distinct([activity], true) |> restrict_hashtag_any(opts) @@ -1291,10 +1298,17 @@ def fetch_activities_query(recipients, opts \\ %{}) do |> restrict_hashtag_reject_any(opts) true -> - restrict_hashtag(query, opts) + restrict_hashtag_agg(query, opts) end end + defp avoid_hashtags_aggregation?(opts) do + [tag_any, tag_all, tag_reject] = hashtag_conditions(opts) + + joins_count = length(tag_all) + if Enum.any?(tag_any), do: 1, else: 0 + Enum.empty?(tag_reject) and joins_count <= 2 + end + def fetch_activities(recipients, opts \\ %{}, pagination \\ :keyset) do list_memberships = Pleroma.List.memberships(opts[:user]) diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index f86d0a265..36fd65c76 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -217,8 +217,8 @@ test "it fetches the appropriate tag-restricted posts" do {:ok, status_two} = CommonAPI.post(user, %{status: ". #essais"}) {:ok, status_three} = CommonAPI.post(user, %{status: ". #test #reject"}) - for new_timeline_enabled <- [true, false] do - clear_config([:instance, :improved_hashtag_timeline], new_timeline_enabled) + for hashtag_timeline_strategy <- [true, :prefer_aggregation, :avoid_aggregation, false] do + clear_config([:instance, :improved_hashtag_timeline], hashtag_timeline_strategy) fetch_one = ActivityPub.fetch_activities([], %{type: "Create", tag: "test"}) From 85f7ef4d13adea9d64d279d1395d17c6ebc20678 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Sun, 17 Jan 2021 10:57:06 +0300 Subject: [PATCH 024/339] [#3213] Feature lock adjustment for HashtagsTableMigrator. --- lib/pleroma/migrators/hashtags_table_migrator.ex | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/migrators/hashtags_table_migrator.ex b/lib/pleroma/migrators/hashtags_table_migrator.ex index b40578d50..47de5e134 100644 --- a/lib/pleroma/migrators/hashtags_table_migrator.ex +++ b/lib/pleroma/migrators/hashtags_table_migrator.ex @@ -196,11 +196,17 @@ defp persist_stats(data_migration) do defp handle_success(data_migration) do update_status(:complete) - unless data_migration.feature_lock || Config.improved_hashtag_timeline() do - Config.put(Config.improved_hashtag_timeline_path(), true) - end + cond do + data_migration.feature_lock -> + :noop - :ok + not is_nil(Config.improved_hashtag_timeline()) -> + :noop + + true -> + Config.put(Config.improved_hashtag_timeline_path(), true) + :ok + end end def failed_objects_query do From 9d28a7ebfbc7bd8fb893cf1e2ad555ed71f4c812 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Sun, 17 Jan 2021 21:58:15 +0300 Subject: [PATCH 025/339] [#3213] Missing copyright header for HashtagsTableMigrator.State. --- lib/pleroma/migrators/hashtags_table_migrator/state.ex | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/pleroma/migrators/hashtags_table_migrator/state.ex b/lib/pleroma/migrators/hashtags_table_migrator/state.ex index c1a2709fc..43f7270e2 100644 --- a/lib/pleroma/migrators/hashtags_table_migrator/state.ex +++ b/lib/pleroma/migrators/hashtags_table_migrator/state.ex @@ -1,3 +1,7 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + defmodule Pleroma.Migrators.HashtagsTableMigrator.State do use Agent From 7f07909a7b56eb368b3f8aab4752def1551c12fe Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Tue, 19 Jan 2021 21:13:32 +0300 Subject: [PATCH 026/339] [#3213] Added `HashtagsTableMigrator.count/1`. --- .../migrators/hashtags_table_migrator.ex | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/migrators/hashtags_table_migrator.ex b/lib/pleroma/migrators/hashtags_table_migrator.ex index 47de5e134..048f3c8ee 100644 --- a/lib/pleroma/migrators/hashtags_table_migrator.ex +++ b/lib/pleroma/migrators/hashtags_table_migrator.ex @@ -85,19 +85,8 @@ def handle_info(:migrate_hashtags, state) do max_processed_id = data_migration.data["max_processed_id"] || 0 - # Note: most objects have Mention-type AS2 tags and no hashtags (but we can't filter them out) - from( - object in Object, - left_join: hashtag in assoc(object, :hashtags), - where: object.id > ^max_processed_id, - where: is_nil(hashtag.id), - where: - fragment("(?)->'tag' IS NOT NULL AND (?)->'tag' != '[]'::jsonb", object.data, object.data), - select: %{ - id: object.id, - tag: fragment("(?)->'tag'", object.data) - } - ) + query() + |> where([object], object.id > ^max_processed_id) |> Repo.chunk_stream(100, :batches, timeout: :infinity) |> Stream.each(fn objects -> object_ids = Enum.map(objects, & &1.id) @@ -155,6 +144,21 @@ def handle_info(:migrate_hashtags, state) do {:noreply, state} end + defp query do + # Note: most objects have Mention-type AS2 tags and no hashtags (but we can't filter them out) + from( + object in Object, + left_join: hashtag in assoc(object, :hashtags), + where: is_nil(hashtag.id), + where: + fragment("(?)->'tag' IS NOT NULL AND (?)->'tag' != '[]'::jsonb", object.data, object.data), + select: %{ + id: object.id, + tag: fragment("(?)->'tag'", object.data) + } + ) + end + defp transfer_object_hashtags(object) do hashtags = Object.object_data_hashtags(%{"tag" => object.tag}) @@ -188,6 +192,18 @@ defp transfer_object_hashtags(object) do end) end + def count(force \\ false) do + stored_count = state()[:count] + + if stored_count && !force do + stored_count + else + count = Repo.aggregate(query(), :count, :id) + put_stat(:count, count) + count + end + end + defp persist_stats(data_migration) do runner_state = Map.drop(state(), [:status]) _ = DataMigration.update(data_migration, %{data: runner_state}) From f0f0f2af00e8b73a7013c1308289795961b23f4b Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Tue, 19 Jan 2021 21:17:06 +0300 Subject: [PATCH 027/339] [#3213] `timeout` option for `HashtagsTableMigrator.count/_`. --- lib/pleroma/migrators/hashtags_table_migrator.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/migrators/hashtags_table_migrator.ex b/lib/pleroma/migrators/hashtags_table_migrator.ex index 048f3c8ee..6a6a7b5b8 100644 --- a/lib/pleroma/migrators/hashtags_table_migrator.ex +++ b/lib/pleroma/migrators/hashtags_table_migrator.ex @@ -192,13 +192,13 @@ defp transfer_object_hashtags(object) do end) end - def count(force \\ false) do + def count(force \\ false, timeout \\ :infinity) do stored_count = state()[:count] if stored_count && !force do stored_count else - count = Repo.aggregate(query(), :count, :id) + count = Repo.aggregate(query(), :count, :id, timeout: timeout) put_stat(:count, count) count end From b830605577f369d6b1a8730a5b3476ceea4fef5a Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Tue, 19 Jan 2021 22:03:25 +0300 Subject: [PATCH 028/339] [#3213] Performance-related stat in HashtagsTableMigrator. Reworked `count/_` to indicate approximate total count for current iteration. --- lib/pleroma/migrators/hashtags_table_migrator.ex | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/migrators/hashtags_table_migrator.ex b/lib/pleroma/migrators/hashtags_table_migrator.ex index 6a6a7b5b8..e9dd9b70c 100644 --- a/lib/pleroma/migrators/hashtags_table_migrator.ex +++ b/lib/pleroma/migrators/hashtags_table_migrator.ex @@ -80,6 +80,7 @@ def handle_info(:migrate_hashtags, state) do DataMigration.update(data_migration, %{state: :running, data: persistent_data}) update_status(:running) + put_stat(:started_at, NaiveDateTime.utc_now()) Logger.info("Starting transferring object embedded hashtags to `hashtags` table...") @@ -118,6 +119,12 @@ def handle_info(:migrate_hashtags, state) do increment_stat(:processed_count, length(object_ids)) increment_stat(:failed_count, length(failed_ids)) + put_stat( + :records_per_second, + state()[:processed_count] / + Enum.max([NaiveDateTime.diff(NaiveDateTime.utc_now(), state()[:started_at]), 1]) + ) + persist_stats(data_migration) # A quick and dirty approach to controlling the load this background migration imposes @@ -192,13 +199,18 @@ defp transfer_object_hashtags(object) do end) end + @doc "Approximate count for current iteration (including processed records count)" def count(force \\ false, timeout \\ :infinity) do stored_count = state()[:count] if stored_count && !force do stored_count else - count = Repo.aggregate(query(), :count, :id, timeout: timeout) + processed_count = state()[:processed_count] || 0 + max_processed_id = data_migration().data["max_processed_id"] || 0 + query = where(query(), [object], object.id > ^max_processed_id) + + count = Repo.aggregate(query, :count, :id, timeout: timeout) + processed_count put_stat(:count, count) count end From c041e9c6300726a40a00146bba04d3ec752219d9 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Thu, 21 Jan 2021 20:19:09 +0300 Subject: [PATCH 029/339] [#3213] HashtagsTableMigrator: failures handling fix, retry function. Changed default hashtags filtering strategy to non-aggregate approach. --- config/description.exs | 2 +- .../migrators/hashtags_table_migrator.ex | 52 +++++++++++++++---- lib/pleroma/web/activity_pub/activity_pub.ex | 13 +---- .../web/activity_pub/activity_pub_test.exs | 2 +- 4 files changed, 47 insertions(+), 22 deletions(-) diff --git a/config/description.exs b/config/description.exs index b48616b22..46f085c70 100644 --- a/config/description.exs +++ b/config/description.exs @@ -940,7 +940,7 @@ key: :improved_hashtag_timeline, type: :keyword, description: - "If `true` / `:prefer_aggregation` / `:avoid_aggregation`, hashtags table and selected strategy will be used for hashtags timeline. When `false`, object-embedded hashtags will be used (slower). Is auto-set to `true` (unless overridden) when HashtagsTableMigrator completes." + "If `true` / `:prefer_aggregation`, hashtags table and selected strategy will be used for hashtags timeline. When `false`, object-embedded hashtags will be used (slower). Is auto-set to `true` (unless overridden) when HashtagsTableMigrator completes." } ] }, diff --git a/lib/pleroma/migrators/hashtags_table_migrator.ex b/lib/pleroma/migrators/hashtags_table_migrator.ex index e9dd9b70c..8ad2c8c73 100644 --- a/lib/pleroma/migrators/hashtags_table_migrator.ex +++ b/lib/pleroma/migrators/hashtags_table_migrator.ex @@ -109,8 +109,9 @@ def handle_info(:migrate_hashtags, state) do _ = Repo.query( - "DELETE FROM data_migration_failed_ids WHERE id = ANY($1)", - [object_ids -- failed_ids] + "DELETE FROM data_migration_failed_ids " <> + "WHERE data_migration_id = $1 AND record_id = ANY($2)", + [data_migration.id, object_ids -- failed_ids] ) max_object_id = Enum.at(object_ids, -1) @@ -133,12 +134,8 @@ def handle_info(:migrate_hashtags, state) do end) |> Stream.run() - with {:ok, %{rows: [[0]]}} <- - Repo.query( - "SELECT COUNT(record_id) FROM data_migration_failed_ids WHERE data_migration_id = $1;", - [data_migration.id] - ) do - _ = DataMigration.update_state(data_migration, :complete) + with 0 <- failures_count(data_migration.id) do + {:ok, data_migration} = DataMigration.update_state(data_migration, :complete) handle_success(data_migration) else @@ -167,7 +164,8 @@ defp query do end defp transfer_object_hashtags(object) do - hashtags = Object.object_data_hashtags(%{"tag" => object.tag}) + embedded_tags = (Map.has_key?(object, :tag) && object.tag) || object.data["tag"] + hashtags = Object.object_data_hashtags(%{"tag" => embedded_tags}) Repo.transaction(fn -> with {:ok, hashtag_records} <- Hashtag.get_or_create_by_names(hashtags) do @@ -246,6 +244,36 @@ def failed_objects_query do |> order_by([o], asc: o.id) end + def failures_count(data_migration_id \\ nil) do + data_migration_id = data_migration_id || data_migration().id + + with {:ok, %{rows: [[count]]}} <- + Repo.query( + "SELECT COUNT(record_id) FROM data_migration_failed_ids WHERE data_migration_id = $1;", + [data_migration_id] + ) do + count + end + end + + def retry_failed do + data_migration = data_migration() + + failed_objects_query() + |> Repo.chunk_stream(100, :one) + |> Stream.each(fn object -> + with {:ok, _} <- transfer_object_hashtags(object) do + _ = + Repo.query( + "DELETE FROM data_migration_failed_ids " <> + "WHERE data_migration_id = $1 AND record_id = $2", + [data_migration.id, object.id] + ) + end + end) + |> Stream.run() + end + def force_continue do send(whereis(), :migrate_hashtags) end @@ -255,6 +283,12 @@ def force_restart do force_continue() end + def force_complete do + {:ok, data_migration} = DataMigration.update_state(data_migration(), :complete) + + handle_success(data_migration) + end + defp update_status(status, message \\ nil) do put_stat(:status, status) put_stat(:message, message) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 0609827ec..dbfd3839d 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -727,6 +727,7 @@ defp hashtag_conditions(opts) do |> Enum.map(&List.wrap(&1)) end + # Note: times out on larger instances (with default timeout), intended for complex queries defp restrict_hashtag_agg(query, opts) do [tag_any, tag_all, tag_reject] = hashtag_conditions(opts) has_conditions = Enum.any?([tag_any, tag_all, tag_reject], &Enum.any?(&1)) @@ -1290,25 +1291,15 @@ def fetch_activities_query(recipients, opts \\ %{}) do hashtag_timeline_strategy == :prefer_aggregation -> restrict_hashtag_agg(query, opts) - hashtag_timeline_strategy == :avoid_aggregation or avoid_hashtags_aggregation?(opts) -> + true -> query |> distinct([activity], true) |> restrict_hashtag_any(opts) |> restrict_hashtag_all(opts) |> restrict_hashtag_reject_any(opts) - - true -> - restrict_hashtag_agg(query, opts) end end - defp avoid_hashtags_aggregation?(opts) do - [tag_any, tag_all, tag_reject] = hashtag_conditions(opts) - - joins_count = length(tag_all) + if Enum.any?(tag_any), do: 1, else: 0 - Enum.empty?(tag_reject) and joins_count <= 2 - end - def fetch_activities(recipients, opts \\ %{}, pagination \\ :keyset) do list_memberships = Pleroma.List.memberships(opts[:user]) diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index 36fd65c76..1fcaf74d3 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -217,7 +217,7 @@ test "it fetches the appropriate tag-restricted posts" do {:ok, status_two} = CommonAPI.post(user, %{status: ". #essais"}) {:ok, status_three} = CommonAPI.post(user, %{status: ". #test #reject"}) - for hashtag_timeline_strategy <- [true, :prefer_aggregation, :avoid_aggregation, false] do + for hashtag_timeline_strategy <- [true, :prefer_aggregation, false] do clear_config([:instance, :improved_hashtag_timeline], hashtag_timeline_strategy) fetch_one = ActivityPub.fetch_activities([], %{type: "Create", tag: "test"}) From ca7f24064304945587fc232325dce4b834ff6c94 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Thu, 21 Jan 2021 20:50:06 +0300 Subject: [PATCH 030/339] [#3213] Ignoring of blank elements from objects.data->tag. --- lib/pleroma/object.ex | 2 ++ test/pleroma/hashtag_test.exs | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 test/pleroma/hashtag_test.exs diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index 5102be1de..9b5c1bec1 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -420,6 +420,8 @@ def object_data_hashtags(%{"tag" => tags}) when is_list(tags) do hashtag when is_bitstring(hashtag) -> String.downcase(hashtag) end) |> Enum.uniq() + # Note: "" elements (plain text) might occur in `data.tag` for incoming objects + |> Enum.filter(&(&1 not in [nil, ""])) end def object_data_hashtags(_), do: [] diff --git a/test/pleroma/hashtag_test.exs b/test/pleroma/hashtag_test.exs new file mode 100644 index 000000000..0264dea0b --- /dev/null +++ b/test/pleroma/hashtag_test.exs @@ -0,0 +1,17 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HashtagTest do + use Pleroma.DataCase + + alias Pleroma.Hashtag + + describe "changeset validations" do + test "ensure non-blank :name" do + changeset = Hashtag.changeset(%Hashtag{}, %{name: ""}) + + assert {:name, {"can't be blank", [validation: :required]}} in changeset.errors + end + end +end From f264d930cc00c463d0f506a94f6f6b494aab7022 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Sun, 24 Jan 2021 23:27:02 +0300 Subject: [PATCH 031/339] [#3213] Speedup of HashtagsTableMigrator (query optimization). State handling fix. --- .../migrators/hashtags_table_migrator.ex | 18 +++++++++++++++--- .../migrators/hashtags_table_migrator/state.ex | 4 ++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/migrators/hashtags_table_migrator.ex b/lib/pleroma/migrators/hashtags_table_migrator.ex index 8ad2c8c73..6a1c9592c 100644 --- a/lib/pleroma/migrators/hashtags_table_migrator.ex +++ b/lib/pleroma/migrators/hashtags_table_migrator.ex @@ -72,6 +72,8 @@ def handle_continue(:init_state, _state) do @impl true def handle_info(:migrate_hashtags, state) do + State.clear() + data_migration = data_migration() persistent_data = Map.take(data_migration.data, ["max_processed_id"]) @@ -152,8 +154,6 @@ defp query do # Note: most objects have Mention-type AS2 tags and no hashtags (but we can't filter them out) from( object in Object, - left_join: hashtag in assoc(object, :hashtags), - where: is_nil(hashtag.id), where: fragment("(?)->'tag' IS NOT NULL AND (?)->'tag' != '[]'::jsonb", object.data, object.data), select: %{ @@ -161,12 +161,24 @@ defp query do tag: fragment("(?)->'tag'", object.data) } ) + |> join(:left, [o], hashtags_objects in fragment("SELECT object_id FROM hashtags_objects"), + on: hashtags_objects.object_id == o.id + ) + |> where([_o, hashtags_objects], is_nil(hashtags_objects.object_id)) end defp transfer_object_hashtags(object) do - embedded_tags = (Map.has_key?(object, :tag) && object.tag) || object.data["tag"] + embedded_tags = if Map.has_key?(object, :tag), do: object.tag, else: object.data["tag"] hashtags = Object.object_data_hashtags(%{"tag" => embedded_tags}) + if Enum.any?(hashtags) do + transfer_object_hashtags(object, hashtags) + else + {:ok, object.id} + end + end + + defp transfer_object_hashtags(object, hashtags) do Repo.transaction(fn -> with {:ok, hashtag_records} <- Hashtag.get_or_create_by_names(hashtags) do for hashtag_record <- hashtag_records do diff --git a/lib/pleroma/migrators/hashtags_table_migrator/state.ex b/lib/pleroma/migrators/hashtags_table_migrator/state.ex index 43f7270e2..901563426 100644 --- a/lib/pleroma/migrators/hashtags_table_migrator/state.ex +++ b/lib/pleroma/migrators/hashtags_table_migrator/state.ex @@ -12,6 +12,10 @@ def start_link(_) do Agent.start_link(fn -> @init_state end, name: @reg_name) end + def clear do + Agent.update(@reg_name, fn _state -> @init_state end) + end + def get do Agent.get(@reg_name, & &1) end From ea4785213a449f3bcd68bcb4ecb3bb6d794736b1 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Mon, 25 Jan 2021 20:12:09 +0300 Subject: [PATCH 032/339] [#3213] Switched to using embedded hashtags in Object.hashtags/1 (to avoid extra joins / preload in timeline queries). --- lib/pleroma/config.ex | 1 - lib/pleroma/object.ex | 18 +++++------------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/lib/pleroma/config.ex b/lib/pleroma/config.ex index ceb8c8b5a..0a6ac0ad0 100644 --- a/lib/pleroma/config.ex +++ b/lib/pleroma/config.ex @@ -98,7 +98,6 @@ def restrict_unauthenticated_access?(resource, kind) do def improved_hashtag_timeline_path, do: [:instance, :improved_hashtag_timeline] def improved_hashtag_timeline, do: get(improved_hashtag_timeline_path()) - def object_embedded_hashtags?, do: !improved_hashtag_timeline() def oauth_consumer_strategies, do: get([:auth, :oauth_consumer_strategies], []) diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index 9b5c1bec1..9edf43e04 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -388,24 +388,16 @@ def tags(%Object{data: %{"tag" => tags}}) when is_list(tags), do: tags def tags(_), do: [] def hashtags(%Object{} = object) do - cond do - Config.object_embedded_hashtags?() -> - embedded_hashtags(object) - - object.id == "pleroma:fake_object_id" -> - [] - - true -> - hashtag_records = Repo.preload(object, :hashtags).hashtags - Enum.map(hashtag_records, & &1.name) - end + # Note: always using embedded hashtags regardless whether they are migrated to hashtags table + # (embedded hashtags stay in sync anyways, and we avoid extra joins and preload hassle) + embedded_hashtags(object) end - defp embedded_hashtags(%Object{data: data}) do + def embedded_hashtags(%Object{data: data}) do object_data_hashtags(data) end - defp embedded_hashtags(_), do: [] + def embedded_hashtags(_), do: [] def object_data_hashtags(%{"tag" => tags}) when is_list(tags) do tags From e7864a32d7c9930e5f6c62bd77cef64c68f1eb21 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Mon, 25 Jan 2021 22:31:23 +0300 Subject: [PATCH 033/339] [#3213] Removed DISTINCT clause from ActivityPub.fetch_activities_query/2. --- lib/pleroma/web/activity_pub/activity_pub.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index fbda89a25..be81e0833 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1293,7 +1293,6 @@ def fetch_activities_query(recipients, opts \\ %{}) do true -> query - |> distinct([activity], true) |> restrict_hashtag_any(opts) |> restrict_hashtag_all(opts) |> restrict_hashtag_reject_any(opts) From 380d0cce6b802baf4d13031a4a39f169dd65bffd Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Fri, 29 Jan 2021 00:17:33 +0300 Subject: [PATCH 034/339] [#3213] Reinstated DISTINCT clause for hashtag "any" filtering with 2+ terms. Added test. --- lib/pleroma/web/activity_pub/activity_pub.ex | 17 ++++++++++++----- .../controllers/timeline_controller.ex | 2 +- .../web/activity_pub/activity_pub_test.exs | 17 +++++++++++++++-- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index be81e0833..0a21ac2f2 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -846,11 +846,18 @@ defp restrict_hashtag_any(_query, %{tag: _tag, skip_preload: true}) do end defp restrict_hashtag_any(query, %{tag: tags}) when is_list(tags) do - from( - [_activity, object] in query, - join: hashtag in assoc(object, :hashtags), - where: hashtag.name in ^tags - ) + query = + from( + [_activity, object] in query, + join: hashtag in assoc(object, :hashtags), + where: hashtag.name in ^tags + ) + + if length(tags) > 1 do + distinct(query, [activity], true) + else + query + end end defp restrict_hashtag_any(query, %{tag: tag}) when is_binary(tag) do diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 08e6f23b9..1fb954a9b 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -134,9 +134,9 @@ defp hashtag_fetching(params, user, local_only) do tags = [params[:tag], params[:any]] |> List.flatten() - |> Enum.uniq() |> Enum.reject(&is_nil/1) |> Enum.map(&String.downcase/1) + |> Enum.uniq() tag_all = params diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index 1fcaf74d3..0b18269cd 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -217,6 +217,9 @@ test "it fetches the appropriate tag-restricted posts" do {:ok, status_two} = CommonAPI.post(user, %{status: ". #essais"}) {:ok, status_three} = CommonAPI.post(user, %{status: ". #test #reject"}) + {:ok, status_four} = CommonAPI.post(user, %{status: ". #any1 #any2"}) + {:ok, status_five} = CommonAPI.post(user, %{status: ". #any2 #any1"}) + for hashtag_timeline_strategy <- [true, :prefer_aggregation, false] do clear_config([:instance, :improved_hashtag_timeline], hashtag_timeline_strategy) @@ -238,8 +241,17 @@ test "it fetches the appropriate tag-restricted posts" do tag_all: ["test", "reject"] }) - [fetch_one, fetch_two, fetch_three, fetch_four] = - Enum.map([fetch_one, fetch_two, fetch_three, fetch_four], fn statuses -> + # This test would fail if JOIN with 2+ terms in "any" clause is done without DISTINCT. + # The :limit is important (w/o DISTINCT 2 records are deduped by Ecto to 1 b/c of preload). + fetch_five = + ActivityPub.fetch_activities([], %{ + type: "Create", + tag: ["any1", "any2"], + limit: 2 + }) + + [fetch_one, fetch_two, fetch_three, fetch_four, fetch_five] = + Enum.map([fetch_one, fetch_two, fetch_three, fetch_four, fetch_five], fn statuses -> Enum.map(statuses, fn s -> Repo.preload(s, object: :hashtags) end) end) @@ -247,6 +259,7 @@ test "it fetches the appropriate tag-restricted posts" do assert fetch_two == [status_one, status_two, status_three] assert fetch_three == [status_one, status_two] assert fetch_four == [status_three] + assert fetch_five == [status_four, status_five] end end From 9948ff3356f9e9e214584207a53eba614c73383c Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Sun, 31 Jan 2021 18:24:19 +0300 Subject: [PATCH 035/339] [#3213] Added HashtagsCleanupWorker periodic job. --- config/config.exs | 2 + config/description.exs | 1 + .../migrators/hashtags_table_migrator.ex | 1 + lib/pleroma/object.ex | 1 + .../workers/cron/hashtags_cleanup_worker.ex | 57 +++++++++++++++++++ 5 files changed, 62 insertions(+) create mode 100644 lib/pleroma/workers/cron/hashtags_cleanup_worker.ex diff --git a/config/config.exs b/config/config.exs index c4a690799..dfd2fc434 100644 --- a/config/config.exs +++ b/config/config.exs @@ -553,10 +553,12 @@ remote_fetcher: 2, attachments_cleanup: 1, new_users_digest: 1, + hashtags_cleanup: 1, mute_expire: 5 ], plugins: [Oban.Plugins.Pruner], crontab: [ + {"0 1 * * *", Pleroma.Workers.Cron.HashtagsCleanupWorker}, {"0 0 * * 0", Pleroma.Workers.Cron.DigestEmailsWorker}, {"0 0 * * *", Pleroma.Workers.Cron.NewUsersDigestWorker} ] diff --git a/config/description.exs b/config/description.exs index 46f085c70..147c1930c 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1943,6 +1943,7 @@ type: {:list, :tuple}, description: "Settings for cron background jobs", suggestions: [ + {"0 1 * * *", Pleroma.Workers.Cron.HashtagsCleanupWorker}, {"0 0 * * 0", Pleroma.Workers.Cron.DigestEmailsWorker}, {"0 0 * * *", Pleroma.Workers.Cron.NewUsersDigestWorker} ] diff --git a/lib/pleroma/migrators/hashtags_table_migrator.ex b/lib/pleroma/migrators/hashtags_table_migrator.ex index 6a1c9592c..07b42a7f4 100644 --- a/lib/pleroma/migrators/hashtags_table_migrator.ex +++ b/lib/pleroma/migrators/hashtags_table_migrator.ex @@ -152,6 +152,7 @@ def handle_info(:migrate_hashtags, state) do defp query do # Note: most objects have Mention-type AS2 tags and no hashtags (but we can't filter them out) + # Note: not checking activity type; HashtagsCleanupWorker should clean up unused records later from( object in Object, where: diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index 9edf43e04..52b77e41c 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -65,6 +65,7 @@ def change(struct, params \\ %{}) do |> maybe_handle_hashtags_change(struct) end + # Note: not checking activity type; HashtagsCleanupWorker should clean up unused records later defp maybe_handle_hashtags_change(changeset, struct) do with data_hashtags_change = get_change(changeset, :data), true <- hashtags_changed?(struct, data_hashtags_change), diff --git a/lib/pleroma/workers/cron/hashtags_cleanup_worker.ex b/lib/pleroma/workers/cron/hashtags_cleanup_worker.ex new file mode 100644 index 000000000..b319067ca --- /dev/null +++ b/lib/pleroma/workers/cron/hashtags_cleanup_worker.ex @@ -0,0 +1,57 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.Cron.HashtagsCleanupWorker do + @moduledoc """ + The worker to clean up unused hashtags_objects and hashtags. + """ + + use Oban.Worker, queue: "hashtags_cleanup" + + alias Pleroma.Repo + + require Logger + + @hashtags_objects_query """ + DELETE FROM hashtags_objects WHERE object_id IN + (SELECT DISTINCT objects.id FROM objects + JOIN hashtags_objects ON hashtags_objects.object_id = objects.id LEFT JOIN activities + ON COALESCE(activities.data->'object'->>'id', activities.data->>'object') = + (objects.data->>'id') + AND activities.data->>'type' = 'Create' + WHERE activities.id IS NULL); + """ + + @hashtags_query """ + DELETE FROM hashtags WHERE id IN + (SELECT hashtags.id FROM hashtags + LEFT OUTER JOIN hashtags_objects + ON hashtags_objects.hashtag_id = hashtags.id + WHERE hashtags_objects.hashtag_id IS NULL AND hashtags.inserted_at < $1); + """ + + @impl Oban.Worker + def perform(_job) do + Logger.info("Cleaning up unused `hashtags_objects` records...") + + {:ok, %{num_rows: hashtags_objects_count}} = + Repo.query(@hashtags_objects_query, [], timeout: :infinity) + + Logger.info("Deleted #{hashtags_objects_count} unused `hashtags_objects` records.") + + Logger.info("Cleaning up unused `hashtags` records...") + + # Note: ignoring recently created hashtags since references are added after hashtag is created + {:ok, %{num_rows: hashtags_count}} = + Repo.query(@hashtags_query, [NaiveDateTime.add(NaiveDateTime.utc_now(), -3600 * 24)], + timeout: :infinity + ) + + Logger.info("Deleted #{hashtags_count} unused `hashtags` records.") + + Logger.info("HashtagsCleanupWorker complete.") + + :ok + end +end From 6fd4163ab60be07b1a20ac8911e105ddca8e2095 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Sun, 31 Jan 2021 20:37:33 +0300 Subject: [PATCH 036/339] [#3213] ActivityPub: implemented subqueries-based hashtags filtering, removed aggregation-based hashtags filtering. --- config/description.exs | 2 +- lib/pleroma/web/activity_pub/activity_pub.ex | 228 ++++++------------ .../web/activity_pub/activity_pub_test.exs | 5 +- 3 files changed, 81 insertions(+), 154 deletions(-) diff --git a/config/description.exs b/config/description.exs index 147c1930c..ead541724 100644 --- a/config/description.exs +++ b/config/description.exs @@ -940,7 +940,7 @@ key: :improved_hashtag_timeline, type: :keyword, description: - "If `true` / `:prefer_aggregation`, hashtags table and selected strategy will be used for hashtags timeline. When `false`, object-embedded hashtags will be used (slower). Is auto-set to `true` (unless overridden) when HashtagsTableMigrator completes." + "If `true`, hashtags will be fetched from `hashtags` table for hashtags timeline. When `false`, object-embedded hashtags will be used (slower). Is auto-set to `true` (unless overridden) when HashtagsTableMigrator completes." } ] }, diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 0a21ac2f2..cda8d3f54 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -669,24 +669,6 @@ defp restrict_since(query, %{since_id: since_id}) do defp restrict_since(query, _), do: query - defp restrict_embedded_tag_reject(_query, %{tag_reject: _tag_reject, skip_preload: true}) do - raise_on_missing_preload() - end - - defp restrict_embedded_tag_reject(query, %{tag_reject: tag_reject}) when is_list(tag_reject) do - from( - [_activity, object] in query, - where: fragment("not (?)->'tag' \\?| (?)", object.data, ^tag_reject) - ) - end - - defp restrict_embedded_tag_reject(query, %{tag_reject: tag_reject}) - when is_binary(tag_reject) do - restrict_embedded_tag_reject(query, %{tag_reject: [tag_reject]}) - end - - defp restrict_embedded_tag_reject(query, _), do: query - defp restrict_embedded_tag_all(_query, %{tag_all: _tag_all, skip_preload: true}) do raise_on_missing_preload() end @@ -699,139 +681,65 @@ defp restrict_embedded_tag_all(query, %{tag_all: tag_all}) when is_list(tag_all) end defp restrict_embedded_tag_all(query, %{tag_all: tag}) when is_binary(tag) do - restrict_embedded_tag(query, %{tag: tag}) + restrict_embedded_tag_any(query, %{tag: tag}) end defp restrict_embedded_tag_all(query, _), do: query - defp restrict_embedded_tag(_query, %{tag: _tag, skip_preload: true}) do + defp restrict_embedded_tag_any(_query, %{tag: _tag, skip_preload: true}) do raise_on_missing_preload() end - defp restrict_embedded_tag(query, %{tag: tag}) when is_list(tag) do + defp restrict_embedded_tag_any(query, %{tag: tag}) when is_list(tag) do from( [_activity, object] in query, where: fragment("(?)->'tag' \\?| (?)", object.data, ^tag) ) end - defp restrict_embedded_tag(query, %{tag: tag}) when is_binary(tag) do - restrict_embedded_tag(query, %{tag: [tag]}) + defp restrict_embedded_tag_any(query, %{tag: tag}) when is_binary(tag) do + restrict_embedded_tag_any(query, %{tag: [tag]}) end - defp restrict_embedded_tag(query, _), do: query + defp restrict_embedded_tag_any(query, _), do: query - defp hashtag_conditions(opts) do - [:tag, :tag_all, :tag_reject] - |> Enum.map(&opts[&1]) - |> Enum.map(&List.wrap(&1)) - end - - # Note: times out on larger instances (with default timeout), intended for complex queries - defp restrict_hashtag_agg(query, opts) do - [tag_any, tag_all, tag_reject] = hashtag_conditions(opts) - has_conditions = Enum.any?([tag_any, tag_all, tag_reject], &Enum.any?(&1)) - - cond do - !has_conditions -> - query - - opts[:skip_preload] -> - raise_on_missing_preload() - - true -> - query - |> group_by_all_bindings() - |> join(:left, [_activity, object], hashtag in assoc(object, :hashtags), as: :hashtag) - |> maybe_restrict_hashtag_any(tag_any) - |> maybe_restrict_hashtag_all(tag_all) - |> maybe_restrict_hashtag_reject_any(tag_reject) - end - end - - # Groups by all bindings to allow aggregation on hashtags - defp group_by_all_bindings(query) do - # Expecting named bindings: :object, :bookmark, :thread_mute, :report_note - cond do - Enum.count(query.aliases) == 4 -> - from([a, o, b3, b4, b5] in query, group_by: [a.id, o.id, b3.id, b4.id, b5.id]) - - Enum.count(query.aliases) == 3 -> - from([a, o, b3, b4] in query, group_by: [a.id, o.id, b3.id, b4.id]) - - Enum.count(query.aliases) == 2 -> - from([a, o, b3] in query, group_by: [a.id, o.id, b3.id]) - - true -> - from([a, o] in query, group_by: [a.id, o.id]) - end - end - - defp maybe_restrict_hashtag_any(query, []) do - query - end - - defp maybe_restrict_hashtag_any(query, tags) do - having( - query, - [hashtag: hashtag], - fragment("array_agg(?) && (?)", hashtag.name, ^tags) - ) - end - - defp maybe_restrict_hashtag_all(query, []) do - query - end - - defp maybe_restrict_hashtag_all(query, tags) do - having( - query, - [hashtag: hashtag], - fragment("array_agg(?) @> (?)", hashtag.name, ^tags) - ) - end - - defp maybe_restrict_hashtag_reject_any(query, []) do - query - end - - defp maybe_restrict_hashtag_reject_any(query, tags) do - having( - query, - [hashtag: hashtag], - fragment("not(array_agg(?) && (?))", hashtag.name, ^tags) - ) - end - - defp restrict_hashtag_reject_any(_query, %{tag_reject: _tag_reject, skip_preload: true}) do + defp restrict_embedded_tag_reject_any(_query, %{tag_reject: _tag_reject, skip_preload: true}) do raise_on_missing_preload() end - defp restrict_hashtag_reject_any(query, %{tag_reject: tags_reject}) when is_list(tags_reject) do - query - |> group_by_all_bindings() - |> join(:left, [_activity, object], hashtag in assoc(object, :hashtags), as: :hashtag) - |> having( - [hashtag: hashtag], - fragment("not(array_agg(?) && (?))", hashtag.name, ^tags_reject) + defp restrict_embedded_tag_reject_any(query, %{tag_reject: tag_reject}) + when is_list(tag_reject) do + from( + [_activity, object] in query, + where: fragment("not (?)->'tag' \\?| (?)", object.data, ^tag_reject) ) end - defp restrict_hashtag_reject_any(query, %{tag_reject: tag_reject}) when is_binary(tag_reject) do - restrict_hashtag_reject_any(query, %{tag_reject: [tag_reject]}) + defp restrict_embedded_tag_reject_any(query, %{tag_reject: tag_reject}) + when is_binary(tag_reject) do + restrict_embedded_tag_reject_any(query, %{tag_reject: [tag_reject]}) end - defp restrict_hashtag_reject_any(query, _), do: query + defp restrict_embedded_tag_reject_any(query, _), do: query defp restrict_hashtag_all(_query, %{tag_all: _tag, skip_preload: true}) do raise_on_missing_preload() end defp restrict_hashtag_all(query, %{tag_all: tags}) when is_list(tags) do - Enum.reduce( - tags, - query, - fn tag, acc -> restrict_hashtag_any(acc, %{tag: tag}) end + from( + [_activity, object] in query, + where: + fragment( + """ + (SELECT array_agg(hashtags.name) FROM hashtags JOIN hashtags_objects + ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?) + AND hashtags_objects.object_id = ?) @> ? + """, + ^tags, + object.id, + ^tags + ) ) end @@ -846,18 +754,19 @@ defp restrict_hashtag_any(_query, %{tag: _tag, skip_preload: true}) do end defp restrict_hashtag_any(query, %{tag: tags}) when is_list(tags) do - query = - from( - [_activity, object] in query, - join: hashtag in assoc(object, :hashtags), - where: hashtag.name in ^tags - ) - - if length(tags) > 1 do - distinct(query, [activity], true) - else - query - end + from( + [_activity, object] in query, + where: + fragment( + """ + EXISTS (SELECT 1 FROM hashtags JOIN hashtags_objects + ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?) + AND hashtags_objects.object_id = ? LIMIT 1) + """, + ^tags, + object.id + ) + ) end defp restrict_hashtag_any(query, %{tag: tag}) when is_binary(tag) do @@ -866,6 +775,32 @@ defp restrict_hashtag_any(query, %{tag: tag}) when is_binary(tag) do defp restrict_hashtag_any(query, _), do: query + defp restrict_hashtag_reject_any(_query, %{tag_reject: _tag_reject, skip_preload: true}) do + raise_on_missing_preload() + end + + defp restrict_hashtag_reject_any(query, %{tag_reject: tags_reject}) when is_list(tags_reject) do + from( + [_activity, object] in query, + where: + fragment( + """ + NOT EXISTS (SELECT 1 FROM hashtags JOIN hashtags_objects + ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?) + AND hashtags_objects.object_id = ? LIMIT 1) + """, + ^tags_reject, + object.id + ) + ) + end + + defp restrict_hashtag_reject_any(query, %{tag_reject: tag_reject}) when is_binary(tag_reject) do + restrict_hashtag_reject_any(query, %{tag_reject: [tag_reject]}) + end + + defp restrict_hashtag_reject_any(query, _), do: query + defp raise_on_missing_preload do raise "Can't use the child object without preloading!" end @@ -1286,23 +1221,16 @@ def fetch_activities_query(recipients, opts \\ %{}) do |> exclude_invisible_actors(opts) |> exclude_visibility(opts) - hashtag_timeline_strategy = Config.improved_hashtag_timeline() - - cond do - !hashtag_timeline_strategy -> - query - |> restrict_embedded_tag(opts) - |> restrict_embedded_tag_reject(opts) - |> restrict_embedded_tag_all(opts) - - hashtag_timeline_strategy == :prefer_aggregation -> - restrict_hashtag_agg(query, opts) - - true -> - query - |> restrict_hashtag_any(opts) - |> restrict_hashtag_all(opts) - |> restrict_hashtag_reject_any(opts) + if Config.improved_hashtag_timeline() do + query + |> restrict_hashtag_any(opts) + |> restrict_hashtag_all(opts) + |> restrict_hashtag_reject_any(opts) + else + query + |> restrict_embedded_tag_any(opts) + |> restrict_embedded_tag_all(opts) + |> restrict_embedded_tag_reject_any(opts) end end diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index 0b18269cd..c2cc5a9af 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -220,7 +220,7 @@ test "it fetches the appropriate tag-restricted posts" do {:ok, status_four} = CommonAPI.post(user, %{status: ". #any1 #any2"}) {:ok, status_five} = CommonAPI.post(user, %{status: ". #any2 #any1"}) - for hashtag_timeline_strategy <- [true, :prefer_aggregation, false] do + for hashtag_timeline_strategy <- [true, false] do clear_config([:instance, :improved_hashtag_timeline], hashtag_timeline_strategy) fetch_one = ActivityPub.fetch_activities([], %{type: "Create", tag: "test"}) @@ -241,8 +241,7 @@ test "it fetches the appropriate tag-restricted posts" do tag_all: ["test", "reject"] }) - # This test would fail if JOIN with 2+ terms in "any" clause is done without DISTINCT. - # The :limit is important (w/o DISTINCT 2 records are deduped by Ecto to 1 b/c of preload). + # Testing that deduplication (if needed) is done on DB (not Ecto) level; :limit is important fetch_five = ActivityPub.fetch_activities([], %{ type: "Create", From 108e90b18edcfb57b9839e7c5d6d444a63ae2069 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Sun, 31 Jan 2021 22:03:59 +0300 Subject: [PATCH 037/339] [#3213] Explicitly defined PKs in hashtags_objects and data_migration_failed_ids. Added "pleroma.database rollback" task to revert a single migration. --- lib/mix/tasks/pleroma/database.ex | 24 +++++++++++++++++++ ...20201221203824_create_hashtags_objects.exs | 4 ++-- ...gration_create_populate_hashtags_table.exs | 4 +++- ...72254_create_data_migration_failed_ids.exs | 4 ++-- 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index 4ddace9c9..30c0d2bf1 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -20,6 +20,30 @@ defmodule Mix.Tasks.Pleroma.Database do @shortdoc "A collection of database related tasks" @moduledoc File.read!("docs/administration/CLI_tasks/database.md") + # Rolls back a specific migration (leaving subsequent migrations applied) + # Based on https://stackoverflow.com/a/53825840 + def run(["rollback", version]) do + start_pleroma() + + version = String.to_integer(version) + re = ~r/^#{version}_.*\.exs/ + path = Application.app_dir(:pleroma, Path.join(["priv", "repo", "migrations"])) + + result = + with {:find, "" <> file} <- {:find, Enum.find(File.ls!(path), &String.match?(&1, re))}, + {:compile, [{mod, _} | _]} <- {:compile, Code.compile_file(Path.join(path, file))}, + {:rollback, :ok} <- {:rollback, Ecto.Migrator.down(Repo, version, mod)} do + {:ok, "Reversed migration: #{file}"} + else + {:find, _} -> {:error, "No migration found with version prefix: #{version}"} + {:compile, e} -> {:error, "Problem compiling migration module: #{inspect(e)}"} + {:rollback, e} -> {:error, "Problem reversing migration: #{inspect(e)}"} + e -> {:error, "Something unexpected happened: #{inspect(e)}"} + end + + IO.inspect(result) + end + def run(["remove_embedded_objects" | args]) do {options, [], []} = OptionParser.parse( diff --git a/priv/repo/migrations/20201221203824_create_hashtags_objects.exs b/priv/repo/migrations/20201221203824_create_hashtags_objects.exs index 214ea81c3..efd60369d 100644 --- a/priv/repo/migrations/20201221203824_create_hashtags_objects.exs +++ b/priv/repo/migrations/20201221203824_create_hashtags_objects.exs @@ -3,8 +3,8 @@ defmodule Pleroma.Repo.Migrations.CreateHashtagsObjects do def change do create_if_not_exists table(:hashtags_objects, primary_key: false) do - add(:hashtag_id, references(:hashtags), null: false) - add(:object_id, references(:objects), null: false) + add(:hashtag_id, references(:hashtags), null: false, primary_key: true) + add(:object_id, references(:objects), null: false, primary_key: true) end create_if_not_exists(unique_index(:hashtags_objects, [:hashtag_id, :object_id])) diff --git a/priv/repo/migrations/20210106183301_data_migration_create_populate_hashtags_table.exs b/priv/repo/migrations/20210106183301_data_migration_create_populate_hashtags_table.exs index 2a965f075..cf3cf26a0 100644 --- a/priv/repo/migrations/20210106183301_data_migration_create_populate_hashtags_table.exs +++ b/priv/repo/migrations/20210106183301_data_migration_create_populate_hashtags_table.exs @@ -10,5 +10,7 @@ def up do ) end - def down, do: :ok + def down do + execute("DELETE FROM data_migrations WHERE name = 'populate_hashtags_table';") + end end diff --git a/priv/repo/migrations/20210111172254_create_data_migration_failed_ids.exs b/priv/repo/migrations/20210111172254_create_data_migration_failed_ids.exs index ba0be98af..18afa74ac 100644 --- a/priv/repo/migrations/20210111172254_create_data_migration_failed_ids.exs +++ b/priv/repo/migrations/20210111172254_create_data_migration_failed_ids.exs @@ -3,8 +3,8 @@ defmodule Pleroma.Repo.Migrations.CreateDataMigrationFailedIds do def change do create_if_not_exists table(:data_migration_failed_ids, primary_key: false) do - add(:data_migration_id, references(:data_migrations), null: false) - add(:record_id, :bigint, null: false) + add(:data_migration_id, references(:data_migrations), null: false, primary_key: true) + add(:record_id, :bigint, null: false, primary_key: true) end create_if_not_exists( From 10207f840ce3515dddfde36288575f203c52840f Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Sun, 31 Jan 2021 22:36:46 +0300 Subject: [PATCH 038/339] [#3213] ActivityPub: temporarily reverted to previous hashtags filtering implementation due to blank results issue. --- lib/pleroma/web/activity_pub/activity_pub.ex | 106 ++++++++++--------- 1 file changed, 54 insertions(+), 52 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index cda8d3f54..fd0144aad 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -722,24 +722,53 @@ defp restrict_embedded_tag_reject_any(query, %{tag_reject: tag_reject}) defp restrict_embedded_tag_reject_any(query, _), do: query + # Groups by all bindings to allow aggregation on hashtags + defp group_by_all_bindings(query) do + # Expecting named bindings: :object, :bookmark, :thread_mute, :report_note + cond do + Enum.count(query.aliases) == 4 -> + from([a, o, b3, b4, b5] in query, group_by: [a.id, o.id, b3.id, b4.id, b5.id]) + + Enum.count(query.aliases) == 3 -> + from([a, o, b3, b4] in query, group_by: [a.id, o.id, b3.id, b4.id]) + + Enum.count(query.aliases) == 2 -> + from([a, o, b3] in query, group_by: [a.id, o.id, b3.id]) + + true -> + from([a, o] in query, group_by: [a.id, o.id]) + end + end + + defp restrict_hashtag_reject_any(_query, %{tag_reject: _tag_reject, skip_preload: true}) do + raise_on_missing_preload() + end + + defp restrict_hashtag_reject_any(query, %{tag_reject: tags_reject}) when is_list(tags_reject) do + query + |> group_by_all_bindings() + |> join(:left, [_activity, object], hashtag in assoc(object, :hashtags), as: :hashtag) + |> having( + [hashtag: hashtag], + fragment("not(array_agg(?) && (?))", hashtag.name, ^tags_reject) + ) + end + + defp restrict_hashtag_reject_any(query, %{tag_reject: tag_reject}) when is_binary(tag_reject) do + restrict_hashtag_reject_any(query, %{tag_reject: [tag_reject]}) + end + + defp restrict_hashtag_reject_any(query, _), do: query + defp restrict_hashtag_all(_query, %{tag_all: _tag, skip_preload: true}) do raise_on_missing_preload() end defp restrict_hashtag_all(query, %{tag_all: tags}) when is_list(tags) do - from( - [_activity, object] in query, - where: - fragment( - """ - (SELECT array_agg(hashtags.name) FROM hashtags JOIN hashtags_objects - ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?) - AND hashtags_objects.object_id = ?) @> ? - """, - ^tags, - object.id, - ^tags - ) + Enum.reduce( + tags, + query, + fn tag, acc -> restrict_hashtag_any(acc, %{tag: tag}) end ) end @@ -754,19 +783,18 @@ defp restrict_hashtag_any(_query, %{tag: _tag, skip_preload: true}) do end defp restrict_hashtag_any(query, %{tag: tags}) when is_list(tags) do - from( - [_activity, object] in query, - where: - fragment( - """ - EXISTS (SELECT 1 FROM hashtags JOIN hashtags_objects - ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?) - AND hashtags_objects.object_id = ? LIMIT 1) - """, - ^tags, - object.id - ) - ) + query = + from( + [_activity, object] in query, + join: hashtag in assoc(object, :hashtags), + where: hashtag.name in ^tags + ) + + if length(tags) > 1 do + distinct(query, [activity], true) + else + query + end end defp restrict_hashtag_any(query, %{tag: tag}) when is_binary(tag) do @@ -775,32 +803,6 @@ defp restrict_hashtag_any(query, %{tag: tag}) when is_binary(tag) do defp restrict_hashtag_any(query, _), do: query - defp restrict_hashtag_reject_any(_query, %{tag_reject: _tag_reject, skip_preload: true}) do - raise_on_missing_preload() - end - - defp restrict_hashtag_reject_any(query, %{tag_reject: tags_reject}) when is_list(tags_reject) do - from( - [_activity, object] in query, - where: - fragment( - """ - NOT EXISTS (SELECT 1 FROM hashtags JOIN hashtags_objects - ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?) - AND hashtags_objects.object_id = ? LIMIT 1) - """, - ^tags_reject, - object.id - ) - ) - end - - defp restrict_hashtag_reject_any(query, %{tag_reject: tag_reject}) when is_binary(tag_reject) do - restrict_hashtag_reject_any(query, %{tag_reject: [tag_reject]}) - end - - defp restrict_hashtag_reject_any(query, _), do: query - defp raise_on_missing_preload do raise "Can't use the child object without preloading!" end From cf4765af4098098fa4d6996193432bd19c439a75 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Sun, 31 Jan 2021 23:06:38 +0300 Subject: [PATCH 039/339] [#3213] ActivityPub: fixed subquery-based hashtags filtering implementation (addressed empty list options issue). Added regression test. --- lib/pleroma/web/activity_pub/activity_pub.ex | 117 +++++++++--------- .../web/activity_pub/activity_pub_test.exs | 11 ++ 2 files changed, 68 insertions(+), 60 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index fd0144aad..6cf4093fb 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -673,7 +673,7 @@ defp restrict_embedded_tag_all(_query, %{tag_all: _tag_all, skip_preload: true}) raise_on_missing_preload() end - defp restrict_embedded_tag_all(query, %{tag_all: tag_all}) when is_list(tag_all) do + defp restrict_embedded_tag_all(query, %{tag_all: [_ | _] = tag_all}) do from( [_activity, object] in query, where: fragment("(?)->'tag' \\?& (?)", object.data, ^tag_all) @@ -690,7 +690,7 @@ defp restrict_embedded_tag_any(_query, %{tag: _tag, skip_preload: true}) do raise_on_missing_preload() end - defp restrict_embedded_tag_any(query, %{tag: tag}) when is_list(tag) do + defp restrict_embedded_tag_any(query, %{tag: [_ | _] = tag}) do from( [_activity, object] in query, where: fragment("(?)->'tag' \\?| (?)", object.data, ^tag) @@ -707,8 +707,7 @@ defp restrict_embedded_tag_reject_any(_query, %{tag_reject: _tag_reject, skip_pr raise_on_missing_preload() end - defp restrict_embedded_tag_reject_any(query, %{tag_reject: tag_reject}) - when is_list(tag_reject) do + defp restrict_embedded_tag_reject_any(query, %{tag_reject: [_ | _] = tag_reject}) do from( [_activity, object] in query, where: fragment("not (?)->'tag' \\?| (?)", object.data, ^tag_reject) @@ -722,53 +721,24 @@ defp restrict_embedded_tag_reject_any(query, %{tag_reject: tag_reject}) defp restrict_embedded_tag_reject_any(query, _), do: query - # Groups by all bindings to allow aggregation on hashtags - defp group_by_all_bindings(query) do - # Expecting named bindings: :object, :bookmark, :thread_mute, :report_note - cond do - Enum.count(query.aliases) == 4 -> - from([a, o, b3, b4, b5] in query, group_by: [a.id, o.id, b3.id, b4.id, b5.id]) - - Enum.count(query.aliases) == 3 -> - from([a, o, b3, b4] in query, group_by: [a.id, o.id, b3.id, b4.id]) - - Enum.count(query.aliases) == 2 -> - from([a, o, b3] in query, group_by: [a.id, o.id, b3.id]) - - true -> - from([a, o] in query, group_by: [a.id, o.id]) - end - end - - defp restrict_hashtag_reject_any(_query, %{tag_reject: _tag_reject, skip_preload: true}) do - raise_on_missing_preload() - end - - defp restrict_hashtag_reject_any(query, %{tag_reject: tags_reject}) when is_list(tags_reject) do - query - |> group_by_all_bindings() - |> join(:left, [_activity, object], hashtag in assoc(object, :hashtags), as: :hashtag) - |> having( - [hashtag: hashtag], - fragment("not(array_agg(?) && (?))", hashtag.name, ^tags_reject) - ) - end - - defp restrict_hashtag_reject_any(query, %{tag_reject: tag_reject}) when is_binary(tag_reject) do - restrict_hashtag_reject_any(query, %{tag_reject: [tag_reject]}) - end - - defp restrict_hashtag_reject_any(query, _), do: query - defp restrict_hashtag_all(_query, %{tag_all: _tag, skip_preload: true}) do raise_on_missing_preload() end - defp restrict_hashtag_all(query, %{tag_all: tags}) when is_list(tags) do - Enum.reduce( - tags, - query, - fn tag, acc -> restrict_hashtag_any(acc, %{tag: tag}) end + defp restrict_hashtag_all(query, %{tag_all: [_ | _] = tags}) do + from( + [_activity, object] in query, + where: + fragment( + """ + (SELECT array_agg(hashtags.name) FROM hashtags JOIN hashtags_objects + ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?) + AND hashtags_objects.object_id = ?) @> ? + """, + ^tags, + object.id, + ^tags + ) ) end @@ -782,19 +752,20 @@ defp restrict_hashtag_any(_query, %{tag: _tag, skip_preload: true}) do raise_on_missing_preload() end - defp restrict_hashtag_any(query, %{tag: tags}) when is_list(tags) do - query = - from( - [_activity, object] in query, - join: hashtag in assoc(object, :hashtags), - where: hashtag.name in ^tags - ) - - if length(tags) > 1 do - distinct(query, [activity], true) - else - query - end + defp restrict_hashtag_any(query, %{tag: [_ | _] = tags}) do + from( + [_activity, object] in query, + where: + fragment( + """ + EXISTS (SELECT 1 FROM hashtags JOIN hashtags_objects + ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?) + AND hashtags_objects.object_id = ? LIMIT 1) + """, + ^tags, + object.id + ) + ) end defp restrict_hashtag_any(query, %{tag: tag}) when is_binary(tag) do @@ -803,6 +774,32 @@ defp restrict_hashtag_any(query, %{tag: tag}) when is_binary(tag) do defp restrict_hashtag_any(query, _), do: query + defp restrict_hashtag_reject_any(_query, %{tag_reject: _tag_reject, skip_preload: true}) do + raise_on_missing_preload() + end + + defp restrict_hashtag_reject_any(query, %{tag_reject: [_ | _] = tags_reject}) do + from( + [_activity, object] in query, + where: + fragment( + """ + NOT EXISTS (SELECT 1 FROM hashtags JOIN hashtags_objects + ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?) + AND hashtags_objects.object_id = ? LIMIT 1) + """, + ^tags_reject, + object.id + ) + ) + end + + defp restrict_hashtag_reject_any(query, %{tag_reject: tag_reject}) when is_binary(tag_reject) do + restrict_hashtag_reject_any(query, %{tag_reject: [tag_reject]}) + end + + defp restrict_hashtag_reject_any(query, _), do: query + defp raise_on_missing_preload do raise "Can't use the child object without preloading!" end diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index 5b9fc061e..04fd1def3 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -249,6 +249,17 @@ test "it fetches the appropriate tag-restricted posts" do limit: 2 }) + fetch_six = + ActivityPub.fetch_activities([], %{ + type: "Create", + tag: ["any1", "any2"], + tag_all: [], + tag_reject: [] + }) + + # Regression test: passing empty lists as filter options shouldn't affect the results + assert fetch_five == fetch_six + [fetch_one, fetch_two, fetch_three, fetch_four, fetch_five] = Enum.map([fetch_one, fetch_two, fetch_three, fetch_four, fetch_five], fn statuses -> Enum.map(statuses, fn s -> Repo.preload(s, object: :hashtags) end) From d1c6dd97aa503ca7c897d67d98fe8c924e113a61 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Sun, 7 Feb 2021 22:24:12 +0300 Subject: [PATCH 040/339] [#3213] Partially addressed code review points. migration rollback task changes, hashtags-related config handling tweaks, `hashtags.data` deletion (unused). --- config/description.exs | 20 ++++--- lib/mix/tasks/pleroma/database.ex | 53 ++++++++++--------- lib/pleroma/config.ex | 3 -- lib/pleroma/hashtag.ex | 5 +- .../migrators/hashtags_table_migrator.ex | 4 +- lib/pleroma/web/activity_pub/activity_pub.ex | 2 +- .../20201221202251_create_hashtags.exs | 1 - ...201221202252_remove_data_from_hashtags.exs | 15 ++++++ .../web/activity_pub/activity_pub_test.exs | 2 +- 9 files changed, 63 insertions(+), 42 deletions(-) create mode 100644 priv/repo/migrations/20201221202252_remove_data_from_hashtags.exs diff --git a/config/description.exs b/config/description.exs index ed3a534a0..02cdf2ff3 100644 --- a/config/description.exs +++ b/config/description.exs @@ -495,6 +495,20 @@ } ] }, + %{ + group: :pleroma, + key: :database, + type: :group, + description: "Database-related settings", + children: [ + %{ + key: :improved_hashtag_timeline, + type: :keyword, + description: + "If `true`, hashtags will be fetched from `hashtags` table for hashtags timeline. When `false`, object-embedded hashtags will be used (slower). Is auto-set to `true` (unless overridden) when HashtagsTableMigrator completes." + } + ] + }, %{ group: :pleroma, key: :instance, @@ -941,12 +955,6 @@ key: :show_reactions, type: :boolean, description: "Let favourites and emoji reactions be viewed through the API." - }, - %{ - key: :improved_hashtag_timeline, - type: :keyword, - description: - "If `true`, hashtags will be fetched from `hashtags` table for hashtags timeline. When `false`, object-embedded hashtags will be used (slower). Is auto-set to `true` (unless overridden) when HashtagsTableMigrator completes." } ] }, diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index 30c0d2bf1..7c4f54141 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -20,30 +20,6 @@ defmodule Mix.Tasks.Pleroma.Database do @shortdoc "A collection of database related tasks" @moduledoc File.read!("docs/administration/CLI_tasks/database.md") - # Rolls back a specific migration (leaving subsequent migrations applied) - # Based on https://stackoverflow.com/a/53825840 - def run(["rollback", version]) do - start_pleroma() - - version = String.to_integer(version) - re = ~r/^#{version}_.*\.exs/ - path = Application.app_dir(:pleroma, Path.join(["priv", "repo", "migrations"])) - - result = - with {:find, "" <> file} <- {:find, Enum.find(File.ls!(path), &String.match?(&1, re))}, - {:compile, [{mod, _} | _]} <- {:compile, Code.compile_file(Path.join(path, file))}, - {:rollback, :ok} <- {:rollback, Ecto.Migrator.down(Repo, version, mod)} do - {:ok, "Reversed migration: #{file}"} - else - {:find, _} -> {:error, "No migration found with version prefix: #{version}"} - {:compile, e} -> {:error, "Problem compiling migration module: #{inspect(e)}"} - {:rollback, e} -> {:error, "Problem reversing migration: #{inspect(e)}"} - e -> {:error, "Something unexpected happened: #{inspect(e)}"} - end - - IO.inspect(result) - end - def run(["remove_embedded_objects" | args]) do {options, [], []} = OptionParser.parse( @@ -194,4 +170,33 @@ def run(["ensure_expiration"]) do end) |> Stream.run() end + + # Rolls back a specific migration (leaving subsequent migrations applied). + # WARNING: imposes a risk of unrecoverable data loss — proceed at your own responsibility. + # Based on https://stackoverflow.com/a/53825840 + def run(["rollback", version]) do + prompt = "SEVERE WARNING: this operation may result in unrecoverable data loss. Continue?" + + if shell_prompt(prompt, "n") in ~w(Yn Y y) do + {_, result, _} = + Ecto.Migrator.with_repo(Pleroma.Repo, fn repo -> + version = String.to_integer(version) + re = ~r/^#{version}_.*\.exs/ + path = Ecto.Migrator.migrations_path(repo) + + with {:find, "" <> file} <- {:find, Enum.find(File.ls!(path), &String.match?(&1, re))}, + {:compile, [{mod, _} | _]} <- {:compile, Code.compile_file(Path.join(path, file))}, + {:rollback, :ok} <- {:rollback, Ecto.Migrator.down(repo, version, mod)} do + {:ok, "Reversed migration: #{file}"} + else + {:find, _} -> {:error, "No migration found with version prefix: #{version}"} + {:compile, e} -> {:error, "Problem compiling migration module: #{inspect(e)}"} + {:rollback, e} -> {:error, "Problem reversing migration: #{inspect(e)}"} + e -> {:error, "Something unexpected happened: #{inspect(e)}"} + end + end) + + IO.inspect(result) + end + end end diff --git a/lib/pleroma/config.ex b/lib/pleroma/config.ex index 0a6ac0ad0..f17e14128 100644 --- a/lib/pleroma/config.ex +++ b/lib/pleroma/config.ex @@ -96,9 +96,6 @@ def restrict_unauthenticated_access?(resource, kind) do end end - def improved_hashtag_timeline_path, do: [:instance, :improved_hashtag_timeline] - def improved_hashtag_timeline, do: get(improved_hashtag_timeline_path()) - def oauth_consumer_strategies, do: get([:auth, :oauth_consumer_strategies], []) def oauth_consumer_enabled?, do: oauth_consumer_strategies() != [] diff --git a/lib/pleroma/hashtag.ex b/lib/pleroma/hashtag.ex index b05927563..9e4c6c894 100644 --- a/lib/pleroma/hashtag.ex +++ b/lib/pleroma/hashtag.ex @@ -10,11 +10,8 @@ defmodule Pleroma.Hashtag do alias Pleroma.Hashtag alias Pleroma.Repo - @derive {Jason.Encoder, only: [:data]} - schema "hashtags" do field(:name, :string) - field(:data, :map, default: %{}) many_to_many(:objects, Pleroma.Object, join_through: "hashtags_objects", on_replace: :delete) @@ -50,7 +47,7 @@ def get_or_create_by_names(names) when is_list(names) do def changeset(%Hashtag{} = struct, params) do struct - |> cast(params, [:name, :data]) + |> cast(params, [:name]) |> update_change(:name, &String.downcase/1) |> validate_required([:name]) |> unique_constraint(:name) diff --git a/lib/pleroma/migrators/hashtags_table_migrator.ex b/lib/pleroma/migrators/hashtags_table_migrator.ex index 07b42a7f4..9a036e0b2 100644 --- a/lib/pleroma/migrators/hashtags_table_migrator.ex +++ b/lib/pleroma/migrators/hashtags_table_migrator.ex @@ -239,11 +239,11 @@ defp handle_success(data_migration) do data_migration.feature_lock -> :noop - not is_nil(Config.improved_hashtag_timeline()) -> + not is_nil(Config.get([:database, :improved_hashtag_timeline])) -> :noop true -> - Config.put(Config.improved_hashtag_timeline_path(), true) + Config.put([:database, :improved_hashtag_timeline], true) :ok end end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 573b4243c..7ac18e5c5 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1227,7 +1227,7 @@ def fetch_activities_query(recipients, opts \\ %{}) do |> exclude_invisible_actors(opts) |> exclude_visibility(opts) - if Config.improved_hashtag_timeline() do + if Config.get([:database, :improved_hashtag_timeline]) do query |> restrict_hashtag_any(opts) |> restrict_hashtag_all(opts) diff --git a/priv/repo/migrations/20201221202251_create_hashtags.exs b/priv/repo/migrations/20201221202251_create_hashtags.exs index afc522002..8d2e9ae66 100644 --- a/priv/repo/migrations/20201221202251_create_hashtags.exs +++ b/priv/repo/migrations/20201221202251_create_hashtags.exs @@ -4,7 +4,6 @@ defmodule Pleroma.Repo.Migrations.CreateHashtags do def change do create_if_not_exists table(:hashtags) do add(:name, :citext, null: false) - add(:data, :map, default: %{}) timestamps() end diff --git a/priv/repo/migrations/20201221202252_remove_data_from_hashtags.exs b/priv/repo/migrations/20201221202252_remove_data_from_hashtags.exs new file mode 100644 index 000000000..0442c3b87 --- /dev/null +++ b/priv/repo/migrations/20201221202252_remove_data_from_hashtags.exs @@ -0,0 +1,15 @@ +defmodule Pleroma.Repo.Migrations.RemoveDataFromHashtags do + use Ecto.Migration + + def up do + alter table(:hashtags) do + remove_if_exists(:data, :map) + end + end + + def down do + alter table(:hashtags) do + add_if_not_exists(:data, :map, default: %{}) + end + end +end diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index 04fd1def3..bab5a199c 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -221,7 +221,7 @@ test "it fetches the appropriate tag-restricted posts" do {:ok, status_five} = CommonAPI.post(user, %{status: ". #any2 #any1"}) for hashtag_timeline_strategy <- [true, false] do - clear_config([:instance, :improved_hashtag_timeline], hashtag_timeline_strategy) + clear_config([:database, :improved_hashtag_timeline], hashtag_timeline_strategy) fetch_one = ActivityPub.fetch_activities([], %{type: "Create", tag: "test"}) From a996ab46a54acbfa7a19da3eae12c78ed6466a1a Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Thu, 11 Feb 2021 19:30:21 +0300 Subject: [PATCH 041/339] [#3213] Reorganized hashtags cleanup. Transaction-wrapped Hashtag.get_or_create_by_names/1. Misc. improvements. --- config/config.exs | 1 - config/description.exs | 1 - lib/pleroma/hashtag.ex | 58 +++++++++++++--- .../migrators/hashtags_table_migrator.ex | 68 +++++++++++++------ lib/pleroma/object.ex | 27 +++++--- .../workers/cron/hashtags_cleanup_worker.ex | 57 ---------------- 6 files changed, 112 insertions(+), 100 deletions(-) delete mode 100644 lib/pleroma/workers/cron/hashtags_cleanup_worker.ex diff --git a/config/config.exs b/config/config.exs index 36c609936..91888c512 100644 --- a/config/config.exs +++ b/config/config.exs @@ -560,7 +560,6 @@ ], plugins: [Oban.Plugins.Pruner], crontab: [ - {"0 1 * * *", Pleroma.Workers.Cron.HashtagsCleanupWorker}, {"0 0 * * 0", Pleroma.Workers.Cron.DigestEmailsWorker}, {"0 0 * * *", Pleroma.Workers.Cron.NewUsersDigestWorker} ] diff --git a/config/description.exs b/config/description.exs index 02cdf2ff3..b2f301e2d 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1964,7 +1964,6 @@ type: {:list, :tuple}, description: "Settings for cron background jobs", suggestions: [ - {"0 1 * * *", Pleroma.Workers.Cron.HashtagsCleanupWorker}, {"0 0 * * 0", Pleroma.Workers.Cron.DigestEmailsWorker}, {"0 0 * * *", Pleroma.Workers.Cron.NewUsersDigestWorker} ] diff --git a/lib/pleroma/hashtag.ex b/lib/pleroma/hashtag.ex index 9e4c6c894..de52c4dae 100644 --- a/lib/pleroma/hashtag.ex +++ b/lib/pleroma/hashtag.ex @@ -6,14 +6,17 @@ defmodule Pleroma.Hashtag do use Ecto.Schema import Ecto.Changeset + import Ecto.Query + alias Ecto.Multi alias Pleroma.Hashtag + alias Pleroma.Object alias Pleroma.Repo schema "hashtags" do field(:name, :string) - many_to_many(:objects, Pleroma.Object, join_through: "hashtags_objects", on_replace: :delete) + many_to_many(:objects, Object, join_through: "hashtags_objects", on_replace: :delete) timestamps() end @@ -34,15 +37,27 @@ def get_or_create_by_name(name) when is_bitstring(name) do end def get_or_create_by_names(names) when is_list(names) do - Enum.reduce_while(names, {:ok, []}, fn name, {:ok, list} -> - case get_or_create_by_name(name) do - {:ok, %Hashtag{} = hashtag} -> - {:cont, {:ok, list ++ [hashtag]}} + timestamp = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) - error -> - {:halt, error} - end - end) + structs = + Enum.map(names, fn name -> + %Hashtag{} + |> changeset(%{name: name}) + |> Map.get(:changes) + |> Map.merge(%{inserted_at: timestamp, updated_at: timestamp}) + end) + + with {:ok, %{query_op: hashtags}} <- + Multi.new() + |> Multi.insert_all(:insert_all_op, Hashtag, structs, on_conflict: :nothing) + |> Multi.run(:query_op, fn _repo, _changes -> + {:ok, Repo.all(from(ht in Hashtag, where: ht.name in ^names))} + end) + |> Repo.transaction() do + {:ok, hashtags} + else + {:error, _name, value, _changes_so_far} -> {:error, value} + end end def changeset(%Hashtag{} = struct, params) do @@ -52,4 +67,29 @@ def changeset(%Hashtag{} = struct, params) do |> validate_required([:name]) |> unique_constraint(:name) end + + def unlink(%Object{id: object_id}) do + with {_, hashtag_ids} <- + from(hto in "hashtags_objects", + where: hto.object_id == ^object_id, + select: hto.hashtag_id + ) + |> Repo.delete_all() do + delete_unreferenced(hashtag_ids) + end + end + + @delete_unreferenced_query """ + DELETE FROM hashtags WHERE id IN + (SELECT hashtags.id FROM hashtags + LEFT OUTER JOIN hashtags_objects + ON hashtags_objects.hashtag_id = hashtags.id + WHERE hashtags_objects.hashtag_id IS NULL AND hashtags.id = ANY($1)); + """ + + def delete_unreferenced(ids) do + with {:ok, %{num_rows: deleted_count}} <- Repo.query(@delete_unreferenced_query, [ids]) do + {:ok, deleted_count} + end + end end diff --git a/lib/pleroma/migrators/hashtags_table_migrator.ex b/lib/pleroma/migrators/hashtags_table_migrator.ex index 9a036e0b2..c53f6be12 100644 --- a/lib/pleroma/migrators/hashtags_table_migrator.ex +++ b/lib/pleroma/migrators/hashtags_table_migrator.ex @@ -74,16 +74,15 @@ def handle_continue(:init_state, _state) do def handle_info(:migrate_hashtags, state) do State.clear() - data_migration = data_migration() + update_status(:running) + put_stat(:started_at, NaiveDateTime.utc_now()) + data_migration = data_migration() persistent_data = Map.take(data_migration.data, ["max_processed_id"]) {:ok, data_migration} = DataMigration.update(data_migration, %{state: :running, data: persistent_data}) - update_status(:running) - put_stat(:started_at, NaiveDateTime.utc_now()) - Logger.info("Starting transferring object embedded hashtags to `hashtags` table...") max_processed_id = data_migration.data["max_processed_id"] || 0 @@ -137,6 +136,8 @@ def handle_info(:migrate_hashtags, state) do |> Stream.run() with 0 <- failures_count(data_migration.id) do + _ = delete_non_create_activities_hashtags() + {:ok, data_migration} = DataMigration.update_state(data_migration, :complete) handle_success(data_migration) @@ -150,9 +151,37 @@ def handle_info(:migrate_hashtags, state) do {:noreply, state} end + @hashtags_objects_cleanup_query """ + DELETE FROM hashtags_objects WHERE object_id IN + (SELECT DISTINCT objects.id FROM objects + JOIN hashtags_objects ON hashtags_objects.object_id = objects.id LEFT JOIN activities + ON COALESCE(activities.data->'object'->>'id', activities.data->>'object') = + (objects.data->>'id') + AND activities.data->>'type' = 'Create' + WHERE activities.id IS NULL); + """ + + @hashtags_cleanup_query """ + DELETE FROM hashtags WHERE id IN + (SELECT hashtags.id FROM hashtags + LEFT OUTER JOIN hashtags_objects + ON hashtags_objects.hashtag_id = hashtags.id + WHERE hashtags_objects.hashtag_id IS NULL); + """ + + def delete_non_create_activities_hashtags do + {:ok, %{num_rows: hashtags_objects_count}} = + Repo.query(@hashtags_objects_cleanup_query, [], timeout: :infinity) + + {:ok, %{num_rows: hashtags_count}} = + Repo.query(@hashtags_cleanup_query, [], timeout: :infinity) + + {:ok, hashtags_objects_count, hashtags_count} + end + defp query do # Note: most objects have Mention-type AS2 tags and no hashtags (but we can't filter them out) - # Note: not checking activity type; HashtagsCleanupWorker should clean up unused records later + # Note: not checking activity type, expecting remove_non_create_objects_hashtags/_ to clean up from( object in Object, where: @@ -182,25 +211,20 @@ defp transfer_object_hashtags(object) do defp transfer_object_hashtags(object, hashtags) do Repo.transaction(fn -> with {:ok, hashtag_records} <- Hashtag.get_or_create_by_names(hashtags) do - for hashtag_record <- hashtag_records do - with {:ok, _} <- - Repo.query( - "insert into hashtags_objects(hashtag_id, object_id) values ($1, $2);", - [hashtag_record.id, object.id] - ) do - nil - else - {:error, e} -> - error = - "ERROR: could not link object #{object.id} and hashtag " <> - "#{hashtag_record.id}: #{inspect(e)}" + maps = Enum.map(hashtag_records, &%{hashtag_id: &1.id, object_id: object.id}) + expected_rows = length(hashtag_records) - Logger.error(error) - Repo.rollback(object.id) - end + with {^expected_rows, _} <- Repo.insert_all("hashtags_objects", maps) do + object.id + else + e -> + error = + "ERROR when inserting #{expected_rows} hashtags_objects " <> + "for object #{object.id}: #{inspect(e)}" + + Logger.error(error) + Repo.rollback(object.id) end - - object.id else e -> error = "ERROR: could not create hashtags for object #{object.id}: #{inspect(e)}" diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index 52b77e41c..3ba749d1a 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -62,27 +62,30 @@ def change(struct, params \\ %{}) do |> cast(params, [:data]) |> validate_required([:data]) |> unique_constraint(:ap_id, name: :objects_unique_apid_index) + # Expecting `maybe_handle_hashtags_change/1` to run last: |> maybe_handle_hashtags_change(struct) end - # Note: not checking activity type; HashtagsCleanupWorker should clean up unused records later + # Note: not checking activity type (assuming non-legacy objects are associated with Create act.) defp maybe_handle_hashtags_change(changeset, struct) do - with data_hashtags_change = get_change(changeset, :data), - true <- hashtags_changed?(struct, data_hashtags_change), + with %Ecto.Changeset{valid?: true} <- changeset, + data_hashtags_change = get_change(changeset, :data), + {_, true} <- {:changed, hashtags_changed?(struct, data_hashtags_change)}, {:ok, hashtag_records} <- data_hashtags_change |> object_data_hashtags() |> Hashtag.get_or_create_by_names() do put_assoc(changeset, :hashtags, hashtag_records) else - false -> + %{valid?: false} -> changeset - {:error, hashtag_changeset} -> - failed_hashtag = get_field(hashtag_changeset, :name) + {:changed, false} -> + changeset + {:error, _} -> validate_change(changeset, :data, fn _, _ -> - [data: "error referencing hashtag: #{failed_hashtag}"] + [data: "error referencing hashtags"] end) end end @@ -221,9 +224,13 @@ def make_tombstone(%Object{data: %{"id" => id, "type" => type}}, deleted \\ Date def swap_object_with_tombstone(object) do tombstone = make_tombstone(object) - object - |> Object.change(%{data: tombstone}) - |> Repo.update() + with {:ok, object} <- + object + |> Object.change(%{data: tombstone}) + |> Repo.update() do + Hashtag.unlink(object) + {:ok, object} + end end def delete(%Object{data: %{"id" => id}} = object) do diff --git a/lib/pleroma/workers/cron/hashtags_cleanup_worker.ex b/lib/pleroma/workers/cron/hashtags_cleanup_worker.ex deleted file mode 100644 index b319067ca..000000000 --- a/lib/pleroma/workers/cron/hashtags_cleanup_worker.ex +++ /dev/null @@ -1,57 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Workers.Cron.HashtagsCleanupWorker do - @moduledoc """ - The worker to clean up unused hashtags_objects and hashtags. - """ - - use Oban.Worker, queue: "hashtags_cleanup" - - alias Pleroma.Repo - - require Logger - - @hashtags_objects_query """ - DELETE FROM hashtags_objects WHERE object_id IN - (SELECT DISTINCT objects.id FROM objects - JOIN hashtags_objects ON hashtags_objects.object_id = objects.id LEFT JOIN activities - ON COALESCE(activities.data->'object'->>'id', activities.data->>'object') = - (objects.data->>'id') - AND activities.data->>'type' = 'Create' - WHERE activities.id IS NULL); - """ - - @hashtags_query """ - DELETE FROM hashtags WHERE id IN - (SELECT hashtags.id FROM hashtags - LEFT OUTER JOIN hashtags_objects - ON hashtags_objects.hashtag_id = hashtags.id - WHERE hashtags_objects.hashtag_id IS NULL AND hashtags.inserted_at < $1); - """ - - @impl Oban.Worker - def perform(_job) do - Logger.info("Cleaning up unused `hashtags_objects` records...") - - {:ok, %{num_rows: hashtags_objects_count}} = - Repo.query(@hashtags_objects_query, [], timeout: :infinity) - - Logger.info("Deleted #{hashtags_objects_count} unused `hashtags_objects` records.") - - Logger.info("Cleaning up unused `hashtags` records...") - - # Note: ignoring recently created hashtags since references are added after hashtag is created - {:ok, %{num_rows: hashtags_count}} = - Repo.query(@hashtags_query, [NaiveDateTime.add(NaiveDateTime.utc_now(), -3600 * 24)], - timeout: :infinity - ) - - Logger.info("Deleted #{hashtags_count} unused `hashtags` records.") - - Logger.info("HashtagsCleanupWorker complete.") - - :ok - end -end From 349b8b0f4fb1c2b86f913e1840f15c052ff43c24 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Sat, 13 Feb 2021 22:01:11 +0300 Subject: [PATCH 042/339] [#3213] `rescue` around potentially-raising `Repo.insert_all/_` calls. Misc. improvements (docs etc.). --- CHANGELOG.md | 2 +- config/config.exs | 1 - config/description.exs | 14 +++++++++ docs/configuration/cheatsheet.md | 6 ++++ lib/pleroma/hashtag.ex | 29 +++++++++++-------- .../migrators/hashtags_table_migrator.ex | 21 +++++++++----- 6 files changed, 51 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23567a97c..a7b5f6ac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Admin API: Reports now ordered by newest </details> -- Extracted object hashtags into separate table in order to improve hashtag timeline performance (via background migration in `Pleroma.Migrators.HashtagsTableMigrator`). +- Improved hashtag timeline performance (requires a background migration). ### Added diff --git a/config/config.exs b/config/config.exs index 8a7c466d3..0fbca06f3 100644 --- a/config/config.exs +++ b/config/config.exs @@ -556,7 +556,6 @@ remote_fetcher: 2, attachments_cleanup: 1, new_users_digest: 1, - hashtags_cleanup: 1, mute_expire: 5 ], plugins: [Oban.Plugins.Pruner], diff --git a/config/description.exs b/config/description.exs index 2e96024f5..29fc5fbd4 100644 --- a/config/description.exs +++ b/config/description.exs @@ -473,6 +473,20 @@ } ] }, + %{ + group: :pleroma, + key: :populate_hashtags_table, + type: :group, + description: "`populate_hashtags_table` background migration settings", + children: [ + %{ + key: :sleep_interval_ms, + type: :integer, + description: + "Sleep interval between each chunk of processed records in order to decrease the load on the system (defaults to 0 and should be keep default on most instances)." + } + ] + }, %{ group: :pleroma, key: :instance, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index ad5768465..68a5a3c7f 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -65,6 +65,12 @@ To add configuration to your config file, you can copy it from the base config. * `show_reactions`: Let favourites and emoji reactions be viewed through the API (default: `true`). * `password_reset_token_validity`: The time after which reset tokens aren't accepted anymore, in seconds (default: one day). +## :database +* `improved_hashtag_timeline`: If `true`, hashtags will be fetched from `hashtags` table for hashtags timeline. When `false`, object-embedded hashtags will be used (slower). Is auto-set to `true` (unless overridden) when `HashtagsTableMigrator` completes. + +## Background migrations +* `populate_hashtags_table/sleep_interval_ms`: Sleep interval between each chunk of processed records in order to decrease the load on the system (defaults to 0 and should be keep default on most instances). + ## Welcome * `direct_message`: - welcome message sent as a direct message. * `enabled`: Enables the send a direct message to a newly registered user. Defaults to `false`. diff --git a/lib/pleroma/hashtag.ex b/lib/pleroma/hashtag.ex index de52c4dae..0d6a4d09e 100644 --- a/lib/pleroma/hashtag.ex +++ b/lib/pleroma/hashtag.ex @@ -47,16 +47,20 @@ def get_or_create_by_names(names) when is_list(names) do |> Map.merge(%{inserted_at: timestamp, updated_at: timestamp}) end) - with {:ok, %{query_op: hashtags}} <- - Multi.new() - |> Multi.insert_all(:insert_all_op, Hashtag, structs, on_conflict: :nothing) - |> Multi.run(:query_op, fn _repo, _changes -> - {:ok, Repo.all(from(ht in Hashtag, where: ht.name in ^names))} - end) - |> Repo.transaction() do - {:ok, hashtags} - else - {:error, _name, value, _changes_so_far} -> {:error, value} + try do + with {:ok, %{query_op: hashtags}} <- + Multi.new() + |> Multi.insert_all(:insert_all_op, Hashtag, structs, on_conflict: :nothing) + |> Multi.run(:query_op, fn _repo, _changes -> + {:ok, Repo.all(from(ht in Hashtag, where: ht.name in ^names))} + end) + |> Repo.transaction() do + {:ok, hashtags} + else + {:error, _name, value, _changes_so_far} -> {:error, value} + end + rescue + e -> {:error, e} end end @@ -74,8 +78,9 @@ def unlink(%Object{id: object_id}) do where: hto.object_id == ^object_id, select: hto.hashtag_id ) - |> Repo.delete_all() do - delete_unreferenced(hashtag_ids) + |> Repo.delete_all(), + {:ok, unreferenced_count} <- delete_unreferenced(hashtag_ids) do + {:ok, length(hashtag_ids), unreferenced_count} end end diff --git a/lib/pleroma/migrators/hashtags_table_migrator.ex b/lib/pleroma/migrators/hashtags_table_migrator.ex index c53f6be12..432c3401a 100644 --- a/lib/pleroma/migrators/hashtags_table_migrator.ex +++ b/lib/pleroma/migrators/hashtags_table_migrator.ex @@ -214,15 +214,20 @@ defp transfer_object_hashtags(object, hashtags) do maps = Enum.map(hashtag_records, &%{hashtag_id: &1.id, object_id: object.id}) expected_rows = length(hashtag_records) - with {^expected_rows, _} <- Repo.insert_all("hashtags_objects", maps) do - object.id - else - e -> - error = - "ERROR when inserting #{expected_rows} hashtags_objects " <> - "for object #{object.id}: #{inspect(e)}" + base_error = + "ERROR when inserting #{expected_rows} hashtags_objects for obj. #{object.id}" - Logger.error(error) + try do + with {^expected_rows, _} <- Repo.insert_all("hashtags_objects", maps) do + object.id + else + e -> + Logger.error("#{base_error}: #{inspect(e)}") + Repo.rollback(object.id) + end + rescue + e -> + Logger.error("#{base_error}: #{inspect(e)}") Repo.rollback(object.id) end else From 1dac7d14623f36744953a523650211540d90d1fc Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Mon, 15 Feb 2021 21:13:14 +0300 Subject: [PATCH 043/339] [#3213] Fixed `hashtags.name` lookup (must use `citext` type to do index scan). Fixed embedded hashtags lookup (lowercasing), adjusted tests. --- lib/pleroma/hashtag.ex | 8 +++++-- lib/pleroma/web/activity_pub/activity_pub.ex | 22 ++++++++++++++----- .../web/activity_pub/activity_pub_test.exs | 18 +++++++-------- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/lib/pleroma/hashtag.ex b/lib/pleroma/hashtag.ex index 0d6a4d09e..a6d033816 100644 --- a/lib/pleroma/hashtag.ex +++ b/lib/pleroma/hashtag.ex @@ -22,7 +22,9 @@ defmodule Pleroma.Hashtag do end def get_by_name(name) do - Repo.get_by(Hashtag, name: name) + from(h in Hashtag) + |> where([h], fragment("name = ?::citext", ^String.downcase(name))) + |> Repo.one() end def get_or_create_by_name(name) when is_bitstring(name) do @@ -37,6 +39,7 @@ def get_or_create_by_name(name) when is_bitstring(name) do end def get_or_create_by_names(names) when is_list(names) do + names = Enum.map(names, &String.downcase/1) timestamp = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) structs = @@ -52,7 +55,8 @@ def get_or_create_by_names(names) when is_list(names) do Multi.new() |> Multi.insert_all(:insert_all_op, Hashtag, structs, on_conflict: :nothing) |> Multi.run(:query_op, fn _repo, _changes -> - {:ok, Repo.all(from(ht in Hashtag, where: ht.name in ^names))} + {:ok, + Repo.all(from(ht in Hashtag, where: ht.name in fragment("?::citext[]", ^names)))} end) |> Repo.transaction() do {:ok, hashtags} diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 9623e635a..e012f2779 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -698,6 +698,8 @@ defp restrict_embedded_tag_all(_query, %{tag_all: _tag_all, skip_preload: true}) end defp restrict_embedded_tag_all(query, %{tag_all: [_ | _] = tag_all}) do + tag_all = Enum.map(tag_all, &String.downcase/1) + from( [_activity, object] in query, where: fragment("(?)->'tag' \\?& (?)", object.data, ^tag_all) @@ -714,10 +716,12 @@ defp restrict_embedded_tag_any(_query, %{tag: _tag, skip_preload: true}) do raise_on_missing_preload() end - defp restrict_embedded_tag_any(query, %{tag: [_ | _] = tag}) do + defp restrict_embedded_tag_any(query, %{tag: [_ | _] = tag_any}) do + tag_any = Enum.map(tag_any, &String.downcase/1) + from( [_activity, object] in query, - where: fragment("(?)->'tag' \\?| (?)", object.data, ^tag) + where: fragment("(?)->'tag' \\?| (?)", object.data, ^tag_any) ) end @@ -732,6 +736,8 @@ defp restrict_embedded_tag_reject_any(_query, %{tag_reject: _tag_reject, skip_pr end defp restrict_embedded_tag_reject_any(query, %{tag_reject: [_ | _] = tag_reject}) do + tag_reject = Enum.map(tag_reject, &String.downcase/1) + from( [_activity, object] in query, where: fragment("not (?)->'tag' \\?| (?)", object.data, ^tag_reject) @@ -749,6 +755,10 @@ defp restrict_hashtag_all(_query, %{tag_all: _tag, skip_preload: true}) do raise_on_missing_preload() end + defp restrict_hashtag_all(query, %{tag_all: [single_tag]}) do + restrict_hashtag_any(query, %{tag: single_tag}) + end + defp restrict_hashtag_all(query, %{tag_all: [_ | _] = tags}) do from( [_activity, object] in query, @@ -756,7 +766,7 @@ defp restrict_hashtag_all(query, %{tag_all: [_ | _] = tags}) do fragment( """ (SELECT array_agg(hashtags.name) FROM hashtags JOIN hashtags_objects - ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?) + ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?::citext[]) AND hashtags_objects.object_id = ?) @> ? """, ^tags, @@ -767,7 +777,7 @@ defp restrict_hashtag_all(query, %{tag_all: [_ | _] = tags}) do end defp restrict_hashtag_all(query, %{tag_all: tag}) when is_binary(tag) do - restrict_hashtag_any(query, %{tag: tag}) + restrict_hashtag_all(query, %{tag_all: [tag]}) end defp restrict_hashtag_all(query, _), do: query @@ -783,7 +793,7 @@ defp restrict_hashtag_any(query, %{tag: [_ | _] = tags}) do fragment( """ EXISTS (SELECT 1 FROM hashtags JOIN hashtags_objects - ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?) + ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?::citext[]) AND hashtags_objects.object_id = ? LIMIT 1) """, ^tags, @@ -809,7 +819,7 @@ defp restrict_hashtag_reject_any(query, %{tag_reject: [_ | _] = tags_reject}) do fragment( """ NOT EXISTS (SELECT 1 FROM hashtags JOIN hashtags_objects - ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?) + ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?::citext[]) AND hashtags_objects.object_id = ? LIMIT 1) """, ^tags_reject, diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index bab5a199c..c41c8a5dd 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -213,24 +213,24 @@ test "works for guppe actors" do test "it fetches the appropriate tag-restricted posts" do user = insert(:user) - {:ok, status_one} = CommonAPI.post(user, %{status: ". #test"}) + {:ok, status_one} = CommonAPI.post(user, %{status: ". #TEST"}) {:ok, status_two} = CommonAPI.post(user, %{status: ". #essais"}) - {:ok, status_three} = CommonAPI.post(user, %{status: ". #test #reject"}) + {:ok, status_three} = CommonAPI.post(user, %{status: ". #test #Reject"}) - {:ok, status_four} = CommonAPI.post(user, %{status: ". #any1 #any2"}) - {:ok, status_five} = CommonAPI.post(user, %{status: ". #any2 #any1"}) + {:ok, status_four} = CommonAPI.post(user, %{status: ". #Any1 #any2"}) + {:ok, status_five} = CommonAPI.post(user, %{status: ". #Any2 #any1"}) for hashtag_timeline_strategy <- [true, false] do clear_config([:database, :improved_hashtag_timeline], hashtag_timeline_strategy) fetch_one = ActivityPub.fetch_activities([], %{type: "Create", tag: "test"}) - fetch_two = ActivityPub.fetch_activities([], %{type: "Create", tag: ["test", "essais"]}) + fetch_two = ActivityPub.fetch_activities([], %{type: "Create", tag: ["TEST", "essais"]}) fetch_three = ActivityPub.fetch_activities([], %{ type: "Create", - tag: ["test", "essais"], + tag: ["test", "Essais"], tag_reject: ["reject"] }) @@ -238,21 +238,21 @@ test "it fetches the appropriate tag-restricted posts" do ActivityPub.fetch_activities([], %{ type: "Create", tag: ["test"], - tag_all: ["test", "reject"] + tag_all: ["test", "REJECT"] }) # Testing that deduplication (if needed) is done on DB (not Ecto) level; :limit is important fetch_five = ActivityPub.fetch_activities([], %{ type: "Create", - tag: ["any1", "any2"], + tag: ["ANY1", "any2"], limit: 2 }) fetch_six = ActivityPub.fetch_activities([], %{ type: "Create", - tag: ["any1", "any2"], + tag: ["any1", "Any2"], tag_all: [], tag_reject: [] }) From 938823c73040f6b55896581daf5baf732f859f02 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Tue, 16 Feb 2021 23:14:15 +0300 Subject: [PATCH 044/339] [#3213] HashtagsTableMigrator state management refactoring & improvements (proper stats serialization etc.). --- lib/pleroma/data_migration.ex | 15 ++-- .../migrators/hashtags_table_migrator.ex | 87 ++++++++----------- .../hashtags_table_migrator/state.ex | 87 ++++++++++++++++--- 3 files changed, 122 insertions(+), 67 deletions(-) diff --git a/lib/pleroma/data_migration.ex b/lib/pleroma/data_migration.ex index 64fa155ff..1377af16e 100644 --- a/lib/pleroma/data_migration.ex +++ b/lib/pleroma/data_migration.ex @@ -10,6 +10,7 @@ defmodule Pleroma.DataMigration do alias Pleroma.Repo import Ecto.Changeset + import Ecto.Query schema "data_migrations" do field(:name, :string) @@ -28,14 +29,12 @@ def changeset(data_migration, params \\ %{}) do |> unique_constraint(:name) end - def update(data_migration, params \\ %{}) do - data_migration - |> changeset(params) - |> Repo.update() - end - - def update_state(data_migration, new_state) do - update(data_migration, %{state: new_state}) + def update_one_by_id(id, params \\ %{}) do + with {1, _} <- + from(dm in DataMigration, where: dm.id == ^id) + |> Repo.update_all(set: params) do + :ok + end end def get_by_name(name) do diff --git a/lib/pleroma/migrators/hashtags_table_migrator.ex b/lib/pleroma/migrators/hashtags_table_migrator.ex index 432c3401a..a226d9d29 100644 --- a/lib/pleroma/migrators/hashtags_table_migrator.ex +++ b/lib/pleroma/migrators/hashtags_table_migrator.ex @@ -11,16 +11,16 @@ defmodule Pleroma.Migrators.HashtagsTableMigrator do alias __MODULE__.State alias Pleroma.Config - alias Pleroma.DataMigration alias Pleroma.Hashtag alias Pleroma.Object alias Pleroma.Repo - defdelegate state(), to: State, as: :get - defdelegate put_stat(key, value), to: State, as: :put - defdelegate increment_stat(key, increment), to: State, as: :increment + defdelegate data_migration(), to: State - defdelegate data_migration(), to: DataMigration, as: :populate_hashtags_table + defdelegate state(), to: State + defdelegate get_stat(key, value), to: State, as: :get_data_key + defdelegate put_stat(key, value), to: State, as: :put_data_key + defdelegate increment_stat(key, increment), to: State, as: :increment_data_key @reg_name {:global, __MODULE__} @@ -45,7 +45,7 @@ def init(_) do def handle_continue(:init_state, _state) do {:ok, _} = State.start_link(nil) - update_status(:init) + update_status(:pending) data_migration = data_migration() manual_migrations = Config.get([:instance, :manual_data_migrations], []) @@ -55,13 +55,13 @@ def handle_continue(:init_state, _state) do update_status(:noop) is_nil(data_migration) -> - update_status(:halt, "Data migration does not exist.") + update_status(:failed, "Data migration does not exist.") data_migration.state == :manual or data_migration.name in manual_migrations -> - update_status(:noop, "Data migration is in manual execution state.") + update_status(:manual, "Data migration is in manual execution state.") data_migration.state == :complete -> - handle_success(data_migration) + on_complete(data_migration) true -> send(self(), :migrate_hashtags) @@ -72,20 +72,15 @@ def handle_continue(:init_state, _state) do @impl true def handle_info(:migrate_hashtags, state) do - State.clear() + State.reinit() update_status(:running) put_stat(:started_at, NaiveDateTime.utc_now()) - data_migration = data_migration() - persistent_data = Map.take(data_migration.data, ["max_processed_id"]) + %{id: data_migration_id} = data_migration() + max_processed_id = get_stat(:max_processed_id, 0) - {:ok, data_migration} = - DataMigration.update(data_migration, %{state: :running, data: persistent_data}) - - Logger.info("Starting transferring object embedded hashtags to `hashtags` table...") - - max_processed_id = data_migration.data["max_processed_id"] || 0 + Logger.info("Transferring embedded hashtags to `hashtags` (from oid: #{max_processed_id})...") query() |> where([object], object.id > ^max_processed_id) @@ -104,7 +99,7 @@ def handle_info(:migrate_hashtags, state) do Repo.query( "INSERT INTO data_migration_failed_ids(data_migration_id, record_id) " <> "VALUES ($1, $2) ON CONFLICT DO NOTHING;", - [data_migration.id, failed_id] + [data_migration_id, failed_id] ) end @@ -112,7 +107,7 @@ def handle_info(:migrate_hashtags, state) do Repo.query( "DELETE FROM data_migration_failed_ids " <> "WHERE data_migration_id = $1 AND record_id = ANY($2)", - [data_migration.id, object_ids -- failed_ids] + [data_migration_id, object_ids -- failed_ids] ) max_object_id = Enum.at(object_ids, -1) @@ -120,14 +115,8 @@ def handle_info(:migrate_hashtags, state) do put_stat(:max_processed_id, max_object_id) increment_stat(:processed_count, length(object_ids)) increment_stat(:failed_count, length(failed_ids)) - - put_stat( - :records_per_second, - state()[:processed_count] / - Enum.max([NaiveDateTime.diff(NaiveDateTime.utc_now(), state()[:started_at]), 1]) - ) - - persist_stats(data_migration) + put_stat(:records_per_second, records_per_second()) + _ = State.persist_to_db() # A quick and dirty approach to controlling the load this background migration imposes sleep_interval = Config.get([:populate_hashtags_table, :sleep_interval_ms], 0) @@ -135,22 +124,25 @@ def handle_info(:migrate_hashtags, state) do end) |> Stream.run() - with 0 <- failures_count(data_migration.id) do + with 0 <- failures_count(data_migration_id) do _ = delete_non_create_activities_hashtags() - - {:ok, data_migration} = DataMigration.update_state(data_migration, :complete) - - handle_success(data_migration) + set_complete() else _ -> - _ = DataMigration.update_state(data_migration, :failed) - update_status(:failed, "Please check data_migration_failed_ids records.") end {:noreply, state} end + defp records_per_second do + get_stat(:processed_count, 0) / Enum.max([running_time(), 1]) + end + + defp running_time do + NaiveDateTime.diff(NaiveDateTime.utc_now(), get_stat(:started_at, NaiveDateTime.utc_now())) + end + @hashtags_objects_cleanup_query """ DELETE FROM hashtags_objects WHERE object_id IN (SELECT DISTINCT objects.id FROM objects @@ -169,6 +161,10 @@ def handle_info(:migrate_hashtags, state) do WHERE hashtags_objects.hashtag_id IS NULL); """ + @doc """ + Deletes `hashtags_objects` for legacy objects not asoociated with Create activity. + Also deletes unreferenced `hashtags` records (might occur after deletion of `hashtags_objects`). + """ def delete_non_create_activities_hashtags do {:ok, %{num_rows: hashtags_objects_count}} = Repo.query(@hashtags_objects_cleanup_query, [], timeout: :infinity) @@ -256,14 +252,7 @@ def count(force \\ false, timeout \\ :infinity) do end end - defp persist_stats(data_migration) do - runner_state = Map.drop(state(), [:status]) - _ = DataMigration.update(data_migration, %{data: runner_state}) - end - - defp handle_success(data_migration) do - update_status(:complete) - + defp on_complete(data_migration) do cond do data_migration.feature_lock -> :noop @@ -321,18 +310,18 @@ def force_continue do end def force_restart do - {:ok, _} = DataMigration.update(data_migration(), %{state: :pending, data: %{}}) + :ok = State.reset() force_continue() end - def force_complete do - {:ok, data_migration} = DataMigration.update_state(data_migration(), :complete) - - handle_success(data_migration) + def set_complete do + update_status(:complete) + _ = State.persist_to_db() + on_complete(data_migration()) end defp update_status(status, message \\ nil) do - put_stat(:status, status) + put_stat(:state, status) put_stat(:message, message) end end diff --git a/lib/pleroma/migrators/hashtags_table_migrator/state.ex b/lib/pleroma/migrators/hashtags_table_migrator/state.ex index 901563426..ed9848824 100644 --- a/lib/pleroma/migrators/hashtags_table_migrator/state.ex +++ b/lib/pleroma/migrators/hashtags_table_migrator/state.ex @@ -5,31 +5,98 @@ defmodule Pleroma.Migrators.HashtagsTableMigrator.State do use Agent - @init_state %{} + alias Pleroma.DataMigration + + defdelegate data_migration(), to: DataMigration, as: :populate_hashtags_table + @reg_name {:global, __MODULE__} def start_link(_) do - Agent.start_link(fn -> @init_state end, name: @reg_name) + Agent.start_link(fn -> load_state_from_db() end, name: @reg_name) end - def clear do - Agent.update(@reg_name, fn _state -> @init_state end) + defp load_state_from_db do + data_migration = data_migration() + + data = + if data_migration do + Map.new(data_migration.data, fn {k, v} -> {String.to_atom(k), v} end) + else + %{} + end + + %{ + data_migration_id: data_migration && data_migration.id, + data: data + } end - def get do + def persist_to_db do + %{data_migration_id: data_migration_id, data: data} = state() + + if data_migration_id do + DataMigration.update_one_by_id(data_migration_id, data: data) + else + {:error, :nil_data_migration_id} + end + end + + def reset do + %{data_migration_id: data_migration_id} = state() + + with false <- is_nil(data_migration_id), + :ok <- + DataMigration.update_one_by_id(data_migration_id, + state: :pending, + data: %{} + ) do + reinit() + else + true -> {:error, :nil_data_migration_id} + e -> e + end + end + + def reinit do + Agent.update(@reg_name, fn _state -> load_state_from_db() end) + end + + def state do Agent.get(@reg_name, & &1) end - def put(key, value) do + def get_data_key(key, default \\ nil) do + get_in(state(), [:data, key]) || default + end + + def put_data_key(key, value) do + _ = persist_non_data_change(key, value) + Agent.update(@reg_name, fn state -> - Map.put(state, key, value) + put_in(state, [:data, key], value) end) end - def increment(key, increment \\ 1) do + def increment_data_key(key, increment \\ 1) do Agent.update(@reg_name, fn state -> - updated_value = (state[key] || 0) + increment - Map.put(state, key, updated_value) + initial_value = get_in(state, [:data, key]) || 0 + updated_value = initial_value + increment + put_in(state, [:data, key], updated_value) end) end + + defp persist_non_data_change(:state, value) do + with true <- get_data_key(:state) != value, + true <- value in Pleroma.DataMigration.State.__valid_values__(), + %{data_migration_id: data_migration_id} when not is_nil(data_migration_id) <- state() do + DataMigration.update_one_by_id(data_migration_id, state: value) + else + false -> :ok + _ -> {:error, :nil_data_migration_id} + end + end + + defp persist_non_data_change(_, _) do + nil + end end From 854ea1aefb5ff4e03e9e9af6e8dd50f66c61c913 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Wed, 17 Feb 2021 09:23:35 +0300 Subject: [PATCH 045/339] [#3213] Fixed `HashtagsTableMigrator.count/1`. --- lib/pleroma/migrators/hashtags_table_migrator.ex | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/migrators/hashtags_table_migrator.ex b/lib/pleroma/migrators/hashtags_table_migrator.ex index a226d9d29..ac17f91cc 100644 --- a/lib/pleroma/migrators/hashtags_table_migrator.ex +++ b/lib/pleroma/migrators/hashtags_table_migrator.ex @@ -18,7 +18,8 @@ defmodule Pleroma.Migrators.HashtagsTableMigrator do defdelegate data_migration(), to: State defdelegate state(), to: State - defdelegate get_stat(key, value), to: State, as: :get_data_key + defdelegate persist_state(), to: State, as: :persist_to_db + defdelegate get_stat(key, value \\ nil), to: State, as: :get_data_key defdelegate put_stat(key, value), to: State, as: :put_data_key defdelegate increment_stat(key, increment), to: State, as: :increment_data_key @@ -116,7 +117,7 @@ def handle_info(:migrate_hashtags, state) do increment_stat(:processed_count, length(object_ids)) increment_stat(:failed_count, length(failed_ids)) put_stat(:records_per_second, records_per_second()) - _ = State.persist_to_db() + persist_state() # A quick and dirty approach to controlling the load this background migration imposes sleep_interval = Config.get([:populate_hashtags_table, :sleep_interval_ms], 0) @@ -237,17 +238,19 @@ defp transfer_object_hashtags(object, hashtags) do @doc "Approximate count for current iteration (including processed records count)" def count(force \\ false, timeout \\ :infinity) do - stored_count = state()[:count] + stored_count = get_stat(:count) if stored_count && !force do stored_count else - processed_count = state()[:processed_count] || 0 - max_processed_id = data_migration().data["max_processed_id"] || 0 + processed_count = get_stat(:processed_count, 0) + max_processed_id = get_stat(:max_processed_id, 0) query = where(query(), [object], object.id > ^max_processed_id) count = Repo.aggregate(query, :count, :id, timeout: timeout) + processed_count put_stat(:count, count) + persist_state() + count end end @@ -316,7 +319,7 @@ def force_restart do def set_complete do update_status(:complete) - _ = State.persist_to_db() + persist_state() on_complete(data_migration()) end From b981edad8a7d8f27b231bc6164fc0546efbdb646 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Thu, 18 Feb 2021 20:40:10 +0300 Subject: [PATCH 046/339] [#3213] HashtagsTableMigrator: fault rate allowance to enable the feature (defaults to 1%), counting of affected objects, misc. tweaks. --- config/config.exs | 2 + config/description.exs | 7 ++ docs/configuration/cheatsheet.md | 1 + .../migrators/hashtags_table_migrator.ex | 101 ++++++++++++------ .../hashtags_table_migrator/state.ex | 4 +- 5 files changed, 84 insertions(+), 31 deletions(-) diff --git a/config/config.exs b/config/config.exs index 0fbca06f3..c371c397c 100644 --- a/config/config.exs +++ b/config/config.exs @@ -657,6 +657,8 @@ config :pleroma, :database, rum_enabled: false +config :pleroma, :populate_hashtags_table, fault_rate_allowance: 0.01 + config :pleroma, :env, Mix.env() config :http_signatures, diff --git a/config/description.exs b/config/description.exs index 29fc5fbd4..6ffc71278 100644 --- a/config/description.exs +++ b/config/description.exs @@ -479,6 +479,13 @@ type: :group, description: "`populate_hashtags_table` background migration settings", children: [ + %{ + key: :fault_rate_allowance, + type: :float, + description: + "Max rate of failed objects to actually processed objects in order to enable the feature (any value from 0.0 which tolerates no errors to 1.0 which will enable the feature even if hashtags transfer failed for all records).", + suggestions: [0.01] + }, %{ key: :sleep_interval_ms, type: :integer, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 68a5a3c7f..6a1031f15 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -70,6 +70,7 @@ To add configuration to your config file, you can copy it from the base config. ## Background migrations * `populate_hashtags_table/sleep_interval_ms`: Sleep interval between each chunk of processed records in order to decrease the load on the system (defaults to 0 and should be keep default on most instances). +* `populate_hashtags_table/fault_rate_allowance`: Max rate of failed objects to actually processed objects in order to enable the feature (any value from 0.0 which tolerates no errors to 1.0 which will enable the feature even if hashtags transfer failed for all records). ## Welcome * `direct_message`: - welcome message sent as a direct message. diff --git a/lib/pleroma/migrators/hashtags_table_migrator.ex b/lib/pleroma/migrators/hashtags_table_migrator.ex index ac17f91cc..45dab8470 100644 --- a/lib/pleroma/migrators/hashtags_table_migrator.ex +++ b/lib/pleroma/migrators/hashtags_table_migrator.ex @@ -15,7 +15,8 @@ defmodule Pleroma.Migrators.HashtagsTableMigrator do alias Pleroma.Object alias Pleroma.Repo - defdelegate data_migration(), to: State + defdelegate data_migration(), to: Pleroma.DataMigration, as: :populate_hashtags_table + defdelegate data_migration_id(), to: State defdelegate state(), to: State defdelegate persist_state(), to: State, as: :persist_to_db @@ -23,10 +24,13 @@ defmodule Pleroma.Migrators.HashtagsTableMigrator do defdelegate put_stat(key, value), to: State, as: :put_data_key defdelegate increment_stat(key, increment), to: State, as: :increment_data_key + @feature_config_path [:database, :improved_hashtag_timeline] @reg_name {:global, __MODULE__} def whereis, do: GenServer.whereis(@reg_name) + def feature_state, do: Config.get(@feature_config_path) + def start_link(_) do case whereis() do nil -> @@ -46,8 +50,6 @@ def init(_) do def handle_continue(:init_state, _state) do {:ok, _} = State.start_link(nil) - update_status(:pending) - data_migration = data_migration() manual_migrations = Config.get([:instance, :manual_data_migrations], []) @@ -56,10 +58,14 @@ def handle_continue(:init_state, _state) do update_status(:noop) is_nil(data_migration) -> - update_status(:failed, "Data migration does not exist.") + message = "Data migration does not exist." + update_status(:failed, message) + Logger.error("#{__MODULE__}: #{message}") data_migration.state == :manual or data_migration.name in manual_migrations -> - update_status(:manual, "Data migration is in manual execution state.") + message = "Data migration is in manual execution or manual fix mode." + update_status(:manual, message) + Logger.warn("#{__MODULE__}: #{message}") data_migration.state == :complete -> on_complete(data_migration) @@ -78,7 +84,7 @@ def handle_info(:migrate_hashtags, state) do update_status(:running) put_stat(:started_at, NaiveDateTime.utc_now()) - %{id: data_migration_id} = data_migration() + data_migration_id = data_migration_id() max_processed_id = get_stat(:max_processed_id, 0) Logger.info("Transferring embedded hashtags to `hashtags` (from oid: #{max_processed_id})...") @@ -89,12 +95,19 @@ def handle_info(:migrate_hashtags, state) do |> Stream.each(fn objects -> object_ids = Enum.map(objects, & &1.id) + results = Enum.map(objects, &transfer_object_hashtags(&1)) + failed_ids = - objects - |> Enum.map(&transfer_object_hashtags(&1)) + results |> Enum.filter(&(elem(&1, 0) == :error)) |> Enum.map(&elem(&1, 1)) + # Count of objects with hashtags (`{:noop, id}` is returned for objects having other AS2 tags) + chunk_affected_count = + results + |> Enum.filter(&(elem(&1, 0) == :ok)) + |> length() + for failed_id <- failed_ids do _ = Repo.query( @@ -116,6 +129,7 @@ def handle_info(:migrate_hashtags, state) do put_stat(:max_processed_id, max_object_id) increment_stat(:processed_count, length(object_ids)) increment_stat(:failed_count, length(failed_ids)) + increment_stat(:affected_count, chunk_affected_count) put_stat(:records_per_second, records_per_second()) persist_state() @@ -125,17 +139,42 @@ def handle_info(:migrate_hashtags, state) do end) |> Stream.run() - with 0 <- failures_count(data_migration_id) do - _ = delete_non_create_activities_hashtags() - set_complete() - else - _ -> - update_status(:failed, "Please check data_migration_failed_ids records.") + fault_rate = fault_rate() + put_stat(:fault_rate, fault_rate) + fault_rate_allowance = Config.get([:populate_hashtags_table, :fault_rate_allowance], 0) + + cond do + fault_rate == 0 -> + set_complete() + + is_float(fault_rate) and fault_rate <= fault_rate_allowance -> + message = """ + Done with fault rate of #{fault_rate} which doesn't exceed #{fault_rate_allowance}. + Putting data migration to manual fix mode. Check `retry_failed/0`. + """ + + Logger.warn("#{__MODULE__}: #{message}") + update_status(:manual, message) + on_complete(data_migration()) + + true -> + message = "Too many failures. Check data_migration_failed_ids records / `retry_failed/0`." + Logger.error("#{__MODULE__}: #{message}") + update_status(:failed, message) end + persist_state() {:noreply, state} end + def fault_rate do + with failures_count when is_integer(failures_count) <- failures_count() do + failures_count / Enum.max([get_stat(:affected_count, 0), 1]) + else + _ -> :error + end + end + defp records_per_second do get_stat(:processed_count, 0) / Enum.max([running_time(), 1]) end @@ -194,6 +233,7 @@ defp query do |> where([_o, hashtags_objects], is_nil(hashtags_objects.object_id)) end + @spec transfer_object_hashtags(Map.t()) :: {:noop | :ok | :error, integer()} defp transfer_object_hashtags(object) do embedded_tags = if Map.has_key?(object, :tag), do: object.tag, else: object.data["tag"] hashtags = Object.object_data_hashtags(%{"tag" => embedded_tags}) @@ -201,7 +241,7 @@ defp transfer_object_hashtags(object) do if Enum.any?(hashtags) do transfer_object_hashtags(object, hashtags) else - {:ok, object.id} + {:noop, object.id} end end @@ -209,13 +249,11 @@ defp transfer_object_hashtags(object, hashtags) do Repo.transaction(fn -> with {:ok, hashtag_records} <- Hashtag.get_or_create_by_names(hashtags) do maps = Enum.map(hashtag_records, &%{hashtag_id: &1.id, object_id: object.id}) - expected_rows = length(hashtag_records) - - base_error = - "ERROR when inserting #{expected_rows} hashtags_objects for obj. #{object.id}" + base_error = "ERROR when inserting hashtags_objects for object with id #{object.id}" try do - with {^expected_rows, _} <- Repo.insert_all("hashtags_objects", maps) do + with {rows_count, _} when is_integer(rows_count) <- + Repo.insert_all("hashtags_objects", maps, on_conflict: :nothing) do object.id else e -> @@ -260,11 +298,11 @@ defp on_complete(data_migration) do data_migration.feature_lock -> :noop - not is_nil(Config.get([:database, :improved_hashtag_timeline])) -> + not is_nil(feature_state()) -> :noop true -> - Config.put([:database, :improved_hashtag_timeline], true) + Config.put(@feature_config_path, true) :ok end end @@ -274,38 +312,41 @@ def failed_objects_query do |> join(:inner, [o], dmf in fragment("SELECT * FROM data_migration_failed_ids"), on: dmf.record_id == o.id ) - |> where([_o, dmf], dmf.data_migration_id == ^data_migration().id) + |> where([_o, dmf], dmf.data_migration_id == ^data_migration_id()) |> order_by([o], asc: o.id) end - def failures_count(data_migration_id \\ nil) do - data_migration_id = data_migration_id || data_migration().id - + def failures_count do with {:ok, %{rows: [[count]]}} <- Repo.query( "SELECT COUNT(record_id) FROM data_migration_failed_ids WHERE data_migration_id = $1;", - [data_migration_id] + [data_migration_id()] ) do count end end def retry_failed do - data_migration = data_migration() + data_migration_id = data_migration_id() failed_objects_query() |> Repo.chunk_stream(100, :one) |> Stream.each(fn object -> - with {:ok, _} <- transfer_object_hashtags(object) do + with {res, _} when res != :error <- transfer_object_hashtags(object) do _ = Repo.query( "DELETE FROM data_migration_failed_ids " <> "WHERE data_migration_id = $1 AND record_id = $2", - [data_migration.id, object.id] + [data_migration_id, object.id] ) end end) |> Stream.run() + + put_stat(:failed_count, failures_count()) + persist_state() + + force_continue() end def force_continue do diff --git a/lib/pleroma/migrators/hashtags_table_migrator/state.ex b/lib/pleroma/migrators/hashtags_table_migrator/state.ex index ed9848824..ee0009b2e 100644 --- a/lib/pleroma/migrators/hashtags_table_migrator/state.ex +++ b/lib/pleroma/migrators/hashtags_table_migrator/state.ex @@ -7,7 +7,7 @@ defmodule Pleroma.Migrators.HashtagsTableMigrator.State do alias Pleroma.DataMigration - defdelegate data_migration(), to: DataMigration, as: :populate_hashtags_table + defdelegate data_migration(), to: Pleroma.Migrators.HashtagsTableMigrator @reg_name {:global, __MODULE__} @@ -99,4 +99,6 @@ defp persist_non_data_change(:state, value) do defp persist_non_data_change(_, _) do nil end + + def data_migration_id, do: Map.get(state(), :data_migration_id) end From 998437d4a4111055e019f28dd84a8af1f9a27047 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Thu, 18 Feb 2021 21:03:06 +0300 Subject: [PATCH 047/339] [#3213] Experimental / debug feature: `database: [improved_hashtag_timeline: :preselect_hashtag_ids]`. --- lib/pleroma/web/activity_pub/activity_pub.ex | 47 +++++++++++++++----- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index e012f2779..5392ce7c9 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -787,19 +787,42 @@ defp restrict_hashtag_any(_query, %{tag: _tag, skip_preload: true}) do end defp restrict_hashtag_any(query, %{tag: [_ | _] = tags}) do - from( - [_activity, object] in query, - where: - fragment( - """ - EXISTS (SELECT 1 FROM hashtags JOIN hashtags_objects - ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?::citext[]) - AND hashtags_objects.object_id = ? LIMIT 1) - """, - ^tags, - object.id + # TODO: refactor: debug / experimental feature + if Config.get([:database, :improved_hashtag_timeline]) == :preselect_hashtag_ids do + hashtag_ids = + from(ht in Pleroma.Hashtag, + where: fragment("name = ANY(?::citext[])", ^tags), + select: ht.id ) - ) + |> Repo.all() + + from( + [_activity, object] in query, + where: + fragment( + """ + EXISTS ( + SELECT 1 FROM hashtags_objects WHERE hashtag_id = ANY(?) AND object_id = ? LIMIT 1) + """, + ^hashtag_ids, + object.id + ) + ) + else + from( + [_activity, object] in query, + where: + fragment( + """ + EXISTS (SELECT 1 FROM hashtags JOIN hashtags_objects + ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?::citext[]) + AND hashtags_objects.object_id = ? LIMIT 1) + """, + ^tags, + object.id + ) + ) + end end defp restrict_hashtag_any(query, %{tag: tag}) when is_binary(tag) do From 6531eddf361fa52db3906ab011a4e33c7a5f9552 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Mon, 22 Feb 2021 23:26:07 +0300 Subject: [PATCH 048/339] [#3213] `hashtags`: altered `name` type to `text`. `hashtags_objects`: removed unused index. HashtagsTableMigrator: records_per_second calculation fix. ActivityPub: hashtags-related options normalization. --- lib/pleroma/hashtag.ex | 22 ++++-- .../migrators/hashtags_table_migrator.ex | 4 +- lib/pleroma/web/activity_pub/activity_pub.ex | 75 ++++++++----------- ...20201221203824_create_hashtags_objects.exs | 2 +- ...emove_hashtags_objects_duplicate_index.exs | 11 +++ ...222184616_change_hashtags_name_to_text.exs | 15 ++++ 6 files changed, 76 insertions(+), 53 deletions(-) create mode 100644 priv/repo/migrations/20210222183840_remove_hashtags_objects_duplicate_index.exs create mode 100644 priv/repo/migrations/20210222184616_change_hashtags_name_to_text.exs diff --git a/lib/pleroma/hashtag.ex b/lib/pleroma/hashtag.ex index a6d033816..e9d143fb1 100644 --- a/lib/pleroma/hashtag.ex +++ b/lib/pleroma/hashtag.ex @@ -21,10 +21,14 @@ defmodule Pleroma.Hashtag do timestamps() end + def normalize_name(name) do + name + |> String.downcase() + |> String.trim() + end + def get_by_name(name) do - from(h in Hashtag) - |> where([h], fragment("name = ?::citext", ^String.downcase(name))) - |> Repo.one() + Repo.get_by(Hashtag, name: normalize_name(name)) end def get_or_create_by_name(name) when is_bitstring(name) do @@ -39,7 +43,7 @@ def get_or_create_by_name(name) when is_bitstring(name) do end def get_or_create_by_names(names) when is_list(names) do - names = Enum.map(names, &String.downcase/1) + names = Enum.map(names, &normalize_name/1) timestamp = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) structs = @@ -53,10 +57,12 @@ def get_or_create_by_names(names) when is_list(names) do try do with {:ok, %{query_op: hashtags}} <- Multi.new() - |> Multi.insert_all(:insert_all_op, Hashtag, structs, on_conflict: :nothing) + |> Multi.insert_all(:insert_all_op, Hashtag, structs, + on_conflict: :nothing, + conflict_target: :name + ) |> Multi.run(:query_op, fn _repo, _changes -> - {:ok, - Repo.all(from(ht in Hashtag, where: ht.name in fragment("?::citext[]", ^names)))} + {:ok, Repo.all(from(ht in Hashtag, where: ht.name in ^names))} end) |> Repo.transaction() do {:ok, hashtags} @@ -71,7 +77,7 @@ def get_or_create_by_names(names) when is_list(names) do def changeset(%Hashtag{} = struct, params) do struct |> cast(params, [:name]) - |> update_change(:name, &String.downcase/1) + |> update_change(:name, &normalize_name/1) |> validate_required([:name]) |> unique_constraint(:name) end diff --git a/lib/pleroma/migrators/hashtags_table_migrator.ex b/lib/pleroma/migrators/hashtags_table_migrator.ex index 45dab8470..07bb9aeb2 100644 --- a/lib/pleroma/migrators/hashtags_table_migrator.ex +++ b/lib/pleroma/migrators/hashtags_table_migrator.ex @@ -82,6 +82,7 @@ def handle_info(:migrate_hashtags, state) do State.reinit() update_status(:running) + put_stat(:iteration_processed_count, 0) put_stat(:started_at, NaiveDateTime.utc_now()) data_migration_id = data_migration_id() @@ -127,6 +128,7 @@ def handle_info(:migrate_hashtags, state) do max_object_id = Enum.at(object_ids, -1) put_stat(:max_processed_id, max_object_id) + increment_stat(:iteration_processed_count, length(object_ids)) increment_stat(:processed_count, length(object_ids)) increment_stat(:failed_count, length(failed_ids)) increment_stat(:affected_count, chunk_affected_count) @@ -176,7 +178,7 @@ def fault_rate do end defp records_per_second do - get_stat(:processed_count, 0) / Enum.max([running_time(), 1]) + get_stat(:iteration_processed_count, 0) / Enum.max([running_time(), 1]) end defp running_time do diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 5392ce7c9..8182bc205 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do alias Pleroma.Conversation alias Pleroma.Conversation.Participation alias Pleroma.Filter + alias Pleroma.Hashtag alias Pleroma.Maps alias Pleroma.Notification alias Pleroma.Object @@ -698,8 +699,6 @@ defp restrict_embedded_tag_all(_query, %{tag_all: _tag_all, skip_preload: true}) end defp restrict_embedded_tag_all(query, %{tag_all: [_ | _] = tag_all}) do - tag_all = Enum.map(tag_all, &String.downcase/1) - from( [_activity, object] in query, where: fragment("(?)->'tag' \\?& (?)", object.data, ^tag_all) @@ -717,8 +716,6 @@ defp restrict_embedded_tag_any(_query, %{tag: _tag, skip_preload: true}) do end defp restrict_embedded_tag_any(query, %{tag: [_ | _] = tag_any}) do - tag_any = Enum.map(tag_any, &String.downcase/1) - from( [_activity, object] in query, where: fragment("(?)->'tag' \\?| (?)", object.data, ^tag_any) @@ -736,8 +733,6 @@ defp restrict_embedded_tag_reject_any(_query, %{tag_reject: _tag_reject, skip_pr end defp restrict_embedded_tag_reject_any(query, %{tag_reject: [_ | _] = tag_reject}) do - tag_reject = Enum.map(tag_reject, &String.downcase/1) - from( [_activity, object] in query, where: fragment("not (?)->'tag' \\?| (?)", object.data, ^tag_reject) @@ -766,7 +761,7 @@ defp restrict_hashtag_all(query, %{tag_all: [_ | _] = tags}) do fragment( """ (SELECT array_agg(hashtags.name) FROM hashtags JOIN hashtags_objects - ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?::citext[]) + ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?) AND hashtags_objects.object_id = ?) @> ? """, ^tags, @@ -787,42 +782,19 @@ defp restrict_hashtag_any(_query, %{tag: _tag, skip_preload: true}) do end defp restrict_hashtag_any(query, %{tag: [_ | _] = tags}) do - # TODO: refactor: debug / experimental feature - if Config.get([:database, :improved_hashtag_timeline]) == :preselect_hashtag_ids do - hashtag_ids = - from(ht in Pleroma.Hashtag, - where: fragment("name = ANY(?::citext[])", ^tags), - select: ht.id + from( + [_activity, object] in query, + where: + fragment( + """ + EXISTS (SELECT 1 FROM hashtags JOIN hashtags_objects + ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?) + AND hashtags_objects.object_id = ? LIMIT 1) + """, + ^tags, + object.id ) - |> Repo.all() - - from( - [_activity, object] in query, - where: - fragment( - """ - EXISTS ( - SELECT 1 FROM hashtags_objects WHERE hashtag_id = ANY(?) AND object_id = ? LIMIT 1) - """, - ^hashtag_ids, - object.id - ) - ) - else - from( - [_activity, object] in query, - where: - fragment( - """ - EXISTS (SELECT 1 FROM hashtags JOIN hashtags_objects - ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?::citext[]) - AND hashtags_objects.object_id = ? LIMIT 1) - """, - ^tags, - object.id - ) - ) - end + ) end defp restrict_hashtag_any(query, %{tag: tag}) when is_binary(tag) do @@ -842,7 +814,7 @@ defp restrict_hashtag_reject_any(query, %{tag_reject: [_ | _] = tags_reject}) do fragment( """ NOT EXISTS (SELECT 1 FROM hashtags JOIN hashtags_objects - ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?::citext[]) + ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?) AND hashtags_objects.object_id = ? LIMIT 1) """, ^tags_reject, @@ -1220,6 +1192,21 @@ defp maybe_order(query, %{order: :asc}) do defp maybe_order(query, _), do: query + defp normalize_fetch_activities_query_opts(opts) do + Enum.reduce([:tag, :tag_all, :tag_reject], opts, fn key, opts -> + case opts[key] do + value when is_bitstring(value) -> + Map.put(opts, key, Hashtag.normalize_name(value)) + + value when is_list(value) -> + Map.put(opts, key, Enum.map(value, &Hashtag.normalize_name/1)) + + _ -> + opts + end + end) + end + defp fetch_activities_query_ap_ids_ops(opts) do source_user = opts[:muting_user] ap_id_relationships = if source_user, do: [:mute, :reblog_mute], else: [] @@ -1243,6 +1230,8 @@ defp fetch_activities_query_ap_ids_ops(opts) do end def fetch_activities_query(recipients, opts \\ %{}) do + opts = normalize_fetch_activities_query_opts(opts) + {restrict_blocked_opts, restrict_muted_opts, restrict_muted_reblogs_opts} = fetch_activities_query_ap_ids_ops(opts) diff --git a/priv/repo/migrations/20201221203824_create_hashtags_objects.exs b/priv/repo/migrations/20201221203824_create_hashtags_objects.exs index efd60369d..581f32b3c 100644 --- a/priv/repo/migrations/20201221203824_create_hashtags_objects.exs +++ b/priv/repo/migrations/20201221203824_create_hashtags_objects.exs @@ -7,7 +7,7 @@ def change do add(:object_id, references(:objects), null: false, primary_key: true) end - create_if_not_exists(unique_index(:hashtags_objects, [:hashtag_id, :object_id])) + # Note: PK index: "hashtags_objects_pkey" PRIMARY KEY, btree (hashtag_id, object_id) create_if_not_exists(index(:hashtags_objects, [:object_id])) end end diff --git a/priv/repo/migrations/20210222183840_remove_hashtags_objects_duplicate_index.exs b/priv/repo/migrations/20210222183840_remove_hashtags_objects_duplicate_index.exs new file mode 100644 index 000000000..6c4a2dfdc --- /dev/null +++ b/priv/repo/migrations/20210222183840_remove_hashtags_objects_duplicate_index.exs @@ -0,0 +1,11 @@ +defmodule Pleroma.Repo.Migrations.RemoveHashtagsObjectsDuplicateIndex do + use Ecto.Migration + + @moduledoc "Removes `hashtags_objects_hashtag_id_object_id_index` index (duplicate of PK index)." + + def up do + drop_if_exists(unique_index(:hashtags_objects, [:hashtag_id, :object_id])) + end + + def down, do: nil +end diff --git a/priv/repo/migrations/20210222184616_change_hashtags_name_to_text.exs b/priv/repo/migrations/20210222184616_change_hashtags_name_to_text.exs new file mode 100644 index 000000000..8940b6ca3 --- /dev/null +++ b/priv/repo/migrations/20210222184616_change_hashtags_name_to_text.exs @@ -0,0 +1,15 @@ +defmodule Pleroma.Repo.Migrations.ChangeHashtagsNameToText do + use Ecto.Migration + + def up do + alter table(:hashtags) do + modify(:name, :text) + end + end + + def down do + alter table(:hashtags) do + modify(:name, :citext) + end + end +end From a98c4423f374c6be8202ae884989e708e7d8ca3b Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantbusiness@gmail.com> Date: Mon, 22 Feb 2021 20:41:57 +0000 Subject: [PATCH 049/339] Apply i1t's suggestion(s) to 1 file(s) --- config/description.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/description.exs b/config/description.exs index 6ffc71278..e280ed8cf 100644 --- a/config/description.exs +++ b/config/description.exs @@ -483,7 +483,7 @@ key: :fault_rate_allowance, type: :float, description: - "Max rate of failed objects to actually processed objects in order to enable the feature (any value from 0.0 which tolerates no errors to 1.0 which will enable the feature even if hashtags transfer failed for all records).", + "Max accepted rate of objects that failed in the migration. Any value from 0.0 which tolerates no errors to 1.0 which will enable the feature even if hashtags transfer failed for all records.", suggestions: [0.01] }, %{ From 77f3da035894e2add911101466bfe41b99ee481e Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Tue, 23 Feb 2021 13:52:28 +0300 Subject: [PATCH 050/339] [#3213] Misc. tweaks: proper upsert in Hashtag, better feature toggle management. --- config/config.exs | 2 ++ config/description.exs | 9 +++++---- docs/configuration/cheatsheet.md | 2 +- lib/pleroma/config.ex | 4 ++++ lib/pleroma/hashtag.ex | 20 ++++++++----------- .../migrators/hashtags_table_migrator.ex | 18 +++++++---------- lib/pleroma/web/activity_pub/activity_pub.ex | 2 +- .../web/activity_pub/activity_pub_test.exs | 4 ++-- 8 files changed, 30 insertions(+), 31 deletions(-) diff --git a/config/config.exs b/config/config.exs index c371c397c..05acbf169 100644 --- a/config/config.exs +++ b/config/config.exs @@ -657,6 +657,8 @@ config :pleroma, :database, rum_enabled: false +config :pleroma, :features, improved_hashtag_timeline: :auto + config :pleroma, :populate_hashtags_table, fault_rate_allowance: 0.01 config :pleroma, :env, Mix.env() diff --git a/config/description.exs b/config/description.exs index e280ed8cf..41e5e4056 100644 --- a/config/description.exs +++ b/config/description.exs @@ -461,15 +461,16 @@ }, %{ group: :pleroma, - key: :database, + key: :features, type: :group, - description: "Database-related settings", + description: "Customizable features", children: [ %{ key: :improved_hashtag_timeline, - type: :keyword, + type: {:dropdown, :atom}, description: - "If `true`, hashtags will be fetched from `hashtags` table for hashtags timeline. When `false`, object-embedded hashtags will be used (slower). Is auto-set to `true` (unless overridden) when HashtagsTableMigrator completes." + "Setting to force toggle / force disable improved hashtags timeline. `:enabled` forces hashtags to be fetched from `hashtags` table for hashtags timeline. `:disabled` forces object-embedded hashtags to be used (slower). Keep it `:auto` for automatic behaviour (it is auto-set to `:enabled` [unless overridden] when HashtagsTableMigrator completes).", + suggestions: [:auto, :enabled, :disabled] } ] }, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 6a1031f15..db1deb665 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -66,7 +66,7 @@ To add configuration to your config file, you can copy it from the base config. * `password_reset_token_validity`: The time after which reset tokens aren't accepted anymore, in seconds (default: one day). ## :database -* `improved_hashtag_timeline`: If `true`, hashtags will be fetched from `hashtags` table for hashtags timeline. When `false`, object-embedded hashtags will be used (slower). Is auto-set to `true` (unless overridden) when `HashtagsTableMigrator` completes. +* `improved_hashtag_timeline`: Setting to force toggle / force disable improved hashtags timeline. `:enabled` forces hashtags to be fetched from `hashtags` table for hashtags timeline. `:disabled` forces object-embedded hashtags to be used (slower). Keep it `:auto` for automatic behaviour (it is auto-set to `:enabled` [unless overridden] when HashtagsTableMigrator completes). ## Background migrations * `populate_hashtags_table/sleep_interval_ms`: Sleep interval between each chunk of processed records in order to decrease the load on the system (defaults to 0 and should be keep default on most instances). diff --git a/lib/pleroma/config.ex b/lib/pleroma/config.ex index f17e14128..e057d8c02 100644 --- a/lib/pleroma/config.ex +++ b/lib/pleroma/config.ex @@ -111,4 +111,8 @@ def oauth_admin_scopes(scopes) when is_list(scopes) do end ) end + + def feature_enabled?(feature_name) do + get([:features, feature_name]) not in [nil, false, :disabled, :auto] + end end diff --git a/lib/pleroma/hashtag.ex b/lib/pleroma/hashtag.ex index e9d143fb1..53e2e9c89 100644 --- a/lib/pleroma/hashtag.ex +++ b/lib/pleroma/hashtag.ex @@ -27,19 +27,15 @@ def normalize_name(name) do |> String.trim() end - def get_by_name(name) do - Repo.get_by(Hashtag, name: normalize_name(name)) - end + def get_or_create_by_name(name) do + changeset = changeset(%Hashtag{}, %{name: name}) - def get_or_create_by_name(name) when is_bitstring(name) do - with %Hashtag{} = hashtag <- get_by_name(name) do - {:ok, hashtag} - else - _ -> - %Hashtag{} - |> changeset(%{name: name}) - |> Repo.insert() - end + Repo.insert( + changeset, + on_conflict: [set: [name: get_field(changeset, :name)]], + conflict_target: :name, + returning: true + ) end def get_or_create_by_names(names) when is_list(names) do diff --git a/lib/pleroma/migrators/hashtags_table_migrator.ex b/lib/pleroma/migrators/hashtags_table_migrator.ex index 07bb9aeb2..6123c88e0 100644 --- a/lib/pleroma/migrators/hashtags_table_migrator.ex +++ b/lib/pleroma/migrators/hashtags_table_migrator.ex @@ -24,7 +24,7 @@ defmodule Pleroma.Migrators.HashtagsTableMigrator do defdelegate put_stat(key, value), to: State, as: :put_data_key defdelegate increment_stat(key, increment), to: State, as: :increment_data_key - @feature_config_path [:database, :improved_hashtag_timeline] + @feature_config_path [:features, :improved_hashtag_timeline] @reg_name {:global, __MODULE__} def whereis, do: GenServer.whereis(@reg_name) @@ -296,16 +296,12 @@ def count(force \\ false, timeout \\ :infinity) do end defp on_complete(data_migration) do - cond do - data_migration.feature_lock -> - :noop - - not is_nil(feature_state()) -> - :noop - - true -> - Config.put(@feature_config_path, true) - :ok + if data_migration.feature_lock || feature_state() == :disabled do + Logger.warn("#{__MODULE__}: migration complete but feature is locked; consider enabling.") + :noop + else + Config.put(@feature_config_path, :enabled) + :ok end end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 8182bc205..9d557c2cd 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1273,7 +1273,7 @@ def fetch_activities_query(recipients, opts \\ %{}) do |> exclude_invisible_actors(opts) |> exclude_visibility(opts) - if Config.get([:database, :improved_hashtag_timeline]) do + if Config.feature_enabled?(:improved_hashtag_timeline) do query |> restrict_hashtag_any(opts) |> restrict_hashtag_all(opts) diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index c41c8a5dd..f92323abe 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -220,8 +220,8 @@ test "it fetches the appropriate tag-restricted posts" do {:ok, status_four} = CommonAPI.post(user, %{status: ". #Any1 #any2"}) {:ok, status_five} = CommonAPI.post(user, %{status: ". #Any2 #any1"}) - for hashtag_timeline_strategy <- [true, false] do - clear_config([:database, :improved_hashtag_timeline], hashtag_timeline_strategy) + for hashtag_timeline_strategy <- [:eanbled, :disabled] do + clear_config([:features, :improved_hashtag_timeline], hashtag_timeline_strategy) fetch_one = ActivityPub.fetch_activities([], %{type: "Create", tag: "test"}) From 40d4362261abaf0856a1b4397a4bff6344137120 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Tue, 23 Feb 2021 18:11:25 +0300 Subject: [PATCH 051/339] [#3213] `mix pleroma.database rollback` tweaks. --- lib/mix/tasks/pleroma/database.ex | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index 2136ddb02..e7f4b67a4 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -231,19 +231,18 @@ def run(["rollback", version]) do re = ~r/^#{version}_.*\.exs/ path = Ecto.Migrator.migrations_path(repo) - with {:find, "" <> file} <- {:find, Enum.find(File.ls!(path), &String.match?(&1, re))}, - {:compile, [{mod, _} | _]} <- {:compile, Code.compile_file(Path.join(path, file))}, - {:rollback, :ok} <- {:rollback, Ecto.Migrator.down(repo, version, mod)} do + with {_, "" <> file} <- {:find, Enum.find(File.ls!(path), &String.match?(&1, re))}, + {_, [{mod, _} | _]} <- {:compile, Code.compile_file(Path.join(path, file))}, + {_, :ok} <- {:rollback, Ecto.Migrator.down(repo, version, mod)} do {:ok, "Reversed migration: #{file}"} else {:find, _} -> {:error, "No migration found with version prefix: #{version}"} {:compile, e} -> {:error, "Problem compiling migration module: #{inspect(e)}"} {:rollback, e} -> {:error, "Problem reversing migration: #{inspect(e)}"} - e -> {:error, "Something unexpected happened: #{inspect(e)}"} end end) - IO.inspect(result) + shell_info(inspect(result)) end end end From 3bc7d122712b5cc35ba509542bde63ca130d6a40 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Mon, 28 Dec 2020 23:21:53 +0100 Subject: [PATCH 052/339] Remove sensitive-property setting #nsfw, create HashtagPolicy --- CHANGELOG.md | 2 +- config/config.exs | 5 + docs/configuration/cheatsheet.md | 10 ++ lib/pleroma/web/activity_pub/mrf.ex | 4 +- .../web/activity_pub/mrf/hashtag_policy.ex | 116 ++++++++++++++++++ .../web/activity_pub/mrf/simple_policy.ex | 12 +- .../web/activity_pub/mrf/tag_policy.ex | 13 +- .../web/activity_pub/transmogrifier.ex | 11 -- lib/pleroma/web/common_api/activity_draft.ex | 2 +- lib/pleroma/web/common_api/utils.ex | 8 -- .../activity_pub/mrf/hashtag_policy_test.exs | 31 +++++ .../activity_pub/mrf/simple_policy_test.exs | 10 +- .../web/activity_pub/mrf/tag_policy_test.exs | 2 +- test/pleroma/web/activity_pub/mrf_test.exs | 14 ++- .../web/activity_pub/transmogrifier_test.exs | 9 -- 15 files changed, 187 insertions(+), 62 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex create mode 100644 test/pleroma/web/activity_pub/mrf/hashtag_policy_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index a7b5f6ac0..52fdcb932 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **Breaking**: Changed `mix pleroma.user toggle_confirmed` to `mix pleroma.user confirm` - **Breaking**: Changed `mix pleroma.user toggle_activated` to `mix pleroma.user activate/deactivate` +- **Breaking:** NSFW hashtag is no longer added on sensitive posts - Polls now always return a `voters_count`, even if they are single-choice. - Admin Emails: The ap id is used as the user link in emails now. - Improved registration workflow for email confirmation and account approval modes. @@ -489,7 +490,6 @@ switched to a new configuration mechanism, however it was not officially removed - Static-FE: Fix remote posts not being sanitized ### Fixed -======= - Rate limiter crashes when there is no explicitly specified ip in the config - 500 errors when no `Accept` header is present if Static-FE is enabled - Instance panel not being updated immediately due to wrong `Cache-Control` headers diff --git a/config/config.exs b/config/config.exs index c371c397c..97e440fee 100644 --- a/config/config.exs +++ b/config/config.exs @@ -391,6 +391,11 @@ federated_timeline_removal: [], replace: [] +config :pleroma, :mrf_hashtag, + sensitive: ["nsfw"], + reject: [], + federated_timeline_removal: [] + config :pleroma, :mrf_subchain, match_actor: %{} config :pleroma, :mrf_activity_expiration, days: 365 diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 6a1031f15..f3eee3e67 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -210,6 +210,16 @@ config :pleroma, :mrf_user_allowlist, %{ * `days`: Default global expiration time for all local Create activities (in days) +#### :mrf_hashtag + +* `sensitive`: List of hashtags to mark activities as sensitive (default: `nsfw`) +* `federated_timeline_removal`: List of hashtags to remove activities from the federated timeline (aka TWNK) +* `reject`: List of hashtags to reject activities from + +Notes: +- The hashtags in the configuration do not have a leading `#`. +- This MRF Policy is always enabled, if you want to disable it you have to set empty lists + ### :activitypub * `unfollow_blocked`: Whether blocks result in people getting unfollowed * `outgoing_blocks`: Whether to federate blocks to other instances diff --git a/lib/pleroma/web/activity_pub/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex index ef5a09a93..f2fec3ff6 100644 --- a/lib/pleroma/web/activity_pub/mrf.ex +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -92,7 +92,9 @@ def pipeline_filter(%{} = message, meta) do end def get_policies do - Pleroma.Config.get([:mrf, :policies], []) |> get_policies() + Pleroma.Config.get([:mrf, :policies], []) + |> get_policies() + |> Enum.concat([Pleroma.Web.ActivityPub.MRF.HashtagPolicy]) end defp get_policies(policy) when is_atom(policy), do: [policy] diff --git a/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex b/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex new file mode 100644 index 000000000..def0c437c --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex @@ -0,0 +1,116 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.HashtagPolicy do + require Pleroma.Constants + + alias Pleroma.Config + alias Pleroma.Object + + @moduledoc """ + Reject, TWKN-remove or Set-Sensitive messsages with specific hashtags (without the leading #) + + Note: This MRF Policy is always enabled, if you want to disable it you have to set empty lists. + """ + + @behaviour Pleroma.Web.ActivityPub.MRF + + defp check_reject(message, hashtags) do + if Enum.any?(Config.get([:mrf_hashtag, :reject]), fn match -> match in hashtags end) do + {:reject, "[HashtagPolicy] Matches with rejected keyword"} + else + {:ok, message} + end + end + + defp check_ftl_removal(%{"to" => to} = message, hashtags) do + if Pleroma.Constants.as_public() in to and + Enum.any?(Config.get([:mrf_hashtag, :federated_timeline_removal]), fn match -> + match in hashtags + end) do + to = List.delete(to, Pleroma.Constants.as_public()) + cc = [Pleroma.Constants.as_public() | message["cc"] || []] + + message = + message + |> Map.put("to", to) + |> Map.put("cc", cc) + |> Kernel.put_in(["object", "to"], to) + |> Kernel.put_in(["object", "cc"], cc) + + {:ok, message} + else + {:ok, message} + end + end + + defp check_ftl_removal(message, _hashtags), do: {:ok, message} + + defp check_sensitive(message, hashtags) do + if Enum.any?(Config.get([:mrf_hashtag, :sensitive]), fn match -> match in hashtags end) do + {:ok, Kernel.put_in(message, ["object", "sensitive"], true)} + else + {:ok, message} + end + end + + @impl true + def filter(%{"type" => "Create", "object" => object} = message) do + hashtags = Object.hashtags(%Object{data: object}) + + if hashtags != [] do + with {:ok, message} <- check_reject(message, hashtags), + {:ok, message} <- check_ftl_removal(message, hashtags), + {:ok, message} <- check_sensitive(message, hashtags) do + {:ok, message} + end + else + {:ok, message} + end + end + + @impl true + def filter(message), do: {:ok, message} + + @impl true + def describe do + mrf_hashtag = + Config.get(:mrf_hashtag) + |> Enum.into(%{}) + + {:ok, %{mrf_hashtag: mrf_hashtag}} + end + + @impl true + def config_description do + %{ + key: :mrf_hashtag, + related_policy: "Pleroma.Web.ActivityPub.MRF.HashtagPolicy", + label: "MRF Hashtag", + description: @moduledoc, + children: [ + %{ + key: :reject, + type: {:list, :string}, + description: "A list of hashtags which result in message being rejected.", + suggestions: ["foo"] + }, + %{ + key: :federated_timeline_removal, + type: {:list, :string}, + description: + "A list of hashtags which result in message being removed from federated timelines (a.k.a unlisted).", + suggestions: ["foo"] + }, + %{ + key: :sensitive, + type: {:list, :string}, + description: + "A list of hashtags which result in message being set as sensitive (a.k.a NSFW/R-18)", + suggestions: ["nsfw", "r18"] + } + ] + } + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index 0b1be8c51..62024c58c 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -64,22 +64,16 @@ defp check_media_nsfw( %{host: actor_host} = _actor_info, %{ "type" => "Create", - "object" => child_object + "object" => %{} = _child_object } = object - ) - when is_map(child_object) do + ) do media_nsfw = Config.get([:mrf_simple, :media_nsfw]) |> MRF.subdomains_regex() object = if MRF.subdomain_match?(media_nsfw, actor_host) do - child_object = - child_object - |> Map.put("tag", (child_object["tag"] || []) ++ ["nsfw"]) - |> Map.put("sensitive", true) - - Map.put(object, "object", child_object) + Kernel.put_in(object, ["object", "sensitive"], true) else object end diff --git a/lib/pleroma/web/activity_pub/mrf/tag_policy.ex b/lib/pleroma/web/activity_pub/mrf/tag_policy.ex index 5739cee63..528093ac0 100644 --- a/lib/pleroma/web/activity_pub/mrf/tag_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/tag_policy.ex @@ -28,20 +28,11 @@ defp process_tag( "mrf_tag:media-force-nsfw", %{ "type" => "Create", - "object" => %{"attachment" => child_attachment} = object + "object" => %{"attachment" => child_attachment} } = message ) when length(child_attachment) > 0 do - tags = (object["tag"] || []) ++ ["nsfw"] - - object = - object - |> Map.put("tag", tags) - |> Map.put("sensitive", true) - - message = Map.put(message, "object", object) - - {:ok, message} + {:ok, Kernel.put_in(message, ["object", "sensitive"], true)} end defp process_tag( diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 0a701334f..8c7d6a747 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -40,7 +40,6 @@ def fix_object(object, options \\ []) do |> fix_in_reply_to(options) |> fix_emoji() |> fix_tag() - |> set_sensitive() |> fix_content_map() |> fix_addressing() |> fix_summary() @@ -741,7 +740,6 @@ def replies(_), do: [] # Prepares the object of an outgoing create activity. def prepare_object(object) do object - |> set_sensitive |> add_hashtags |> add_mention_tags |> add_emoji_tags @@ -932,15 +930,6 @@ def set_conversation(object) do Map.put(object, "conversation", object["context"]) end - def set_sensitive(%{"sensitive" => _} = object) do - object - end - - def set_sensitive(object) do - tags = object["tag"] || [] - Map.put(object, "sensitive", "nsfw" in tags) - end - def set_type(%{"type" => "Answer"} = object) do Map.put(object, "type", "Note") end diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex index fb059c27c..da726a690 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -179,7 +179,7 @@ defp context(draft) do end defp sensitive(draft) do - sensitive = draft.params[:sensitive] || Enum.member?(draft.tags, {"#nsfw", "nsfw"}) + sensitive = draft.params[:sensitive] %__MODULE__{draft | sensitive: sensitive} end diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 9587dfa25..4e6a3feb0 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -217,7 +217,6 @@ def make_content_html(%ActivityDraft{} = draft) do draft.status |> format_input(content_type, options) |> maybe_add_attachments(draft.attachments, attachment_links) - |> maybe_add_nsfw_tag(draft.params) end defp get_content_type(content_type) do @@ -228,13 +227,6 @@ defp get_content_type(content_type) do end end - defp maybe_add_nsfw_tag({text, mentions, tags}, %{"sensitive" => sensitive}) - when sensitive in [true, "True", "true", "1"] do - {text, mentions, [{"#nsfw", "nsfw"} | tags]} - end - - defp maybe_add_nsfw_tag(data, _), do: data - def make_context(_, %Participation{} = participation) do Repo.preload(participation, :conversation).conversation.ap_id end diff --git a/test/pleroma/web/activity_pub/mrf/hashtag_policy_test.exs b/test/pleroma/web/activity_pub/mrf/hashtag_policy_test.exs new file mode 100644 index 000000000..13415bb79 --- /dev/null +++ b/test/pleroma/web/activity_pub/mrf/hashtag_policy_test.exs @@ -0,0 +1,31 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.HashtagPolicyTest do + use Oban.Testing, repo: Pleroma.Repo + use Pleroma.DataCase + + alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.CommonAPI + + import Pleroma.Factory + + test "it sets the sensitive property with relevant hashtags" do + user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{status: "#nsfw hey"}) + {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data) + + assert modified["object"]["sensitive"] + end + + test "it doesn't sets the sensitive property with irrelevant hashtags" do + user = insert(:user) + + {:ok, activity} = CommonAPI.post(user, %{status: "#cofe hey"}) + {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data) + + refute modified["object"]["sensitive"] + end +end diff --git a/test/pleroma/web/activity_pub/mrf/simple_policy_test.exs b/test/pleroma/web/activity_pub/mrf/simple_policy_test.exs index f48e5b39b..5c0aff26e 100644 --- a/test/pleroma/web/activity_pub/mrf/simple_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/simple_policy_test.exs @@ -75,10 +75,7 @@ test "has a matching host" do local_message = build_local_message() assert SimplePolicy.filter(media_message) == - {:ok, - media_message - |> put_in(["object", "tag"], ["foo", "nsfw"]) - |> put_in(["object", "sensitive"], true)} + {:ok, put_in(media_message, ["object", "sensitive"], true)} assert SimplePolicy.filter(local_message) == {:ok, local_message} end @@ -89,10 +86,7 @@ test "match with wildcard domain" do local_message = build_local_message() assert SimplePolicy.filter(media_message) == - {:ok, - media_message - |> put_in(["object", "tag"], ["foo", "nsfw"]) - |> put_in(["object", "sensitive"], true)} + {:ok, put_in(media_message, ["object", "sensitive"], true)} assert SimplePolicy.filter(local_message) == {:ok, local_message} end diff --git a/test/pleroma/web/activity_pub/mrf/tag_policy_test.exs b/test/pleroma/web/activity_pub/mrf/tag_policy_test.exs index 66e98b7ee..faaadff79 100644 --- a/test/pleroma/web/activity_pub/mrf/tag_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/tag_policy_test.exs @@ -114,7 +114,7 @@ test "Mark as sensitive on presence of attachments" do except_message = %{ "actor" => actor.ap_id, "type" => "Create", - "object" => %{"tag" => ["test", "nsfw"], "attachment" => ["file1"], "sensitive" => true} + "object" => %{"tag" => ["test"], "attachment" => ["file1"], "sensitive" => true} } assert TagPolicy.filter(message) == {:ok, except_message} diff --git a/test/pleroma/web/activity_pub/mrf_test.exs b/test/pleroma/web/activity_pub/mrf_test.exs index 7c1eef7e0..61d308b97 100644 --- a/test/pleroma/web/activity_pub/mrf_test.exs +++ b/test/pleroma/web/activity_pub/mrf_test.exs @@ -68,7 +68,12 @@ test "it works as expected with noop policy" do clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.NoOpPolicy]) expected = %{ - mrf_policies: ["NoOpPolicy"], + mrf_policies: ["NoOpPolicy", "HashtagPolicy"], + mrf_hashtag: %{ + federated_timeline_removal: [], + reject: [], + sensitive: ["nsfw"] + }, exclusions: false } @@ -79,8 +84,13 @@ test "it works as expected with mock policy" do clear_config([:mrf, :policies], [MRFModuleMock]) expected = %{ - mrf_policies: ["MRFModuleMock"], + mrf_policies: ["MRFModuleMock", "HashtagPolicy"], mrf_module_mock: "some config data", + mrf_hashtag: %{ + federated_timeline_removal: [], + reject: [], + sensitive: ["nsfw"] + }, exclusions: false } diff --git a/test/pleroma/web/activity_pub/transmogrifier_test.exs b/test/pleroma/web/activity_pub/transmogrifier_test.exs index 7c97fa8f8..a7894a8d0 100644 --- a/test/pleroma/web/activity_pub/transmogrifier_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier_test.exs @@ -153,15 +153,6 @@ test "it turns mentions into tags" do end end - test "it adds the sensitive property" do - user = insert(:user) - - {:ok, activity} = CommonAPI.post(user, %{status: "#nsfw hey"}) - {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data) - - assert modified["object"]["sensitive"] - end - test "it adds the json-ld context and the conversation property" do user = insert(:user) From 3aae5231b2c8f669eadba9228cece254349dd2aa Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn <egor@kislitsyn.com> Date: Tue, 2 Mar 2021 20:49:17 +0400 Subject: [PATCH 053/339] Add OpenAPI spec for AdminAPI.UserController --- CHANGELOG.md | 1 + lib/pleroma/user.ex | 7 - .../admin_api/controllers/user_controller.ex | 128 +++--- .../web/admin_api/views/account_view.ex | 19 +- .../operations/admin/user_operation.ex | 389 ++++++++++++++++++ lib/pleroma/web/router.ex | 2 +- .../controllers/user_controller_test.exs | 118 +++--- 7 files changed, 539 insertions(+), 125 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/admin/user_operation.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index 812816f48..78f21e69f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). <details> <summary>API Changes</summary> - Admin API: (`GET /api/pleroma/admin/users`) filter users by `unconfirmed` status and `actor_type`. +- Admin API: OpenAPI spec for the user-related operations - Pleroma API: `GET /api/v2/pleroma/chats` added. It is exactly like `GET /api/v1/pleroma/chats` except supports pagination. - Pleroma API: Add `idempotency_key` to the chat message entity that can be used for optimistic message sending. - Pleroma API: (`GET /api/v1/pleroma/federation_status`) Add a way to get a list of unreachable instances. diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 9942617d8..c1aa0f716 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -2255,13 +2255,6 @@ def update_background(user, background) do |> update_and_set_cache() end - def roles(%{is_moderator: is_moderator, is_admin: is_admin}) do - %{ - admin: is_admin, - moderator: is_moderator - } - end - def validate_fields(changeset, remote? \\ false) do limit_name = if remote?, do: :max_remote_account_fields, else: :max_account_fields limit = Config.get([:instance, limit_name], 0) diff --git a/lib/pleroma/web/admin_api/controllers/user_controller.ex b/lib/pleroma/web/admin_api/controllers/user_controller.ex index 65bc63cb9..d3e4c18a3 100644 --- a/lib/pleroma/web/admin_api/controllers/user_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/user_controller.ex @@ -13,16 +13,17 @@ defmodule Pleroma.Web.AdminAPI.UserController do alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.AdminAPI - alias Pleroma.Web.AdminAPI.AccountView alias Pleroma.Web.AdminAPI.Search alias Pleroma.Web.Plugs.OAuthScopesPlug @users_page_size 50 + plug(Pleroma.Web.ApiSpec.CastAndValidate) + plug( OAuthScopesPlug, %{scopes: ["admin:read:accounts"]} - when action in [:list, :show] + when action in [:index, :show] ) plug( @@ -44,13 +45,19 @@ defmodule Pleroma.Web.AdminAPI.UserController do when action in [:follow, :unfollow] ) + plug(:put_view, Pleroma.Web.AdminAPI.AccountView) + action_fallback(AdminAPI.FallbackController) - def delete(conn, %{"nickname" => nickname}) do - delete(conn, %{"nicknames" => [nickname]}) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.UserOperation + + def delete(conn, %{nickname: nickname}) do + conn + |> Map.put(:body_params, %{nicknames: [nickname]}) + |> delete(%{}) end - def delete(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do + def delete(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = conn, _) do users = Enum.map(nicknames, &User.get_cached_by_nickname/1) Enum.each(users, fn user -> @@ -67,10 +74,16 @@ def delete(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do json(conn, nicknames) end - def follow(%{assigns: %{user: admin}} = conn, %{ - "follower" => follower_nick, - "followed" => followed_nick - }) do + def follow( + %{ + assigns: %{user: admin}, + body_params: %{ + follower: follower_nick, + followed: followed_nick + } + } = conn, + _ + ) 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) @@ -86,10 +99,16 @@ def follow(%{assigns: %{user: admin}} = conn, %{ json(conn, "ok") end - def unfollow(%{assigns: %{user: admin}} = conn, %{ - "follower" => follower_nick, - "followed" => followed_nick - }) do + def unfollow( + %{ + assigns: %{user: admin}, + body_params: %{ + follower: follower_nick, + followed: followed_nick + } + } = conn, + _ + ) 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) @@ -105,9 +124,10 @@ def unfollow(%{assigns: %{user: admin}} = conn, %{ json(conn, "ok") end - def create(%{assigns: %{user: admin}} = conn, %{"users" => users}) do + def create(%{assigns: %{user: admin}, body_params: %{users: users}} = conn, _) do changesets = - Enum.map(users, fn %{"nickname" => nickname, "email" => email, "password" => password} -> + users + |> Enum.map(fn %{nickname: nickname, email: email, password: password} -> user_data = %{ nickname: nickname, name: nickname, @@ -124,52 +144,49 @@ def create(%{assigns: %{user: admin}} = conn, %{"users" => users}) do end) case Pleroma.Repo.transaction(changesets) do - {:ok, users} -> - res = - users + {:ok, users_map} -> + users = + users_map |> Map.values() |> Enum.map(fn user -> {:ok, user} = User.post_register_action(user) user end) - |> Enum.map(&AccountView.render("created.json", %{user: &1})) ModerationLog.insert_log(%{ actor: admin, - subjects: Map.values(users), + subjects: users, action: "create" }) - json(conn, res) + render(conn, "created_many.json", users: users) {:error, id, changeset, _} -> - res = + changesets = Enum.map(changesets.operations, fn - {current_id, {:changeset, _current_changeset, _}} when current_id == id -> - AccountView.render("create-error.json", %{changeset: changeset}) + {^id, {:changeset, _current_changeset, _}} -> + changeset {_, {:changeset, current_changeset, _}} -> - AccountView.render("create-error.json", %{changeset: current_changeset}) + current_changeset end) conn |> put_status(:conflict) - |> json(res) + |> render("create_errors.json", changesets: changesets) end end - def show(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do + def show(%{assigns: %{user: admin}} = conn, %{nickname: nickname}) do with %User{} = user <- User.get_cached_by_nickname_or_id(nickname, for: admin) do - conn - |> put_view(AccountView) - |> render("show.json", %{user: user}) + render(conn, "show.json", %{user: user}) else _ -> {:error, :not_found} end end - def toggle_activation(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do + def toggle_activation(%{assigns: %{user: admin}} = conn, %{nickname: nickname}) do user = User.get_cached_by_nickname(nickname) {:ok, updated_user} = User.set_activation(user, !user.is_active) @@ -182,12 +199,10 @@ def toggle_activation(%{assigns: %{user: admin}} = conn, %{"nickname" => nicknam action: action }) - conn - |> put_view(AccountView) - |> render("show.json", %{user: updated_user}) + render(conn, "show.json", user: updated_user) end - def activate(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do + def activate(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = conn, _) do users = Enum.map(nicknames, &User.get_cached_by_nickname/1) {:ok, updated_users} = User.set_activation(users, true) @@ -197,12 +212,10 @@ def activate(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do action: "activate" }) - conn - |> put_view(AccountView) - |> render("index.json", %{users: Keyword.values(updated_users)}) + render(conn, "index.json", users: Keyword.values(updated_users)) end - def deactivate(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do + def deactivate(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = conn, _) do users = Enum.map(nicknames, &User.get_cached_by_nickname/1) {:ok, updated_users} = User.set_activation(users, false) @@ -212,12 +225,10 @@ def deactivate(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) d action: "deactivate" }) - conn - |> put_view(AccountView) - |> render("index.json", %{users: Keyword.values(updated_users)}) + render(conn, "index.json", users: Keyword.values(updated_users)) end - def approve(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do + def approve(%{assigns: %{user: admin}, body_params: %{nicknames: nicknames}} = conn, _) do users = Enum.map(nicknames, &User.get_cached_by_nickname/1) {:ok, updated_users} = User.approve(users) @@ -227,36 +238,27 @@ def approve(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do action: "approve" }) - conn - |> put_view(AccountView) - |> render("index.json", %{users: updated_users}) + render(conn, "index.json", users: updated_users) end - def list(conn, params) do + def index(conn, params) do {page, page_size} = page_params(params) - filters = maybe_parse_filters(params["filters"]) + filters = maybe_parse_filters(params[:filters]) search_params = %{ - query: params["query"], + query: params[:query], page: page, page_size: page_size, - tags: params["tags"], - name: params["name"], - email: params["email"], - actor_types: params["actor_types"] + tags: params[:tags], + name: params[:name], + email: params[:email], + actor_types: params[:actor_types] } |> Map.merge(filters) with {:ok, users, count} <- Search.user(search_params) do - json( - conn, - AccountView.render("index.json", - users: users, - count: count, - page_size: page_size - ) - ) + render(conn, "index.json", users: users, count: count, page_size: page_size) end end @@ -274,8 +276,8 @@ defp maybe_parse_filters(filters) do defp page_params(params) do { - fetch_integer_param(params, "page", 1), - fetch_integer_param(params, "page_size", @users_page_size) + fetch_integer_param(params, :page, 1), + fetch_integer_param(params, :page_size, @users_page_size) } end end diff --git a/lib/pleroma/web/admin_api/views/account_view.ex b/lib/pleroma/web/admin_api/views/account_view.ex index d7c63d385..e053a9b67 100644 --- a/lib/pleroma/web/admin_api/views/account_view.ex +++ b/lib/pleroma/web/admin_api/views/account_view.ex @@ -75,7 +75,7 @@ def render("show.json", %{user: user}) do "display_name" => display_name, "is_active" => user.is_active, "local" => user.local, - "roles" => User.roles(user), + "roles" => roles(user), "tags" => user.tags || [], "is_confirmed" => user.is_confirmed, "is_approved" => user.is_approved, @@ -85,6 +85,10 @@ def render("show.json", %{user: user}) do } end + def render("created_many.json", %{users: users}) do + render_many(users, AccountView, "created.json", as: :user) + end + def render("created.json", %{user: user}) do %{ type: "success", @@ -96,7 +100,11 @@ def render("created.json", %{user: user}) do } end - def render("create-error.json", %{changeset: %Ecto.Changeset{changes: changes, errors: errors}}) do + def render("create_errors.json", %{changesets: changesets}) do + render_many(changesets, AccountView, "create_error.json", as: :changeset) + end + + def render("create_error.json", %{changeset: %Ecto.Changeset{changes: changes, errors: errors}}) do %{ type: "error", code: 409, @@ -140,4 +148,11 @@ defp parse_error(errors) do defp image_url(%{"url" => [%{"href" => href} | _]}), do: href defp image_url(_), do: nil + + defp roles(%{is_moderator: is_moderator, is_admin: is_admin}) do + %{ + admin: is_admin, + moderator: is_moderator + } + end end diff --git a/lib/pleroma/web/api_spec/operations/admin/user_operation.ex b/lib/pleroma/web/api_spec/operations/admin/user_operation.ex new file mode 100644 index 000000000..183c61236 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/user_operation.ex @@ -0,0 +1,389 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.Admin.UserOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ActorType + 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: ["Users"], + summary: "List users", + operationId: "AdminAPI.UserController.index", + security: [%{"oAuth" => ["admin:read:accounts"]}], + parameters: [ + Operation.parameter(:filters, :query, :string, "Comma separated list of filters"), + Operation.parameter(:query, :query, :string, "Search users query"), + Operation.parameter(:name, :query, :string, "Search by display name"), + Operation.parameter(:email, :query, :string, "Search by email"), + Operation.parameter(:page, :query, :integer, "Page Number"), + Operation.parameter(:page_size, :query, :integer, "Number of users to return per page"), + Operation.parameter( + :actor_types, + :query, + %Schema{type: :array, items: ActorType}, + "Filter by actor type" + ), + Operation.parameter( + :tags, + :query, + %Schema{type: :array, items: %Schema{type: :string}}, + "Filter by tags" + ) + | admin_api_params() + ], + responses: %{ + 200 => + Operation.response( + "Response", + "application/json", + %Schema{ + type: :object, + properties: %{ + users: %Schema{type: :array, items: user()}, + count: %Schema{type: :integer}, + page_size: %Schema{type: :integer} + } + } + ), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def create_operation do + %Operation{ + tags: ["Users"], + summary: "Create a single or multiple users", + operationId: "AdminAPI.UserController.create", + security: [%{"oAuth" => ["admin:write:accounts"]}], + parameters: admin_api_params(), + requestBody: + request_body( + "Parameters", + %Schema{ + description: "POST body for creating users", + type: :object, + properties: %{ + users: %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + nickname: %Schema{type: :string}, + email: %Schema{type: :string}, + password: %Schema{type: :string} + } + } + } + } + } + ), + responses: %{ + 200 => + Operation.response("Response", "application/json", %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + code: %Schema{type: :integer}, + type: %Schema{type: :string}, + data: %Schema{ + type: :object, + properties: %{ + email: %Schema{type: :string, format: :email}, + nickname: %Schema{type: :string} + } + } + } + } + }), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 409 => + Operation.response("Conflict", "application/json", %Schema{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + code: %Schema{type: :integer}, + error: %Schema{type: :string}, + type: %Schema{type: :string}, + data: %Schema{ + type: :object, + properties: %{ + email: %Schema{type: :string, format: :email}, + nickname: %Schema{type: :string} + } + } + } + } + }) + } + } + end + + def show_operation do + %Operation{ + tags: ["Users"], + summary: "Show user", + operationId: "AdminAPI.UserController.show", + security: [%{"oAuth" => ["admin:read:accounts"]}], + parameters: [ + Operation.parameter( + :nickname, + :path, + :string, + "User nickname or ID" + ) + | admin_api_params() + ], + responses: %{ + 200 => Operation.response("Response", "application/json", user()), + 403 => Operation.response("Forbidden", "application/json", ApiError), + 404 => Operation.response("Not Found", "application/json", ApiError) + } + } + end + + def follow_operation do + %Operation{ + tags: ["Users"], + summary: "Follow", + operationId: "AdminAPI.UserController.follow", + security: [%{"oAuth" => ["admin:write:follows"]}], + parameters: admin_api_params(), + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + properties: %{ + follower: %Schema{type: :string, description: "Follower nickname"}, + followed: %Schema{type: :string, description: "Followed nickname"} + } + } + ), + responses: %{ + 200 => Operation.response("Response", "application/json", %Schema{type: :string}), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def unfollow_operation do + %Operation{ + tags: ["Users"], + summary: "Unfollow", + operationId: "AdminAPI.UserController.unfollow", + security: [%{"oAuth" => ["admin:write:follows"]}], + parameters: admin_api_params(), + requestBody: + request_body( + "Parameters", + %Schema{ + type: :object, + properties: %{ + follower: %Schema{type: :string, description: "Follower nickname"}, + followed: %Schema{type: :string, description: "Followed nickname"} + } + } + ), + responses: %{ + 200 => Operation.response("Response", "application/json", %Schema{type: :string}), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def approve_operation do + %Operation{ + tags: ["Users"], + summary: "Approve multiple users", + operationId: "AdminAPI.UserController.approve", + security: [%{"oAuth" => ["admin:write:accounts"]}], + parameters: admin_api_params(), + requestBody: + request_body( + "Parameters", + %Schema{ + description: "POST body for deleting multiple users", + type: :object, + properties: %{ + nicknames: %Schema{ + type: :array, + items: %Schema{type: :string} + } + } + } + ), + responses: %{ + 200 => + Operation.response("Response", "application/json", %Schema{ + type: :object, + properties: %{user: %Schema{type: :array, items: user()}} + }), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def toggle_activation_operation do + %Operation{ + tags: ["Users"], + summary: "Toggle user activation", + operationId: "AdminAPI.UserController.toggle_activation", + security: [%{"oAuth" => ["admin:write:accounts"]}], + parameters: [ + Operation.parameter(:nickname, :path, :string, "User nickname") + | admin_api_params() + ], + responses: %{ + 200 => Operation.response("Response", "application/json", user()), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def activate_operation do + %Operation{ + tags: ["Users"], + summary: "Activate multiple users", + operationId: "AdminAPI.UserController.activate", + security: [%{"oAuth" => ["admin:write:accounts"]}], + parameters: admin_api_params(), + requestBody: + request_body( + "Parameters", + %Schema{ + description: "POST body for deleting multiple users", + type: :object, + properties: %{ + nicknames: %Schema{ + type: :array, + items: %Schema{type: :string} + } + } + } + ), + responses: %{ + 200 => + Operation.response("Response", "application/json", %Schema{ + type: :object, + properties: %{user: %Schema{type: :array, items: user()}} + }), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def deactivate_operation do + %Operation{ + tags: ["Users"], + summary: "Deactivates multiple users", + operationId: "AdminAPI.UserController.deactivate", + security: [%{"oAuth" => ["admin:write:accounts"]}], + parameters: admin_api_params(), + requestBody: + request_body( + "Parameters", + %Schema{ + description: "POST body for deleting multiple users", + type: :object, + properties: %{ + nicknames: %Schema{ + type: :array, + items: %Schema{type: :string} + } + } + } + ), + responses: %{ + 200 => + Operation.response("Response", "application/json", %Schema{ + type: :object, + properties: %{user: %Schema{type: :array, items: user()}} + }), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + def delete_operation do + %Operation{ + tags: ["Users"], + summary: "Removes a single or multiple users", + operationId: "AdminAPI.UserController.delete", + security: [%{"oAuth" => ["admin:write:accounts"]}], + parameters: [ + Operation.parameter( + :nickname, + :query, + :string, + "User nickname" + ) + | admin_api_params() + ], + requestBody: + request_body( + "Parameters", + %Schema{ + description: "POST body for deleting multiple users", + type: :object, + properties: %{ + nicknames: %Schema{ + type: :array, + items: %Schema{type: :string} + } + } + } + ), + responses: %{ + 200 => + Operation.response("Response", "application/json", %Schema{ + description: "Array of nicknames", + type: :array, + items: %Schema{type: :string} + }), + 403 => Operation.response("Forbidden", "application/json", ApiError) + } + } + end + + defp user do + %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string}, + email: %Schema{type: :string, format: :email}, + avatar: %Schema{type: :string, format: :uri}, + nickname: %Schema{type: :string}, + display_name: %Schema{type: :string}, + is_active: %Schema{type: :boolean}, + local: %Schema{type: :boolean}, + roles: %Schema{ + type: :object, + properties: %{ + admin: %Schema{type: :boolean}, + moderator: %Schema{type: :boolean} + } + }, + tags: %Schema{type: :array, items: %Schema{type: :string}}, + is_confirmed: %Schema{type: :boolean}, + is_approved: %Schema{type: :boolean}, + url: %Schema{type: :string, format: :uri}, + registration_reason: %Schema{type: :string, nullable: true}, + actor_type: %Schema{type: :string} + } + } + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 72ad14f05..de0bd27d7 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -204,7 +204,7 @@ defmodule Pleroma.Web.Router do get("/users/:nickname/credentials", AdminAPIController, :show_user_credentials) patch("/users/:nickname/credentials", AdminAPIController, :update_user_credentials) - get("/users", UserController, :list) + get("/users", UserController, :index) get("/users/:nickname", UserController, :show) get("/users/:nickname/statuses", AdminAPIController, :list_user_statuses) get("/users/:nickname/chats", AdminAPIController, :list_user_chats) diff --git a/test/pleroma/web/admin_api/controllers/user_controller_test.exs b/test/pleroma/web/admin_api/controllers/user_controller_test.exs index beb8a5d58..31319b5e5 100644 --- a/test/pleroma/web/admin_api/controllers/user_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/user_controller_test.exs @@ -44,7 +44,7 @@ test "with valid `admin_token` query parameter, skips OAuth scopes check" do conn = get(build_conn(), "/api/pleroma/admin/users/#{user.nickname}?admin_token=password123") - assert json_response(conn, 200) + assert json_response_and_validate_schema(conn, 200) end test "GET /api/pleroma/admin/users/:nickname requires admin:read:accounts or broader scope", @@ -67,7 +67,7 @@ test "GET /api/pleroma/admin/users/:nickname requires admin:read:accounts or bro |> assign(:token, good_token) |> get(url) - assert json_response(conn, 200) + assert json_response_and_validate_schema(conn, 200) end for good_token <- [good_token1, good_token2, good_token3] do @@ -87,7 +87,7 @@ test "GET /api/pleroma/admin/users/:nickname requires admin:read:accounts or bro |> assign(:token, bad_token) |> get(url) - assert json_response(conn, :forbidden) + assert json_response_and_validate_schema(conn, :forbidden) end end @@ -131,7 +131,7 @@ test "single user", %{admin: admin, conn: conn} do assert ModerationLog.get_log_entry_message(log_entry) == "@#{admin.nickname} deleted users: @#{user.nickname}" - assert json_response(conn, 200) == [user.nickname] + assert json_response_and_validate_schema(conn, 200) == [user.nickname] user = Repo.get(User, user.id) refute user.is_active @@ -152,28 +152,30 @@ test "multiple users", %{admin: admin, conn: conn} do user_one = insert(:user) user_two = insert(:user) - conn = + response = conn |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") |> delete("/api/pleroma/admin/users", %{ nicknames: [user_one.nickname, user_two.nickname] }) + |> json_response_and_validate_schema(200) log_entry = Repo.one(ModerationLog) assert ModerationLog.get_log_entry_message(log_entry) == "@#{admin.nickname} deleted users: @#{user_one.nickname}, @#{user_two.nickname}" - response = json_response(conn, 200) assert response -- [user_one.nickname, user_two.nickname] == [] end end describe "/api/pleroma/admin/users" do test "Create", %{conn: conn} do - conn = + response = conn |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") |> post("/api/pleroma/admin/users", %{ "users" => [ %{ @@ -188,8 +190,9 @@ test "Create", %{conn: conn} do } ] }) + |> json_response_and_validate_schema(200) + |> Enum.map(&Map.get(&1, "type")) - response = json_response(conn, 200) |> Enum.map(&Map.get(&1, "type")) assert response == ["success", "success"] log_entry = Repo.one(ModerationLog) @@ -203,6 +206,7 @@ test "Cannot create user with existing email", %{conn: conn} do conn = conn |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") |> post("/api/pleroma/admin/users", %{ "users" => [ %{ @@ -213,7 +217,7 @@ test "Cannot create user with existing email", %{conn: conn} do ] }) - assert json_response(conn, 409) == [ + assert json_response_and_validate_schema(conn, 409) == [ %{ "code" => 409, "data" => %{ @@ -232,6 +236,7 @@ test "Cannot create user with existing nickname", %{conn: conn} do conn = conn |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") |> post("/api/pleroma/admin/users", %{ "users" => [ %{ @@ -242,7 +247,7 @@ test "Cannot create user with existing nickname", %{conn: conn} do ] }) - assert json_response(conn, 409) == [ + assert json_response_and_validate_schema(conn, 409) == [ %{ "code" => 409, "data" => %{ @@ -261,6 +266,7 @@ test "Multiple user creation works in transaction", %{conn: conn} do conn = conn |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") |> post("/api/pleroma/admin/users", %{ "users" => [ %{ @@ -276,7 +282,7 @@ test "Multiple user creation works in transaction", %{conn: conn} do ] }) - assert json_response(conn, 409) == [ + assert json_response_and_validate_schema(conn, 409) == [ %{ "code" => 409, "data" => %{ @@ -307,7 +313,7 @@ test "Show", %{conn: conn} do conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}") - assert user_response(user) == json_response(conn, 200) + assert user_response(user) == json_response_and_validate_schema(conn, 200) end test "when the user doesn't exist", %{conn: conn} do @@ -315,7 +321,7 @@ test "when the user doesn't exist", %{conn: conn} do conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}") - assert %{"error" => "Not found"} == json_response(conn, 404) + assert %{"error" => "Not found"} == json_response_and_validate_schema(conn, 404) end end @@ -326,6 +332,7 @@ test "allows to force-follow another user", %{admin: admin, conn: conn} do conn |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") |> post("/api/pleroma/admin/users/follow", %{ "follower" => follower.nickname, "followed" => user.nickname @@ -352,6 +359,7 @@ test "allows to force-unfollow another user", %{admin: admin, conn: conn} do conn |> put_req_header("accept", "application/json") + |> put_req_header("content-type", "application/json") |> post("/api/pleroma/admin/users/unfollow", %{ "follower" => follower.nickname, "followed" => user.nickname @@ -395,7 +403,7 @@ test "renders users array for the first page", %{conn: conn, admin: admin} do ] |> Enum.sort_by(& &1["nickname"]) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "count" => 3, "page_size" => 50, "users" => users @@ -410,7 +418,7 @@ test "pagination works correctly with service users", %{conn: conn} do assert %{"count" => 26, "page_size" => 10, "users" => users1} = conn |> get("/api/pleroma/admin/users?page=1&filters=", %{page_size: "10"}) - |> json_response(200) + |> json_response_and_validate_schema(200) assert Enum.count(users1) == 10 assert service1 not in users1 @@ -418,7 +426,7 @@ test "pagination works correctly with service users", %{conn: conn} do assert %{"count" => 26, "page_size" => 10, "users" => users2} = conn |> get("/api/pleroma/admin/users?page=2&filters=", %{page_size: "10"}) - |> json_response(200) + |> json_response_and_validate_schema(200) assert Enum.count(users2) == 10 assert service1 not in users2 @@ -426,7 +434,7 @@ test "pagination works correctly with service users", %{conn: conn} do assert %{"count" => 26, "page_size" => 10, "users" => users3} = conn |> get("/api/pleroma/admin/users?page=3&filters=", %{page_size: "10"}) - |> json_response(200) + |> json_response_and_validate_schema(200) assert Enum.count(users3) == 6 assert service1 not in users3 @@ -437,7 +445,7 @@ test "renders empty array for the second page", %{conn: conn} do conn = get(conn, "/api/pleroma/admin/users?page=2") - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "count" => 2, "page_size" => 50, "users" => [] @@ -449,7 +457,7 @@ test "regular search", %{conn: conn} do conn = get(conn, "/api/pleroma/admin/users?query=bo") - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "count" => 1, "page_size" => 50, "users" => [user_response(user, %{"local" => true})] @@ -462,7 +470,7 @@ test "search by domain", %{conn: conn} do conn = get(conn, "/api/pleroma/admin/users?query=domain.com") - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "count" => 1, "page_size" => 50, "users" => [user_response(user)] @@ -475,7 +483,7 @@ test "search by full nickname", %{conn: conn} do conn = get(conn, "/api/pleroma/admin/users?query=nickname@domain.com") - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "count" => 1, "page_size" => 50, "users" => [user_response(user)] @@ -488,7 +496,7 @@ test "search by display name", %{conn: conn} do conn = get(conn, "/api/pleroma/admin/users?name=display") - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "count" => 1, "page_size" => 50, "users" => [user_response(user)] @@ -501,7 +509,7 @@ test "search by email", %{conn: conn} do conn = get(conn, "/api/pleroma/admin/users?email=email@example.com") - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "count" => 1, "page_size" => 50, "users" => [user_response(user)] @@ -514,7 +522,7 @@ test "regular search with page size", %{conn: conn} do conn1 = get(conn, "/api/pleroma/admin/users?query=a&page_size=1&page=1") - assert json_response(conn1, 200) == %{ + assert json_response_and_validate_schema(conn1, 200) == %{ "count" => 2, "page_size" => 1, "users" => [user_response(user)] @@ -522,7 +530,7 @@ test "regular search with page size", %{conn: conn} do conn2 = get(conn, "/api/pleroma/admin/users?query=a&page_size=1&page=2") - assert json_response(conn2, 200) == %{ + assert json_response_and_validate_schema(conn2, 200) == %{ "count" => 2, "page_size" => 1, "users" => [user_response(user2)] @@ -542,7 +550,7 @@ test "only local users" do |> assign(:token, token) |> get("/api/pleroma/admin/users?query=bo&filters=local") - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "count" => 1, "page_size" => 50, "users" => [user_response(user)] @@ -570,7 +578,7 @@ test "only local users with no query", %{conn: conn, admin: old_admin} do ] |> Enum.sort_by(& &1["nickname"]) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "count" => 3, "page_size" => 50, "users" => users @@ -587,7 +595,7 @@ test "only unconfirmed users", %{conn: conn} do result = conn |> get("/api/pleroma/admin/users?filters=unconfirmed") - |> json_response(200) + |> json_response_and_validate_schema(200) users = Enum.map([old_user, sad_user], fn user -> @@ -620,7 +628,7 @@ test "only unapproved users", %{conn: conn} do ) ] - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "count" => 1, "page_size" => 50, "users" => users @@ -647,7 +655,7 @@ test "load only admins", %{conn: conn, admin: admin} do ] |> Enum.sort_by(& &1["nickname"]) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "count" => 2, "page_size" => 50, "users" => users @@ -661,7 +669,7 @@ test "load only moderators", %{conn: conn} do conn = get(conn, "/api/pleroma/admin/users?filters=is_moderator") - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "count" => 1, "page_size" => 50, "users" => [ @@ -682,8 +690,8 @@ test "load users with actor_type is Person", %{admin: admin, conn: conn} do response = conn - |> get(user_path(conn, :list), %{actor_types: ["Person"]}) - |> json_response(200) + |> get(user_path(conn, :index), %{actor_types: ["Person"]}) + |> json_response_and_validate_schema(200) users = [ @@ -705,8 +713,8 @@ test "load users with actor_type is Person and Service", %{admin: admin, conn: c response = conn - |> get(user_path(conn, :list), %{actor_types: ["Person", "Service"]}) - |> json_response(200) + |> get(user_path(conn, :index), %{actor_types: ["Person", "Service"]}) + |> json_response_and_validate_schema(200) users = [ @@ -728,8 +736,8 @@ test "load users with actor_type is Service", %{conn: conn} do response = conn - |> get(user_path(conn, :list), %{actor_types: ["Service"]}) - |> json_response(200) + |> get(user_path(conn, :index), %{actor_types: ["Service"]}) + |> json_response_and_validate_schema(200) users = [user_response(user_service, %{"actor_type" => "Service"})] @@ -751,7 +759,7 @@ test "load users with tags list", %{conn: conn} do ] |> Enum.sort_by(& &1["nickname"]) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "count" => 2, "page_size" => 50, "users" => users @@ -776,7 +784,7 @@ test "`active` filters out users pending approval", %{token: token} do %{"id" => ^admin_id}, %{"id" => ^user_id} ] - } = json_response(conn, 200) + } = json_response_and_validate_schema(conn, 200) end test "it works with multiple filters" do @@ -793,7 +801,7 @@ test "it works with multiple filters" do |> assign(:token, token) |> get("/api/pleroma/admin/users?filters=deactivated,external") - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "count" => 1, "page_size" => 50, "users" => [user_response(user)] @@ -805,7 +813,7 @@ test "it omits relay user", %{admin: admin, conn: conn} do conn = get(conn, "/api/pleroma/admin/users") - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "count" => 1, "page_size" => 50, "users" => [ @@ -820,13 +828,14 @@ test "PATCH /api/pleroma/admin/users/activate", %{admin: admin, conn: conn} do user_two = insert(:user, is_active: false) conn = - patch( - conn, + conn + |> put_req_header("content-type", "application/json") + |> patch( "/api/pleroma/admin/users/activate", %{nicknames: [user_one.nickname, user_two.nickname]} ) - response = json_response(conn, 200) + response = json_response_and_validate_schema(conn, 200) assert Enum.map(response["users"], & &1["is_active"]) == [true, true] log_entry = Repo.one(ModerationLog) @@ -840,13 +849,14 @@ test "PATCH /api/pleroma/admin/users/deactivate", %{admin: admin, conn: conn} do user_two = insert(:user, is_active: true) conn = - patch( - conn, + conn + |> put_req_header("content-type", "application/json") + |> patch( "/api/pleroma/admin/users/deactivate", %{nicknames: [user_one.nickname, user_two.nickname]} ) - response = json_response(conn, 200) + response = json_response_and_validate_schema(conn, 200) assert Enum.map(response["users"], & &1["is_active"]) == [false, false] log_entry = Repo.one(ModerationLog) @@ -860,13 +870,14 @@ test "PATCH /api/pleroma/admin/users/approve", %{admin: admin, conn: conn} do user_two = insert(:user, is_approved: false) conn = - patch( - conn, + conn + |> put_req_header("content-type", "application/json") + |> patch( "/api/pleroma/admin/users/approve", %{nicknames: [user_one.nickname, user_two.nickname]} ) - response = json_response(conn, 200) + response = json_response_and_validate_schema(conn, 200) assert Enum.map(response["users"], & &1["is_approved"]) == [true, true] log_entry = Repo.one(ModerationLog) @@ -878,9 +889,12 @@ test "PATCH /api/pleroma/admin/users/approve", %{admin: admin, conn: conn} do test "PATCH /api/pleroma/admin/users/:nickname/toggle_activation", %{admin: admin, conn: conn} do user = insert(:user) - conn = patch(conn, "/api/pleroma/admin/users/#{user.nickname}/toggle_activation") + conn = + conn + |> put_req_header("content-type", "application/json") + |> patch("/api/pleroma/admin/users/#{user.nickname}/toggle_activation") - assert json_response(conn, 200) == + assert json_response_and_validate_schema(conn, 200) == user_response( user, %{"is_active" => !user.is_active} From 85b2387f665045a303486d10e6879a46a7ab922e Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 2 Mar 2021 11:37:37 -0600 Subject: [PATCH 054/339] Fix build_application/1 match --- lib/pleroma/web/mastodon_api/views/status_view.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index bac897a57..a7e762ac1 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -536,6 +536,8 @@ defp build_emoji_map(emoji, users, current_user) do end @spec build_application(map() | nil) :: map() | nil - defp build_application(%{type: _type, name: name, url: url}), do: %{name: name, website: url} + defp build_application(%{"type" => _type, "name" => name, "url" => url}), + do: %{name: name, website: url} + defp build_application(_), do: nil end From f0208980e48ee361f9eaa40352f519a1b95ace28 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 2 Mar 2021 12:29:16 -0600 Subject: [PATCH 055/339] Test both ingestion of post in the status controller and the correct response during the view --- .../controllers/status_controller_test.exs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs index bd385bccd..634ebf79c 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -376,6 +376,17 @@ test "discloses application metadata when enabled" do "status" => "cofe is my copilot" }) + assert %{ + "content" => "cofe is my copilot" + } = json_response_and_validate_schema(result, 200) + + activity = result.assigns.activity.id + + result = + conn + |> put_req_header("content-type", "application/json") + |> get("api/v1/statuses/#{activity}") + assert %{ "content" => "cofe is my copilot", "application" => %{ From ccbf162088951e4b7f28291ca4cd9b9280b40857 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 2 Mar 2021 12:33:32 -0600 Subject: [PATCH 056/339] Actually test viewing status after ingestion --- .../controllers/status_controller_test.exs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs index 634ebf79c..39ab90ba6 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -407,6 +407,16 @@ test "hides application metadata when disabled" do "status" => "club mate is my wingman" }) + assert %{"content" => "club mate is my wingman"} = + json_response_and_validate_schema(result, 200) + + activity = result.assigns.activity.id + + result = + conn + |> put_req_header("content-type", "application/json") + |> get("api/v1/statuses/#{activity}") + assert %{ "content" => "club mate is my wingman", "application" => nil From 913d53b7d7301445fdb0fc8dbe5ecf8b59aafa43 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 2 Mar 2021 14:04:50 -0600 Subject: [PATCH 057/339] Remove useless header on the get request --- .../web/mastodon_api/controllers/status_controller_test.exs | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs index 39ab90ba6..f616f405e 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -384,7 +384,6 @@ test "discloses application metadata when enabled" do result = conn - |> put_req_header("content-type", "application/json") |> get("api/v1/statuses/#{activity}") assert %{ @@ -414,7 +413,6 @@ test "hides application metadata when disabled" do result = conn - |> put_req_header("content-type", "application/json") |> get("api/v1/statuses/#{activity}") assert %{ From 8d601d3b234cfe2a6a942dd156712cc400af8500 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 2 Mar 2021 14:14:38 -0600 Subject: [PATCH 058/339] Make the object reference in both render("show.json", _) functions consistently named --- lib/pleroma/web/mastodon_api/views/status_view.ex | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index a7e762ac1..f3f54e03d 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -124,16 +124,16 @@ def render( ) do user = CommonAPI.get_user(activity.data["actor"]) created_at = Utils.to_masto_date(activity.data["published"]) - activity_object = Object.normalize(activity, fetch: false) + object = Object.normalize(activity, fetch: false) reblogged_parent_activity = if opts[:parent_activities] do Activity.Queries.find_by_object_ap_id( opts[:parent_activities], - activity_object.data["id"] + object.data["id"] ) else - Activity.create_by_object_ap_id(activity_object.data["id"]) + Activity.create_by_object_ap_id(object.data["id"]) |> Activity.with_preloaded_bookmark(opts[:for]) |> Activity.with_set_thread_muted_field(opts[:for]) |> Repo.one() @@ -142,7 +142,7 @@ def render( reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity) reblogged = render("show.json", reblog_rendering_opts) - favorited = opts[:for] && opts[:for].ap_id in (activity_object.data["likes"] || []) + favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || []) bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil @@ -154,8 +154,8 @@ def render( %{ id: to_string(activity.id), - uri: activity_object.data["id"], - url: activity_object.data["id"], + uri: object.data["id"], + url: object.data["id"], account: AccountView.render("show.json", %{ user: user, @@ -180,7 +180,7 @@ def render( media_attachments: reblogged[:media_attachments] || [], mentions: mentions, tags: reblogged[:tags] || [], - application: build_application(activity_object.data["generator"]), + application: build_application(object.data["generator"]), language: nil, emojis: [], pleroma: %{ From 5b8cceba09bda6a01adee4939e3c2521c2ea037e Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 2 Mar 2021 18:17:32 -0600 Subject: [PATCH 059/339] Fix migration in cases where database name has a hyphen --- .../20210121080964_add_default_text_search_config.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/priv/repo/migrations/20210121080964_add_default_text_search_config.exs b/priv/repo/migrations/20210121080964_add_default_text_search_config.exs index 09b6cccc9..27f600b70 100644 --- a/priv/repo/migrations/20210121080964_add_default_text_search_config.exs +++ b/priv/repo/migrations/20210121080964_add_default_text_search_config.exs @@ -4,7 +4,7 @@ defmodule Pleroma.Repo.Migrations.AddDefaultTextSearchConfig do def change do execute("DO $$ BEGIN - execute 'ALTER DATABASE '||current_database()||' SET default_text_search_config = ''english'' '; + execute 'ALTER DATABASE \"'||current_database()||'\" SET default_text_search_config = ''english'' '; END $$;") end From c5352e90be363f88f011ed5a63129caf3ee1a9fc Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Wed, 3 Mar 2021 13:56:40 +0100 Subject: [PATCH 060/339] Changelog, mix: merge in stable --- CHANGELOG.md | 4 ++++ mix.exs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a55ebbf8a..40c423273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## Unreleased + +## Unreleased (Patch) + ## [2.3.0] - 2020-03-01 ### Security diff --git a/mix.exs b/mix.exs index 436381f32..ec6e92df7 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Pleroma.Mixfile do def project do [ app: :pleroma, - version: version("2.3.0"), + version: version("2.3.50"), elixir: "~> 1.9", elixirc_paths: elixirc_paths(Mix.env()), compilers: [:phoenix, :gettext] ++ Mix.compilers(), From 2e296c079f0666a8239a0d3ce5b5fba6baf45a29 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Wed, 3 Mar 2021 15:33:06 +0100 Subject: [PATCH 061/339] Revert "StatusController: Deactivate application support for now." This reverts commit 024c11c18d289d4acd65d749f939ad3684f31905. --- .../controllers/status_controller.ex | 20 +++++++++---------- .../controllers/status_controller_test.exs | 1 - 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index d1a58d5e1..b051fca74 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -21,7 +21,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do alias Pleroma.Web.CommonAPI alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.ScheduledActivityView - # alias Pleroma.Web.OAuth.Token + alias Pleroma.Web.OAuth.Token alias Pleroma.Web.Plugs.OAuthScopesPlug alias Pleroma.Web.Plugs.RateLimiter @@ -420,16 +420,14 @@ def bookmarks(%{assigns: %{user: user}} = conn, params) do ) end - # Deactivated for 2.3.0 - # defp put_application(params, - # %{assigns: %{token: %Token{user: %User{} = user} = token}} = _conn) do - # if user.disclose_client do - # %{client_name: client_name, website: website} = Repo.preload(token, :app).app - # Map.put(params, :generator, %{type: "Application", name: client_name, url: website}) - # else - # Map.put(params, :generator, nil) - # end - # end + defp put_application(params, %{assigns: %{token: %Token{user: %User{} = user} = token}} = _conn) do + if user.disclose_client do + %{client_name: client_name, website: website} = Repo.preload(token, :app).app + Map.put(params, :generator, %{type: "Application", name: client_name, url: website}) + else + Map.put(params, :generator, nil) + end + end defp put_application(params, _), do: Map.put(params, :generator, nil) end diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs index e76c2760d..bd385bccd 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -358,7 +358,6 @@ test "posting a direct status", %{conn: conn} do assert activity.data["cc"] == [] end - @tag :skip test "discloses application metadata when enabled" do user = insert(:user, disclose_client: true) %{user: _user, token: token, conn: conn} = oauth_access(["write:statuses"], user: user) From 10f402af6d0f088aa6ad8a3f26b5e226a2287634 Mon Sep 17 00:00:00 2001 From: lain <lain@soykaf.club> Date: Wed, 3 Mar 2021 15:35:25 +0100 Subject: [PATCH 062/339] Changelog: Re-add application support --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40c423273..ed08701fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +- The `application` metadata returned with statuses is no longer hardcoded. Apps that want to display these details will now have valid data for new posts after this change. + ## Unreleased (Patch) ## [2.3.0] - 2020-03-01 From 5856f51717c12f4c6b0b89e480ff689c8480393d Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Wed, 3 Mar 2021 23:09:30 +0300 Subject: [PATCH 063/339] [#3213] ActivityPub hashtags filtering refactoring. Test fix. --- lib/pleroma/repo.ex | 2 ++ lib/pleroma/web/activity_pub/activity_pub.ex | 29 ++++++------------- mix.exs | 1 + mix.lock | 1 + .../web/activity_pub/activity_pub_test.exs | 2 +- 5 files changed, 14 insertions(+), 21 deletions(-) diff --git a/lib/pleroma/repo.ex b/lib/pleroma/repo.ex index 61b64ed3e..b8ea06e33 100644 --- a/lib/pleroma/repo.ex +++ b/lib/pleroma/repo.ex @@ -8,6 +8,8 @@ defmodule Pleroma.Repo do adapter: Ecto.Adapters.Postgres, migration_timestamps: [type: :naive_datetime_usec] + use Ecto.Explain + import Ecto.Query require Logger diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 9d557c2cd..a4b48ec9b 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -746,6 +746,13 @@ defp restrict_embedded_tag_reject_any(query, %{tag_reject: tag_reject}) defp restrict_embedded_tag_reject_any(query, _), do: query + defp object_ids_query_for_tags(tags) do + from(hto in "hashtags_objects") + |> join(:inner, [hto], ht in Pleroma.Hashtag, on: hto.hashtag_id == ht.id) + |> where([hto, ht], ht.name in ^tags) + |> select([hto], hto.object_id) + end + defp restrict_hashtag_all(_query, %{tag_all: _tag, skip_preload: true}) do raise_on_missing_preload() end @@ -784,16 +791,7 @@ defp restrict_hashtag_any(_query, %{tag: _tag, skip_preload: true}) do defp restrict_hashtag_any(query, %{tag: [_ | _] = tags}) do from( [_activity, object] in query, - where: - fragment( - """ - EXISTS (SELECT 1 FROM hashtags JOIN hashtags_objects - ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?) - AND hashtags_objects.object_id = ? LIMIT 1) - """, - ^tags, - object.id - ) + where: object.id in subquery(object_ids_query_for_tags(tags)) ) end @@ -810,16 +808,7 @@ defp restrict_hashtag_reject_any(_query, %{tag_reject: _tag_reject, skip_preload defp restrict_hashtag_reject_any(query, %{tag_reject: [_ | _] = tags_reject}) do from( [_activity, object] in query, - where: - fragment( - """ - NOT EXISTS (SELECT 1 FROM hashtags JOIN hashtags_objects - ON hashtags_objects.hashtag_id = hashtags.id WHERE hashtags.name = ANY(?) - AND hashtags_objects.object_id = ? LIMIT 1) - """, - ^tags_reject, - object.id - ) + where: object.id not in subquery(object_ids_query_for_tags(tags_reject)) ) end diff --git a/mix.exs b/mix.exs index 50d4b4080..c06e27314 100644 --- a/mix.exs +++ b/mix.exs @@ -121,6 +121,7 @@ defp deps do {:phoenix_pubsub, "~> 2.0"}, {:phoenix_ecto, "~> 4.0"}, {:ecto_enum, "~> 1.4"}, + {:ecto_explain, "~> 0.1.2"}, {:ecto_sql, "~> 3.4.4"}, {:postgrex, ">= 0.15.5"}, {:oban, "~> 2.3.4"}, diff --git a/mix.lock b/mix.lock index 3e5631c72..cb09ffead 100644 --- a/mix.lock +++ b/mix.lock @@ -31,6 +31,7 @@ "earmark_parser": {:hex, :earmark_parser, "1.4.10", "6603d7a603b9c18d3d20db69921527f82ef09990885ed7525003c7fe7dc86c56", [:mix], [], "hexpm", "8e2d5370b732385db2c9b22215c3f59c84ac7dda7ed7e544d7c459496ae519c0"}, "ecto": {:hex, :ecto, "3.4.6", "08f7afad3257d6eb8613309af31037e16c36808dfda5a3cd0cb4e9738db030e4", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6f13a9e2a62e75c2dcfc7207bfc65645ab387af8360db4c89fee8b5a4bf3f70b"}, "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, + "ecto_explain": {:hex, :ecto_explain, "0.1.2", "a9d504cbd4adc809911f796d5ef7ebb17a576a6d32286c3d464c015bd39d5541", [:mix], [], "hexpm", "1d0e7798ae30ecf4ce34e912e5354a0c1c832b7ebceba39298270b9a9f316330"}, "ecto_sql": {:hex, :ecto_sql, "3.4.5", "30161f81b167d561a9a2df4329c10ae05ff36eca7ccc84628f2c8b9fa1e43323", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.0", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "31990c6a3579b36a3c0841d34a94c275e727de8b84f58509da5f1b2032c98ac2"}, "eimp": {:hex, :eimp, "1.0.14", "fc297f0c7e2700457a95a60c7010a5f1dcb768a083b6d53f49cd94ab95a28f22", [:rebar3], [{:p1_utils, "1.0.18", [hex: :p1_utils, repo: "hexpm", optional: false]}], "hexpm", "501133f3112079b92d9e22da8b88bf4f0e13d4d67ae9c15c42c30bd25ceb83b6"}, "elixir_make": {:hex, :elixir_make, "0.6.2", "7dffacd77dec4c37b39af867cedaabb0b59f6a871f89722c25b28fcd4bd70530", [:mix], [], "hexpm", "03e49eadda22526a7e5279d53321d1cced6552f344ba4e03e619063de75348d9"}, diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index f92323abe..1e1e74074 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -220,7 +220,7 @@ test "it fetches the appropriate tag-restricted posts" do {:ok, status_four} = CommonAPI.post(user, %{status: ". #Any1 #any2"}) {:ok, status_five} = CommonAPI.post(user, %{status: ". #Any2 #any1"}) - for hashtag_timeline_strategy <- [:eanbled, :disabled] do + for hashtag_timeline_strategy <- [:enabled, :disabled] do clear_config([:features, :improved_hashtag_timeline], hashtag_timeline_strategy) fetch_one = ActivityPub.fetch_activities([], %{type: "Create", tag: "test"}) From 9876fa8e902e66a77193ebeef674a9f0e9f37657 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn <egor@kislitsyn.com> Date: Thu, 4 Mar 2021 21:13:53 +0400 Subject: [PATCH 064/339] Add UserOperation to Redoc --- lib/pleroma/web/api_spec.ex | 5 +++-- .../operations/admin/user_operation.ex | 20 +++++++++---------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/web/api_spec.ex b/lib/pleroma/web/api_spec.ex index adc8762dc..528cd9cf4 100644 --- a/lib/pleroma/web/api_spec.ex +++ b/lib/pleroma/web/api_spec.ex @@ -92,9 +92,10 @@ def spec(opts \\ []) do "Invites", "MediaProxy cache", "OAuth application managment", - "Report managment", "Relays", - "Status administration" + "Report managment", + "Status administration", + "User administration" ] }, %{"name" => "Applications", "tags" => ["Applications", "Push subscriptions"]}, diff --git a/lib/pleroma/web/api_spec/operations/admin/user_operation.ex b/lib/pleroma/web/api_spec/operations/admin/user_operation.ex index 183c61236..c9d0bfd7c 100644 --- a/lib/pleroma/web/api_spec/operations/admin/user_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/user_operation.ex @@ -17,7 +17,7 @@ def open_api_operation(action) do def index_operation do %Operation{ - tags: ["Users"], + tags: ["User administration"], summary: "List users", operationId: "AdminAPI.UserController.index", security: [%{"oAuth" => ["admin:read:accounts"]}], @@ -63,7 +63,7 @@ def index_operation do def create_operation do %Operation{ - tags: ["Users"], + tags: ["User administration"], summary: "Create a single or multiple users", operationId: "AdminAPI.UserController.create", security: [%{"oAuth" => ["admin:write:accounts"]}], @@ -134,7 +134,7 @@ def create_operation do def show_operation do %Operation{ - tags: ["Users"], + tags: ["User administration"], summary: "Show user", operationId: "AdminAPI.UserController.show", security: [%{"oAuth" => ["admin:read:accounts"]}], @@ -157,7 +157,7 @@ def show_operation do def follow_operation do %Operation{ - tags: ["Users"], + tags: ["User administration"], summary: "Follow", operationId: "AdminAPI.UserController.follow", security: [%{"oAuth" => ["admin:write:follows"]}], @@ -182,7 +182,7 @@ def follow_operation do def unfollow_operation do %Operation{ - tags: ["Users"], + tags: ["User administration"], summary: "Unfollow", operationId: "AdminAPI.UserController.unfollow", security: [%{"oAuth" => ["admin:write:follows"]}], @@ -207,7 +207,7 @@ def unfollow_operation do def approve_operation do %Operation{ - tags: ["Users"], + tags: ["User administration"], summary: "Approve multiple users", operationId: "AdminAPI.UserController.approve", security: [%{"oAuth" => ["admin:write:accounts"]}], @@ -239,7 +239,7 @@ def approve_operation do def toggle_activation_operation do %Operation{ - tags: ["Users"], + tags: ["User administration"], summary: "Toggle user activation", operationId: "AdminAPI.UserController.toggle_activation", security: [%{"oAuth" => ["admin:write:accounts"]}], @@ -256,7 +256,7 @@ def toggle_activation_operation do def activate_operation do %Operation{ - tags: ["Users"], + tags: ["User administration"], summary: "Activate multiple users", operationId: "AdminAPI.UserController.activate", security: [%{"oAuth" => ["admin:write:accounts"]}], @@ -288,7 +288,7 @@ def activate_operation do def deactivate_operation do %Operation{ - tags: ["Users"], + tags: ["User administration"], summary: "Deactivates multiple users", operationId: "AdminAPI.UserController.deactivate", security: [%{"oAuth" => ["admin:write:accounts"]}], @@ -320,7 +320,7 @@ def deactivate_operation do def delete_operation do %Operation{ - tags: ["Users"], + tags: ["User administration"], summary: "Removes a single or multiple users", operationId: "AdminAPI.UserController.delete", security: [%{"oAuth" => ["admin:write:accounts"]}], From 92ab72dbbb4f56a0e0c3d0882ce29d54739437c1 Mon Sep 17 00:00:00 2001 From: Egor Kislitsyn <egor@kislitsyn.com> Date: Fri, 5 Mar 2021 15:51:29 +0400 Subject: [PATCH 065/339] Update OpenApiSpex dependency --- lib/pleroma/web/api_spec/cast_and_validate.ex | 31 ++++++++----------- .../api_spec/operations/status_operation.ex | 2 +- .../web/api_spec/schemas/boolean_like.ex | 10 ++++-- .../controllers/instance_controller.ex | 2 +- .../controllers/backup_controller.ex | 2 +- .../controllers/chat_controller.ex | 2 +- .../controllers/user_import_controller.ex | 2 +- mix.exs | 4 +-- mix.lock | 2 +- test/support/conn_case.ex | 8 ++--- 10 files changed, 30 insertions(+), 35 deletions(-) diff --git a/lib/pleroma/web/api_spec/cast_and_validate.ex b/lib/pleroma/web/api_spec/cast_and_validate.ex index a3da856ff..d23a7dcb6 100644 --- a/lib/pleroma/web/api_spec/cast_and_validate.ex +++ b/lib/pleroma/web/api_spec/cast_and_validate.ex @@ -15,6 +15,7 @@ defmodule Pleroma.Web.ApiSpec.CastAndValidate do @behaviour Plug + alias OpenApiSpex.Plug.PutApiSpec alias Plug.Conn @impl Plug @@ -25,12 +26,10 @@ def init(opts) do end @impl Plug - def call(%{private: %{open_api_spex: private_data}} = conn, %{ - operation_id: operation_id, - render_error: render_error - }) do - spec = private_data.spec - operation = private_data.operation_lookup[operation_id] + + def call(conn, %{operation_id: operation_id, render_error: render_error}) do + {spec, operation_lookup} = PutApiSpec.get_spec_and_operation_lookup(conn) + operation = operation_lookup[operation_id] content_type = case Conn.get_req_header(conn, "content-type") do @@ -43,8 +42,7 @@ def call(%{private: %{open_api_spex: private_data}} = conn, %{ "application/json" end - private_data = Map.put(private_data, :operation_id, operation_id) - conn = Conn.put_private(conn, :open_api_spex, private_data) + conn = Conn.put_private(conn, :operation_id, operation_id) case cast_and_validate(spec, operation, conn, content_type, strict?()) do {:ok, conn} -> @@ -64,25 +62,22 @@ def call( private: %{ phoenix_controller: controller, phoenix_action: action, - open_api_spex: private_data + open_api_spex: %{spec_module: spec_module} } } = conn, opts ) do + {spec, operation_lookup} = PutApiSpec.get_spec_and_operation_lookup(conn) + operation = - case private_data.operation_lookup[{controller, action}] do + case operation_lookup[{controller, action}] do nil -> operation_id = controller.open_api_operation(action).operationId - operation = private_data.operation_lookup[operation_id] + operation = operation_lookup[operation_id] - operation_lookup = - private_data.operation_lookup - |> Map.put({controller, action}, operation) + operation_lookup = Map.put(operation_lookup, {controller, action}, operation) - OpenApiSpex.Plug.Cache.adapter().put( - private_data.spec_module, - {private_data.spec, operation_lookup} - ) + OpenApiSpex.Plug.Cache.adapter().put(spec_module, {spec, operation_lookup}) operation diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index 40edc747d..4bdb8e281 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -59,7 +59,7 @@ def create_operation do Operation.response( "Status. When `scheduled_at` is present, ScheduledStatus is returned instead", "application/json", - %Schema{oneOf: [Status, ScheduledStatus]} + %Schema{anyOf: [Status, ScheduledStatus]} ), 422 => Operation.response("Bad Request / MRF Rejection", "application/json", ApiError) } diff --git a/lib/pleroma/web/api_spec/schemas/boolean_like.ex b/lib/pleroma/web/api_spec/schemas/boolean_like.ex index eb001c5bb..778158f66 100644 --- a/lib/pleroma/web/api_spec/schemas/boolean_like.ex +++ b/lib/pleroma/web/api_spec/schemas/boolean_like.ex @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ApiSpec.Schemas.BooleanLike do + alias OpenApiSpex.Cast alias OpenApiSpex.Schema require OpenApiSpex @@ -27,10 +28,13 @@ defmodule Pleroma.Web.ApiSpec.Schemas.BooleanLike do %Schema{type: :boolean}, %Schema{type: :string}, %Schema{type: :integer} - ] + ], + "x-validate": __MODULE__ }) - def after_cast(value, _schmea) do - {:ok, Pleroma.Web.ControllerHelper.truthy_param?(value)} + def cast(%Cast{value: value} = context) do + context + |> Map.put(:value, Pleroma.Web.ControllerHelper.truthy_param?(value)) + |> Cast.ok() end end diff --git a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex index 267d0f03b..c7a5267d4 100644 --- a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceController do use Pleroma.Web, :controller - plug(OpenApiSpex.Plug.CastAndValidate) + plug(Pleroma.Web.ApiSpec.CastAndValidate) plug( :skip_plug, diff --git a/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex b/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex index 315657e9c..fc5d16771 100644 --- a/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/backup_controller.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Web.PleromaAPI.BackupController do action_fallback(Pleroma.Web.MastodonAPI.FallbackController) plug(OAuthScopesPlug, %{scopes: ["read:accounts"]} when action in [:index, :create]) - plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError) + plug(Pleroma.Web.ApiSpec.CastAndValidate) defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaBackupOperation diff --git a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex index 4adc685fe..dcd54b1af 100644 --- a/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/chat_controller.ex @@ -38,7 +38,7 @@ defmodule Pleroma.Web.PleromaAPI.ChatController do %{scopes: ["read:chats"]} when action in [:messages, :index, :index2, :show] ) - plug(OpenApiSpex.Plug.CastAndValidate, render_error: Pleroma.Web.ApiSpec.RenderError) + plug(Pleroma.Web.ApiSpec.CastAndValidate) defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.ChatOperation diff --git a/lib/pleroma/web/pleroma_api/controllers/user_import_controller.ex b/lib/pleroma/web/pleroma_api/controllers/user_import_controller.ex index 6d9a11fb6..078d470d9 100644 --- a/lib/pleroma/web/pleroma_api/controllers/user_import_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/user_import_controller.ex @@ -15,7 +15,7 @@ defmodule Pleroma.Web.PleromaAPI.UserImportController do plug(OAuthScopesPlug, %{scopes: ["follow", "write:blocks"]} when action == :blocks) plug(OAuthScopesPlug, %{scopes: ["follow", "write:mutes"]} when action == :mutes) - plug(OpenApiSpex.Plug.CastAndValidate) + plug(Pleroma.Web.ApiSpec.CastAndValidate) defdelegate open_api_operation(action), to: ApiSpec.UserImportOperation def follow(%{body_params: %{list: %Plug.Upload{path: path}}} = conn, _) do diff --git a/mix.exs b/mix.exs index ec6e92df7..7f8665ea1 100644 --- a/mix.exs +++ b/mix.exs @@ -195,9 +195,7 @@ defp deps do {:majic, git: "https://git.pleroma.social/pleroma/elixir-libraries/majic.git", ref: "289cda1b6d0d70ccb2ba508a2b0bd24638db2880"}, - {:open_api_spex, - git: "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", - ref: "f296ac0924ba3cf79c7a588c4c252889df4c2edd"}, + {:open_api_spex, "~> 3.10"}, ## dev & test {:ex_doc, "~> 0.22", only: :dev, runtime: false}, diff --git a/mix.lock b/mix.lock index 99be81826..61c79a7f9 100644 --- a/mix.lock +++ b/mix.lock @@ -82,7 +82,7 @@ "nimble_pool": {:hex, :nimble_pool, "0.1.0", "ffa9d5be27eee2b00b0c634eb649aa27f97b39186fec3c493716c2a33e784ec6", [:mix], [], "hexpm", "343a1eaa620ddcf3430a83f39f2af499fe2370390d4f785cd475b4df5acaf3f9"}, "nodex": {:git, "https://git.pleroma.social/pleroma/nodex", "cb6730f943cfc6aad674c92161be23a8411f15d1", [ref: "cb6730f943cfc6aad674c92161be23a8411f15d1"]}, "oban": {:hex, :oban, "2.3.4", "ec7509b9af2524d55f529cb7aee93d36131ae0bf0f37706f65d2fe707f4d9fd8", [:mix], [{:ecto_sql, ">= 3.4.3", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c70ca0434758fd1805422ea4446af5e910ddc697c0c861549c8f0eb0cfbd2fdf"}, - "open_api_spex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", "f296ac0924ba3cf79c7a588c4c252889df4c2edd", [ref: "f296ac0924ba3cf79c7a588c4c252889df4c2edd"]}, + "open_api_spex": {:hex, :open_api_spex, "3.10.0", "94e9521ad525b3fcf6dc77da7c45f87fdac24756d4de588cb0816b413e7c1844", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.1", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "2dbb2bde3d2b821f06936e8dfaf3284331186556291946d84eeba3750ac28765"}, "p1_utils": {:hex, :p1_utils, "1.0.18", "3fe224de5b2e190d730a3c5da9d6e8540c96484cf4b4692921d1e28f0c32b01c", [:rebar3], [], "hexpm", "1fc8773a71a15553b179c986b22fbeead19b28fe486c332d4929700ffeb71f88"}, "parse_trans": {:git, "https://github.com/uwiger/parse_trans.git", "76abb347c3c1d00fb0ccf9e4b43e22b3d2288484", [tag: "3.3.0"]}, "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "1.2.1", "9cbe354b58121075bd20eb83076900a3832324b7dd171a6895fab57b6bb2752c", [:mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}], "hexpm", "d3b40a4a4630f0b442f19eca891fcfeeee4c40871936fed2f68e1c4faa30481f"}, diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 953aa010a..deee98599 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -67,13 +67,11 @@ defp empty_json_response(conn) do end defp json_response_and_validate_schema( - %{ - private: %{ - open_api_spex: %{operation_id: op_id, operation_lookup: lookup, spec: spec} - } - } = conn, + %{private: %{operation_id: op_id}} = conn, status ) do + {spec, lookup} = OpenApiSpex.Plug.PutApiSpec.get_spec_and_operation_lookup(conn) + content_type = conn |> Plug.Conn.get_resp_header("content-type") From e97b34f65d71b3dd11aab151fe7ce6def315635a Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Fri, 5 Mar 2021 13:18:37 -0600 Subject: [PATCH 066/339] Add simple way to decode fully qualified mediaproxy URLs --- lib/pleroma/web/media_proxy.ex | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/pleroma/web/media_proxy.ex b/lib/pleroma/web/media_proxy.ex index 27f337138..d0d4bb4b3 100644 --- a/lib/pleroma/web/media_proxy.ex +++ b/lib/pleroma/web/media_proxy.ex @@ -121,6 +121,11 @@ def decode_url(sig, url) do end end + def decode_url(encoded) do + [_, "proxy", sig, base64 | _] = URI.parse(encoded).path |> String.split("/") + decode_url(sig, base64) + end + defp signed_url(url) do :crypto.hmac(:sha, Config.get([Web.Endpoint, :secret_key_base]), url) end From eaaa20e0f1ac56fee0a8a0eb6a21bc7bf11dbe48 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Fri, 5 Mar 2021 13:21:22 -0600 Subject: [PATCH 067/339] Make tests use it --- test/pleroma/web/media_proxy_test.exs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/pleroma/web/media_proxy_test.exs b/test/pleroma/web/media_proxy_test.exs index 7411d0a7a..b5ee6328d 100644 --- a/test/pleroma/web/media_proxy_test.exs +++ b/test/pleroma/web/media_proxy_test.exs @@ -11,8 +11,7 @@ defmodule Pleroma.Web.MediaProxyTest do alias Pleroma.Web.MediaProxy defp decode_result(encoded) do - [_, "proxy", sig, base64 | _] = URI.parse(encoded).path |> String.split("/") - {:ok, decoded} = MediaProxy.decode_url(sig, base64) + {:ok, decoded} = MediaProxy.decode_url(encoded) decoded end From 7f8785fd9be11fbb09283c2dbd32aeb7903a4f58 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Sun, 7 Mar 2021 11:33:21 +0300 Subject: [PATCH 068/339] [#3213] Performance optimization of filtering by hashtags ("any" condition). --- lib/pleroma/pagination.ex | 3 ++ lib/pleroma/web/activity_pub/activity_pub.ex | 47 ++++++++++++++----- .../controllers/timeline_controller.ex | 39 ++++++--------- 3 files changed, 53 insertions(+), 36 deletions(-) diff --git a/lib/pleroma/pagination.ex b/lib/pleroma/pagination.ex index 0d24e1010..33e45a0eb 100644 --- a/lib/pleroma/pagination.ex +++ b/lib/pleroma/pagination.ex @@ -93,6 +93,7 @@ defp cast_params(params) do max_id: :string, offset: :integer, limit: :integer, + skip_extra_order: :boolean, skip_order: :boolean } @@ -114,6 +115,8 @@ defp restrict(query, :max_id, %{max_id: max_id}, table_binding) do defp restrict(query, :order, %{skip_order: true}, _), do: query + defp restrict(%{order_bys: [_ | _]} = query, :order, %{skip_extra_order: true}, _), do: query + defp restrict(query, :order, %{min_id: _}, table_binding) do order_by( query, diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index a4b48ec9b..230faf024 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -466,6 +466,23 @@ def fetch_latest_direct_activity_id_for_context(context, opts \\ %{}) do |> Repo.one() end + defp fetch_paginated_optimized(query, opts, pagination) do + # Note: tag-filtering funcs may apply "ORDER BY objects.id DESC", + # and extra sorting on "activities.id DESC NULLS LAST" would worse the query plan + opts = Map.put(opts, :skip_extra_order, true) + + Pagination.fetch_paginated(query, opts, pagination) + end + + def fetch_activities(recipients, opts \\ %{}, pagination \\ :keyset) do + list_memberships = Pleroma.List.memberships(opts[:user]) + + fetch_activities_query(recipients ++ list_memberships, opts) + |> fetch_paginated_optimized(opts, pagination) + |> Enum.reverse() + |> maybe_update_cc(list_memberships, opts[:user]) + end + @spec fetch_public_or_unlisted_activities(map(), Pagination.type()) :: [Activity.t()] def fetch_public_or_unlisted_activities(opts \\ %{}, pagination \\ :keyset) do opts = Map.delete(opts, :user) @@ -473,7 +490,7 @@ def fetch_public_or_unlisted_activities(opts \\ %{}, pagination \\ :keyset) do [Constants.as_public()] |> fetch_activities_query(opts) |> restrict_unlisted(opts) - |> Pagination.fetch_paginated(opts, pagination) + |> fetch_paginated_optimized(opts, pagination) end @spec fetch_public_activities(map(), Pagination.type()) :: [Activity.t()] @@ -751,6 +768,7 @@ defp object_ids_query_for_tags(tags) do |> join(:inner, [hto], ht in Pleroma.Hashtag, on: hto.hashtag_id == ht.id) |> where([hto, ht], ht.name in ^tags) |> select([hto], hto.object_id) + |> distinct([hto], true) end defp restrict_hashtag_all(_query, %{tag_all: _tag, skip_preload: true}) do @@ -789,9 +807,18 @@ defp restrict_hashtag_any(_query, %{tag: _tag, skip_preload: true}) do end defp restrict_hashtag_any(query, %{tag: [_ | _] = tags}) do + hashtag_ids = + from(ht in Hashtag, where: ht.name in ^tags, select: ht.id) + |> Repo.all() + + # Note: NO extra ordering should be done on "activities.id desc nulls last" for optimal plan from( [_activity, object] in query, - where: object.id in subquery(object_ids_query_for_tags(tags)) + join: hto in "hashtags_objects", + on: hto.object_id == object.id, + where: hto.hashtag_id in ^hashtag_ids, + distinct: [desc: object.id], + order_by: [desc: object.id] ) end @@ -1188,7 +1215,12 @@ defp normalize_fetch_activities_query_opts(opts) do Map.put(opts, key, Hashtag.normalize_name(value)) value when is_list(value) -> - Map.put(opts, key, Enum.map(value, &Hashtag.normalize_name/1)) + normalized_value = + value + |> Enum.map(&Hashtag.normalize_name/1) + |> Enum.uniq() + + Map.put(opts, key, normalized_value) _ -> opts @@ -1275,15 +1307,6 @@ def fetch_activities_query(recipients, opts \\ %{}) do end end - def fetch_activities(recipients, opts \\ %{}, pagination \\ :keyset) do - list_memberships = Pleroma.List.memberships(opts[:user]) - - fetch_activities_query(recipients ++ list_memberships, opts) - |> Pagination.fetch_paginated(opts, pagination) - |> Enum.reverse() - |> maybe_update_cc(list_memberships, opts[:user]) - end - @doc """ Fetch favorites activities of user with order by sort adds to favorites """ diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 87effa00b..c611958be 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -133,34 +133,25 @@ defp fail_on_bad_auth(conn) do end defp hashtag_fetching(params, user, local_only) do - tags = + # Note: not sanitizing tag options at this stage (may be mix-cased, have duplicates etc.) + tags_any = [params[:tag], params[:any]] |> List.flatten() - |> Enum.reject(&is_nil/1) - |> Enum.map(&String.downcase/1) - |> Enum.uniq() + |> Enum.filter(& &1) - tag_all = - params - |> Map.get(:all, []) - |> Enum.map(&String.downcase/1) + tag_all = Map.get(params, :all, []) + tag_reject = Map.get(params, :none, []) - tag_reject = - params - |> Map.get(:none, []) - |> Enum.map(&String.downcase/1) - - _activities = - params - |> Map.put(:type, "Create") - |> Map.put(:local_only, local_only) - |> Map.put(:blocking_user, user) - |> Map.put(:muting_user, user) - |> Map.put(:user, user) - |> Map.put(:tag, tags) - |> Map.put(:tag_all, tag_all) - |> Map.put(:tag_reject, tag_reject) - |> ActivityPub.fetch_public_activities() + params + |> Map.put(:type, "Create") + |> Map.put(:local_only, local_only) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:user, user) + |> Map.put(:tag, tags_any) + |> Map.put(:tag_all, tag_all) + |> Map.put(:tag_reject, tag_reject) + |> ActivityPub.fetch_public_activities() end # GET /api/v1/timelines/tag/:tag From 8feeb672c8ec0b916d94fb516ea05b464342e19b Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Wed, 10 Mar 2021 13:03:14 -0600 Subject: [PATCH 069/339] Ensure we fetch deps during spec-build stage --- .gitlab-ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c7e8291d8..68644660c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -40,6 +40,7 @@ spec-build: paths: - spec.json script: + - mix deps.get - mix pleroma.openapi_spec spec.json benchmark: @@ -393,4 +394,4 @@ docker-adhoc: tags: - dind only: - - /^build-docker/.*$/@pleroma/pleroma \ No newline at end of file + - /^build-docker/.*$/@pleroma/pleroma From 502d166b7e44e36a94974df4770de6c6a239ad75 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Wed, 10 Mar 2021 16:19:18 -0600 Subject: [PATCH 070/339] See if switching to same image as releases fixes the build --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 68644660c..ea6611947 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: elixir:1.9.4 +image: elixir:1.10.3 variables: &global_variables POSTGRES_DB: pleroma_test From fa75f11ca138e69952cf1a1483a8b848b3b0d1b9 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Wed, 10 Mar 2021 16:37:24 -0600 Subject: [PATCH 071/339] Revert "See if switching to same image as releases fixes the build" This reverts commit 502d166b7e44e36a94974df4770de6c6a239ad75. --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ea6611947..68644660c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: elixir:1.10.3 +image: elixir:1.9.4 variables: &global_variables POSTGRES_DB: pleroma_test From 8246db2a968943a0cab615b8b5c1439aa4cb2547 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Sat, 6 Mar 2021 12:02:32 -0600 Subject: [PATCH 072/339] Workaround for URI.merge/2 bug https://github.com/elixir-lang/elixir/issues/10771 If we avoid URI.merge unless we know we need it we reduce the edge cases we could encounter. The site would need to both have "//" in the %URI{:path} and the image needs to be a relative URL. --- lib/pleroma/web/mastodon_api/views/status_view.ex | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index f3f54e03d..cf8037abb 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -380,9 +380,15 @@ def render("card.json", %{rich_media: rich_media, page_url: page_url}) do page_url = page_url_data |> to_string image_url = - if is_binary(rich_media["image"]) do - URI.merge(page_url_data, URI.parse(rich_media["image"])) - |> to_string + cond do + !is_binary(rich_media["image"]) -> + nil + + String.starts_with?(rich_media["image"], "http") -> + rich_media["image"] + + true -> + URI.merge(page_url_data, URI.parse(rich_media["image"])) |> to_string end %{ From 029ff6538972b59c6259dd7345ad9c4465fb3f73 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Thu, 11 Mar 2021 09:20:29 -0600 Subject: [PATCH 073/339] Leverage function pattern matching instead --- .../web/mastodon_api/views/status_view.ex | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index cf8037abb..581b4e952 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -379,18 +379,15 @@ def render("card.json", %{rich_media: rich_media, page_url: page_url}) do page_url = page_url_data |> to_string - image_url = - cond do - !is_binary(rich_media["image"]) -> - nil - - String.starts_with?(rich_media["image"], "http") -> - rich_media["image"] - - true -> - URI.merge(page_url_data, URI.parse(rich_media["image"])) |> to_string + image_url_data = + if is_binary(rich_media["image"]) do + URI.parse(rich_media["image"]) + else + nil end + image_url = get_image_url(image_url_data, page_url_data) + %{ type: "link", provider_name: page_url_data.host, @@ -546,4 +543,23 @@ defp build_application(%{"type" => _type, "name" => name, "url" => url}), do: %{name: name, website: url} defp build_application(_), do: nil + + # Workaround for Elixir issue #10771 + # Avoid applying URI.merge unless necessary + # TODO: revert to always attempting URI.merge(image_url_data, page_url_data) + # when Elixir 1.12 is the minimum supported version + @spec get_image_url(struct() | nil, struct()) :: String.t() | nil + defp get_image_url( + %URI{scheme: image_scheme, host: image_host} = image_url_data, + %URI{} = _page_url_data + ) + when not is_nil(image_scheme) and not is_nil(image_host) do + image_url_data |> to_string + end + + defp get_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do + URI.merge(page_url_data, image_url_data) |> to_string + end + + defp get_image_url(_, _), do: nil end From 884584772bd7ff52825bbb3bd38ca7c6190c084a Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Thu, 11 Mar 2021 09:40:40 -0600 Subject: [PATCH 074/339] Execute mix deps.get earlier and avoid duplicate invocations if possible --- .gitlab-ci.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 68644660c..2bc571971 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -25,13 +25,13 @@ before_script: - apt-get update && apt-get install -y cmake - mix local.hex --force - mix local.rebar --force + - mix deps.get - apt-get -qq update - apt-get install -y libmagic-dev build: stage: build script: - - mix deps.get - mix compile --force spec-build: @@ -40,7 +40,6 @@ spec-build: paths: - spec.json script: - - mix deps.get - mix pleroma.openapi_spec spec.json benchmark: @@ -53,7 +52,6 @@ benchmark: alias: postgres command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"] script: - - mix deps.get - mix ecto.create - mix ecto.migrate - mix pleroma.load_testing @@ -71,7 +69,6 @@ unit-testing: command: ["postgres", "-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"] script: - apt-get update && apt-get install -y libimage-exiftool-perl ffmpeg - - mix deps.get - mix ecto.create - mix ecto.migrate - mix coveralls --preload-modules @@ -105,7 +102,6 @@ unit-testing-rum: RUM_ENABLED: "true" script: - apt-get update && apt-get install -y libimage-exiftool-perl ffmpeg - - mix deps.get - mix ecto.create - mix ecto.migrate - "mix ecto.migrate --migrations-path priv/repo/optional_migrations/rum_indexing/" @@ -121,7 +117,6 @@ analysis: stage: test cache: *testing_cache_policy script: - - mix deps.get - mix credo --strict --only=warnings,todo,fixme,consistency,readability docs-deploy: From 3edf45021eb6c3fba06bc083b346f7db54cd073f Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Fri, 12 Mar 2021 12:18:11 +0300 Subject: [PATCH 075/339] [#3213] Background migration infrastructure refactoring. Extracted BaseMigrator and BaseMigratorState. --- lib/pleroma/application.ex | 11 +- .../migrators/hashtags_table_migrator.ex | 265 ++++-------------- .../hashtags_table_migrator/state.ex | 104 ------- .../migrators/support/base_migrator.ex | 210 ++++++++++++++ .../migrators/support/base_migrator_state.ex | 116 ++++++++ 5 files changed, 385 insertions(+), 321 deletions(-) delete mode 100644 lib/pleroma/migrators/hashtags_table_migrator/state.ex create mode 100644 lib/pleroma/migrators/support/base_migrator.ex create mode 100644 lib/pleroma/migrators/support/base_migrator_state.ex diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index 2ff7562e2..06d399b2e 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -103,10 +103,7 @@ def start(_type, _args) do task_children(@mix_env) ++ dont_run_in_test(@mix_env) ++ chat_child(chat_enabled?()) ++ - [ - Pleroma.Migrators.HashtagsTableMigrator, - Pleroma.Gopher.Server - ] + [Pleroma.Gopher.Server] # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html # for other strategies and supported options @@ -231,6 +228,12 @@ defp dont_run_in_test(_) do keys: :duplicate, partitions: System.schedulers_online() ]} + ] ++ background_migrators() + end + + defp background_migrators do + [ + Pleroma.Migrators.HashtagsTableMigrator ] end diff --git a/lib/pleroma/migrators/hashtags_table_migrator.ex b/lib/pleroma/migrators/hashtags_table_migrator.ex index 6123c88e0..b84058e11 100644 --- a/lib/pleroma/migrators/hashtags_table_migrator.ex +++ b/lib/pleroma/migrators/hashtags_table_migrator.ex @@ -3,88 +3,27 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Migrators.HashtagsTableMigrator do - use GenServer + defmodule State do + use Pleroma.Migrators.Support.BaseMigratorState - require Logger + @impl Pleroma.Migrators.Support.BaseMigratorState + defdelegate data_migration(), to: Pleroma.DataMigration, as: :populate_hashtags_table + end - import Ecto.Query + use Pleroma.Migrators.Support.BaseMigrator - alias __MODULE__.State - alias Pleroma.Config alias Pleroma.Hashtag + alias Pleroma.Migrators.Support.BaseMigrator alias Pleroma.Object - alias Pleroma.Repo - defdelegate data_migration(), to: Pleroma.DataMigration, as: :populate_hashtags_table - defdelegate data_migration_id(), to: State + @impl BaseMigrator + def feature_config_path, do: [:features, :improved_hashtag_timeline] - defdelegate state(), to: State - defdelegate persist_state(), to: State, as: :persist_to_db - defdelegate get_stat(key, value \\ nil), to: State, as: :get_data_key - defdelegate put_stat(key, value), to: State, as: :put_data_key - defdelegate increment_stat(key, increment), to: State, as: :increment_data_key - - @feature_config_path [:features, :improved_hashtag_timeline] - @reg_name {:global, __MODULE__} - - def whereis, do: GenServer.whereis(@reg_name) - - def feature_state, do: Config.get(@feature_config_path) - - def start_link(_) do - case whereis() do - nil -> - GenServer.start_link(__MODULE__, nil, name: @reg_name) - - pid -> - {:ok, pid} - end - end - - @impl true - def init(_) do - {:ok, nil, {:continue, :init_state}} - end - - @impl true - def handle_continue(:init_state, _state) do - {:ok, _} = State.start_link(nil) - - data_migration = data_migration() - manual_migrations = Config.get([:instance, :manual_data_migrations], []) - - cond do - Config.get(:env) == :test -> - update_status(:noop) - - is_nil(data_migration) -> - message = "Data migration does not exist." - update_status(:failed, message) - Logger.error("#{__MODULE__}: #{message}") - - data_migration.state == :manual or data_migration.name in manual_migrations -> - message = "Data migration is in manual execution or manual fix mode." - update_status(:manual, message) - Logger.warn("#{__MODULE__}: #{message}") - - data_migration.state == :complete -> - on_complete(data_migration) - - true -> - send(self(), :migrate_hashtags) - end - - {:noreply, nil} - end - - @impl true - def handle_info(:migrate_hashtags, state) do - State.reinit() - - update_status(:running) - put_stat(:iteration_processed_count, 0) - put_stat(:started_at, NaiveDateTime.utc_now()) + @impl BaseMigrator + def fault_rate_allowance, do: Config.get([:populate_hashtags_table, :fault_rate_allowance], 0) + @impl BaseMigrator + def perform do data_migration_id = data_migration_id() max_processed_id = get_stat(:max_processed_id, 0) @@ -103,7 +42,7 @@ def handle_info(:migrate_hashtags, state) do |> Enum.filter(&(elem(&1, 0) == :error)) |> Enum.map(&elem(&1, 1)) - # Count of objects with hashtags (`{:noop, id}` is returned for objects having other AS2 tags) + # Count of objects with hashtags: `{:noop, id}` is returned for objects having other AS2 tags chunk_affected_count = results |> Enum.filter(&(elem(&1, 0) == :ok)) @@ -140,84 +79,10 @@ def handle_info(:migrate_hashtags, state) do Process.sleep(sleep_interval) end) |> Stream.run() - - fault_rate = fault_rate() - put_stat(:fault_rate, fault_rate) - fault_rate_allowance = Config.get([:populate_hashtags_table, :fault_rate_allowance], 0) - - cond do - fault_rate == 0 -> - set_complete() - - is_float(fault_rate) and fault_rate <= fault_rate_allowance -> - message = """ - Done with fault rate of #{fault_rate} which doesn't exceed #{fault_rate_allowance}. - Putting data migration to manual fix mode. Check `retry_failed/0`. - """ - - Logger.warn("#{__MODULE__}: #{message}") - update_status(:manual, message) - on_complete(data_migration()) - - true -> - message = "Too many failures. Check data_migration_failed_ids records / `retry_failed/0`." - Logger.error("#{__MODULE__}: #{message}") - update_status(:failed, message) - end - - persist_state() - {:noreply, state} end - def fault_rate do - with failures_count when is_integer(failures_count) <- failures_count() do - failures_count / Enum.max([get_stat(:affected_count, 0), 1]) - else - _ -> :error - end - end - - defp records_per_second do - get_stat(:iteration_processed_count, 0) / Enum.max([running_time(), 1]) - end - - defp running_time do - NaiveDateTime.diff(NaiveDateTime.utc_now(), get_stat(:started_at, NaiveDateTime.utc_now())) - end - - @hashtags_objects_cleanup_query """ - DELETE FROM hashtags_objects WHERE object_id IN - (SELECT DISTINCT objects.id FROM objects - JOIN hashtags_objects ON hashtags_objects.object_id = objects.id LEFT JOIN activities - ON COALESCE(activities.data->'object'->>'id', activities.data->>'object') = - (objects.data->>'id') - AND activities.data->>'type' = 'Create' - WHERE activities.id IS NULL); - """ - - @hashtags_cleanup_query """ - DELETE FROM hashtags WHERE id IN - (SELECT hashtags.id FROM hashtags - LEFT OUTER JOIN hashtags_objects - ON hashtags_objects.hashtag_id = hashtags.id - WHERE hashtags_objects.hashtag_id IS NULL); - """ - - @doc """ - Deletes `hashtags_objects` for legacy objects not asoociated with Create activity. - Also deletes unreferenced `hashtags` records (might occur after deletion of `hashtags_objects`). - """ - def delete_non_create_activities_hashtags do - {:ok, %{num_rows: hashtags_objects_count}} = - Repo.query(@hashtags_objects_cleanup_query, [], timeout: :infinity) - - {:ok, %{num_rows: hashtags_count}} = - Repo.query(@hashtags_cleanup_query, [], timeout: :infinity) - - {:ok, hashtags_objects_count, hashtags_count} - end - - defp query do + @impl BaseMigrator + def query do # Note: most objects have Mention-type AS2 tags and no hashtags (but we can't filter them out) # Note: not checking activity type, expecting remove_non_create_objects_hashtags/_ to clean up from( @@ -276,54 +141,7 @@ defp transfer_object_hashtags(object, hashtags) do end) end - @doc "Approximate count for current iteration (including processed records count)" - def count(force \\ false, timeout \\ :infinity) do - stored_count = get_stat(:count) - - if stored_count && !force do - stored_count - else - processed_count = get_stat(:processed_count, 0) - max_processed_id = get_stat(:max_processed_id, 0) - query = where(query(), [object], object.id > ^max_processed_id) - - count = Repo.aggregate(query, :count, :id, timeout: timeout) + processed_count - put_stat(:count, count) - persist_state() - - count - end - end - - defp on_complete(data_migration) do - if data_migration.feature_lock || feature_state() == :disabled do - Logger.warn("#{__MODULE__}: migration complete but feature is locked; consider enabling.") - :noop - else - Config.put(@feature_config_path, :enabled) - :ok - end - end - - def failed_objects_query do - from(o in Object) - |> join(:inner, [o], dmf in fragment("SELECT * FROM data_migration_failed_ids"), - on: dmf.record_id == o.id - ) - |> where([_o, dmf], dmf.data_migration_id == ^data_migration_id()) - |> order_by([o], asc: o.id) - end - - def failures_count do - with {:ok, %{rows: [[count]]}} <- - Repo.query( - "SELECT COUNT(record_id) FROM data_migration_failed_ids WHERE data_migration_id = $1;", - [data_migration_id()] - ) do - count - end - end - + @impl BaseMigrator def retry_failed do data_migration_id = data_migration_id() @@ -347,23 +165,44 @@ def retry_failed do force_continue() end - def force_continue do - send(whereis(), :migrate_hashtags) + defp failed_objects_query do + from(o in Object) + |> join(:inner, [o], dmf in fragment("SELECT * FROM data_migration_failed_ids"), + on: dmf.record_id == o.id + ) + |> where([_o, dmf], dmf.data_migration_id == ^data_migration_id()) + |> order_by([o], asc: o.id) end - def force_restart do - :ok = State.reset() - force_continue() - end + @doc """ + Service func to delete `hashtags_objects` for legacy objects not associated with Create activity. + Also deletes unreferenced `hashtags` records (might occur after deletion of `hashtags_objects`). + """ + def delete_non_create_activities_hashtags do + hashtags_objects_cleanup_query = """ + DELETE FROM hashtags_objects WHERE object_id IN + (SELECT DISTINCT objects.id FROM objects + JOIN hashtags_objects ON hashtags_objects.object_id = objects.id LEFT JOIN activities + ON COALESCE(activities.data->'object'->>'id', activities.data->>'object') = + (objects.data->>'id') + AND activities.data->>'type' = 'Create' + WHERE activities.id IS NULL); + """ - def set_complete do - update_status(:complete) - persist_state() - on_complete(data_migration()) - end + hashtags_cleanup_query = """ + DELETE FROM hashtags WHERE id IN + (SELECT hashtags.id FROM hashtags + LEFT OUTER JOIN hashtags_objects + ON hashtags_objects.hashtag_id = hashtags.id + WHERE hashtags_objects.hashtag_id IS NULL); + """ - defp update_status(status, message \\ nil) do - put_stat(:state, status) - put_stat(:message, message) + {:ok, %{num_rows: hashtags_objects_count}} = + Repo.query(hashtags_objects_cleanup_query, [], timeout: :infinity) + + {:ok, %{num_rows: hashtags_count}} = + Repo.query(hashtags_cleanup_query, [], timeout: :infinity) + + {:ok, hashtags_objects_count, hashtags_count} end end diff --git a/lib/pleroma/migrators/hashtags_table_migrator/state.ex b/lib/pleroma/migrators/hashtags_table_migrator/state.ex deleted file mode 100644 index ee0009b2e..000000000 --- a/lib/pleroma/migrators/hashtags_table_migrator/state.ex +++ /dev/null @@ -1,104 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Migrators.HashtagsTableMigrator.State do - use Agent - - alias Pleroma.DataMigration - - defdelegate data_migration(), to: Pleroma.Migrators.HashtagsTableMigrator - - @reg_name {:global, __MODULE__} - - def start_link(_) do - Agent.start_link(fn -> load_state_from_db() end, name: @reg_name) - end - - defp load_state_from_db do - data_migration = data_migration() - - data = - if data_migration do - Map.new(data_migration.data, fn {k, v} -> {String.to_atom(k), v} end) - else - %{} - end - - %{ - data_migration_id: data_migration && data_migration.id, - data: data - } - end - - def persist_to_db do - %{data_migration_id: data_migration_id, data: data} = state() - - if data_migration_id do - DataMigration.update_one_by_id(data_migration_id, data: data) - else - {:error, :nil_data_migration_id} - end - end - - def reset do - %{data_migration_id: data_migration_id} = state() - - with false <- is_nil(data_migration_id), - :ok <- - DataMigration.update_one_by_id(data_migration_id, - state: :pending, - data: %{} - ) do - reinit() - else - true -> {:error, :nil_data_migration_id} - e -> e - end - end - - def reinit do - Agent.update(@reg_name, fn _state -> load_state_from_db() end) - end - - def state do - Agent.get(@reg_name, & &1) - end - - def get_data_key(key, default \\ nil) do - get_in(state(), [:data, key]) || default - end - - def put_data_key(key, value) do - _ = persist_non_data_change(key, value) - - Agent.update(@reg_name, fn state -> - put_in(state, [:data, key], value) - end) - end - - def increment_data_key(key, increment \\ 1) do - Agent.update(@reg_name, fn state -> - initial_value = get_in(state, [:data, key]) || 0 - updated_value = initial_value + increment - put_in(state, [:data, key], updated_value) - end) - end - - defp persist_non_data_change(:state, value) do - with true <- get_data_key(:state) != value, - true <- value in Pleroma.DataMigration.State.__valid_values__(), - %{data_migration_id: data_migration_id} when not is_nil(data_migration_id) <- state() do - DataMigration.update_one_by_id(data_migration_id, state: value) - else - false -> :ok - _ -> {:error, :nil_data_migration_id} - end - end - - defp persist_non_data_change(_, _) do - nil - end - - def data_migration_id, do: Map.get(state(), :data_migration_id) -end diff --git a/lib/pleroma/migrators/support/base_migrator.ex b/lib/pleroma/migrators/support/base_migrator.ex new file mode 100644 index 000000000..1f8a5402b --- /dev/null +++ b/lib/pleroma/migrators/support/base_migrator.ex @@ -0,0 +1,210 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Migrators.Support.BaseMigrator do + @moduledoc """ + Base background migrator functionality. + """ + + @callback perform() :: any() + @callback retry_failed() :: any() + @callback feature_config_path() :: list(atom()) + @callback query() :: Ecto.Query.t() + @callback fault_rate_allowance() :: integer() | float() + + defmacro __using__(_opts) do + quote do + use GenServer + + require Logger + + import Ecto.Query + + alias __MODULE__.State + alias Pleroma.Config + alias Pleroma.Repo + + @behaviour Pleroma.Migrators.Support.BaseMigrator + + defdelegate data_migration(), to: State + defdelegate data_migration_id(), to: State + defdelegate state(), to: State + defdelegate persist_state(), to: State, as: :persist_to_db + defdelegate get_stat(key, value \\ nil), to: State, as: :get_data_key + defdelegate put_stat(key, value), to: State, as: :put_data_key + defdelegate increment_stat(key, increment), to: State, as: :increment_data_key + + @reg_name {:global, __MODULE__} + + def whereis, do: GenServer.whereis(@reg_name) + + def start_link(_) do + case whereis() do + nil -> + GenServer.start_link(__MODULE__, nil, name: @reg_name) + + pid -> + {:ok, pid} + end + end + + @impl true + def init(_) do + {:ok, nil, {:continue, :init_state}} + end + + @impl true + def handle_continue(:init_state, _state) do + {:ok, _} = State.start_link(nil) + + data_migration = data_migration() + manual_migrations = Config.get([:instance, :manual_data_migrations], []) + + cond do + Config.get(:env) == :test -> + update_status(:noop) + + is_nil(data_migration) -> + message = "Data migration does not exist." + update_status(:failed, message) + Logger.error("#{__MODULE__}: #{message}") + + data_migration.state == :manual or data_migration.name in manual_migrations -> + message = "Data migration is in manual execution or manual fix mode." + update_status(:manual, message) + Logger.warn("#{__MODULE__}: #{message}") + + data_migration.state == :complete -> + on_complete(data_migration) + + true -> + send(self(), :perform) + end + + {:noreply, nil} + end + + @impl true + def handle_info(:perform, state) do + State.reinit() + + update_status(:running) + put_stat(:iteration_processed_count, 0) + put_stat(:started_at, NaiveDateTime.utc_now()) + + perform() + + fault_rate = fault_rate() + put_stat(:fault_rate, fault_rate) + fault_rate_allowance = fault_rate_allowance() + + cond do + fault_rate == 0 -> + set_complete() + + is_float(fault_rate) and fault_rate <= fault_rate_allowance -> + message = """ + Done with fault rate of #{fault_rate} which doesn't exceed #{fault_rate_allowance}. + Putting data migration to manual fix mode. Try running `#{__MODULE__}.retry_failed/0`. + """ + + Logger.warn("#{__MODULE__}: #{message}") + update_status(:manual, message) + on_complete(data_migration()) + + true -> + message = "Too many failures. Try running `#{__MODULE__}.retry_failed/0`." + Logger.error("#{__MODULE__}: #{message}") + update_status(:failed, message) + end + + persist_state() + {:noreply, state} + end + + defp on_complete(data_migration) do + if data_migration.feature_lock || feature_state() == :disabled do + Logger.warn( + "#{__MODULE__}: migration complete but feature is locked; consider enabling." + ) + + :noop + else + Config.put(feature_config_path(), :enabled) + :ok + end + end + + @doc "Approximate count for current iteration (including processed records count)" + def count(force \\ false, timeout \\ :infinity) do + stored_count = get_stat(:count) + + if stored_count && !force do + stored_count + else + processed_count = get_stat(:processed_count, 0) + max_processed_id = get_stat(:max_processed_id, 0) + query = where(query(), [entity], entity.id > ^max_processed_id) + + count = Repo.aggregate(query, :count, :id, timeout: timeout) + processed_count + put_stat(:count, count) + persist_state() + + count + end + end + + def failures_count do + with {:ok, %{rows: [[count]]}} <- + Repo.query( + "SELECT COUNT(record_id) FROM data_migration_failed_ids WHERE data_migration_id = $1;", + [data_migration_id()] + ) do + count + end + end + + def feature_state, do: Config.get(feature_config_path()) + + def force_continue do + send(whereis(), :perform) + end + + def force_restart do + :ok = State.reset() + force_continue() + end + + def set_complete do + update_status(:complete) + persist_state() + on_complete(data_migration()) + end + + defp update_status(status, message \\ nil) do + put_stat(:state, status) + put_stat(:message, message) + end + + defp fault_rate do + with failures_count when is_integer(failures_count) <- failures_count() do + failures_count / Enum.max([get_stat(:affected_count, 0), 1]) + else + _ -> :error + end + end + + defp records_per_second do + get_stat(:iteration_processed_count, 0) / Enum.max([running_time(), 1]) + end + + defp running_time do + NaiveDateTime.diff( + NaiveDateTime.utc_now(), + get_stat(:started_at, NaiveDateTime.utc_now()) + ) + end + end + end +end diff --git a/lib/pleroma/migrators/support/base_migrator_state.ex b/lib/pleroma/migrators/support/base_migrator_state.ex new file mode 100644 index 000000000..69724ae79 --- /dev/null +++ b/lib/pleroma/migrators/support/base_migrator_state.ex @@ -0,0 +1,116 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Migrators.Support.BaseMigratorState do + @moduledoc """ + Base background migrator state functionality. + """ + + @callback data_migration() :: Pleroma.DataMigration.t() + + defmacro __using__(_opts) do + quote do + use Agent + + alias Pleroma.DataMigration + + @behaviour Pleroma.Migrators.Support.BaseMigratorState + @reg_name {:global, __MODULE__} + + def start_link(_) do + Agent.start_link(fn -> load_state_from_db() end, name: @reg_name) + end + + def data_migration, do: raise("data_migration/0 is not implemented") + defoverridable data_migration: 0 + + defp load_state_from_db do + data_migration = data_migration() + + data = + if data_migration do + Map.new(data_migration.data, fn {k, v} -> {String.to_atom(k), v} end) + else + %{} + end + + %{ + data_migration_id: data_migration && data_migration.id, + data: data + } + end + + def persist_to_db do + %{data_migration_id: data_migration_id, data: data} = state() + + if data_migration_id do + DataMigration.update_one_by_id(data_migration_id, data: data) + else + {:error, :nil_data_migration_id} + end + end + + def reset do + %{data_migration_id: data_migration_id} = state() + + with false <- is_nil(data_migration_id), + :ok <- + DataMigration.update_one_by_id(data_migration_id, + state: :pending, + data: %{} + ) do + reinit() + else + true -> {:error, :nil_data_migration_id} + e -> e + end + end + + def reinit do + Agent.update(@reg_name, fn _state -> load_state_from_db() end) + end + + def state do + Agent.get(@reg_name, & &1) + end + + def get_data_key(key, default \\ nil) do + get_in(state(), [:data, key]) || default + end + + def put_data_key(key, value) do + _ = persist_non_data_change(key, value) + + Agent.update(@reg_name, fn state -> + put_in(state, [:data, key], value) + end) + end + + def increment_data_key(key, increment \\ 1) do + Agent.update(@reg_name, fn state -> + initial_value = get_in(state, [:data, key]) || 0 + updated_value = initial_value + increment + put_in(state, [:data, key], updated_value) + end) + end + + defp persist_non_data_change(:state, value) do + with true <- get_data_key(:state) != value, + true <- value in Pleroma.DataMigration.State.__valid_values__(), + %{data_migration_id: data_migration_id} when not is_nil(data_migration_id) <- state() do + DataMigration.update_one_by_id(data_migration_id, state: value) + else + false -> :ok + _ -> {:error, :nil_data_migration_id} + end + end + + defp persist_non_data_change(_, _) do + nil + end + + def data_migration_id, do: Map.get(state(), :data_migration_id) + end + end +end From cb734566093f406fc3db12de2408fc166486f417 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov <ivantashkinov@gmail.com> Date: Fri, 12 Mar 2021 12:25:18 +0300 Subject: [PATCH 076/339] [#3213] Code formatting fix. --- lib/pleroma/migrators/support/base_migrator_state.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/migrators/support/base_migrator_state.ex b/lib/pleroma/migrators/support/base_migrator_state.ex index 69724ae79..b698587f2 100644 --- a/lib/pleroma/migrators/support/base_migrator_state.ex +++ b/lib/pleroma/migrators/support/base_migrator_state.ex @@ -98,7 +98,8 @@ def increment_data_key(key, increment \\ 1) do defp persist_non_data_change(:state, value) do with true <- get_data_key(:state) != value, true <- value in Pleroma.DataMigration.State.__valid_values__(), - %{data_migration_id: data_migration_id} when not is_nil(data_migration_id) <- state() do + %{data_migration_id: data_migration_id} when not is_nil(data_migration_id) <- + state() do DataMigration.update_one_by_id(data_migration_id, state: value) else false -> :ok From 2408363e2a5115e4856957ba46231211eec6b338 Mon Sep 17 00:00:00 2001 From: Ben Is <spambenis@fastwebnet.it> Date: Thu, 11 Mar 2021 13:51:22 +0000 Subject: [PATCH 077/339] Translated using Weblate (Italian) Currently translated at 100.0% (106 of 106 strings) Translation: Pleroma/Pleroma backend Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma/it/ --- priv/gettext/it/LC_MESSAGES/errors.po | 44 +++++++++++++-------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/priv/gettext/it/LC_MESSAGES/errors.po b/priv/gettext/it/LC_MESSAGES/errors.po index cd0cd6c65..6a6ec058e 100644 --- a/priv/gettext/it/LC_MESSAGES/errors.po +++ b/priv/gettext/it/LC_MESSAGES/errors.po @@ -3,8 +3,8 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-06-19 14:33+0000\n" -"PO-Revision-Date: 2020-07-09 14:40+0000\n" -"Last-Translator: Ben Is <srsbzns@cock.li>\n" +"PO-Revision-Date: 2021-03-13 09:40+0000\n" +"Last-Translator: Ben Is <spambenis@fastwebnet.it>\n" "Language-Team: Italian <https://translate.pleroma.social/projects/pleroma/" "pleroma/it/>\n" "Language: it\n" @@ -45,7 +45,7 @@ msgstr "ha una voce invalida" ## From Ecto.Changeset.validate_exclusion/3 msgid "is reserved" -msgstr "è vietato" +msgstr "è riservato" ## From Ecto.Changeset.validate_confirmation/3 msgid "does not match confirmation" @@ -123,7 +123,7 @@ msgstr "Richiesta invalida" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:425 #, elixir-format msgid "Can't delete object" -msgstr "Non puoi eliminare quest'oggetto" +msgstr "Oggetto non eliminabile" #: lib/pleroma/web/mastodon_api/controllers/status_controller.ex:196 #, elixir-format @@ -160,12 +160,12 @@ msgstr "Non puoi pubblicare un messaggio vuoto senza allegati" #: lib/pleroma/web/common_api/utils.ex:504 #, elixir-format msgid "Comment must be up to %{max_size} characters" -msgstr "I commenti posso al massimo consistere di %{max_size} caratteri" +msgstr "I commenti posso al massimo contenere %{max_size} caratteri" #: lib/pleroma/config/config_db.ex:222 #, elixir-format msgid "Config with params %{params} not found" -msgstr "Configurazione con parametri %{max_size} non trovata" +msgstr "Configurazione con parametri %{params} non trovata" #: lib/pleroma/web/common_api/common_api.ex:95 #, elixir-format @@ -200,7 +200,7 @@ msgstr "Non de-intestato" #: lib/pleroma/web/common_api/common_api.ex:126 #, elixir-format msgid "Could not unrepeat" -msgstr "Non de-ripetuto" +msgstr "Non de-condiviso" #: lib/pleroma/web/common_api/common_api.ex:428 #: lib/pleroma/web/common_api/common_api.ex:437 @@ -310,12 +310,12 @@ msgstr "Il messaggio ha superato la lunghezza massima" #: lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex:31 #, elixir-format msgid "This resource requires authentication." -msgstr "Accedi per leggere." +msgstr "Accedi per poter leggere." #: lib/pleroma/plugs/rate_limiter/rate_limiter.ex:206 #, elixir-format msgid "Throttled" -msgstr "Strozzato" +msgstr "Limitato" #: lib/pleroma/web/common_api/common_api.ex:266 #, elixir-format @@ -347,17 +347,17 @@ msgstr "Devi aggiungere un indirizzo email valido" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:389 #, elixir-format msgid "can't read inbox of %{nickname} as %{as_nickname}" -msgstr "non puoi leggere i messaggi privati di %{nickname} come %{as_nickname}" +msgstr "non puoi leggere i messaggi di %{nickname} come %{as_nickname}" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:472 #, elixir-format msgid "can't update outbox of %{nickname} as %{as_nickname}" -msgstr "non puoi aggiornare gli inviati di %{nickname} come %{as_nickname}" +msgstr "non puoi inviare da %{nickname} come %{as_nickname}" #: lib/pleroma/web/common_api/common_api.ex:388 #, elixir-format msgid "conversation is already muted" -msgstr "la conversazione è già zittita" +msgstr "la conversazione è già silenziata" #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:316 #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:491 @@ -419,7 +419,7 @@ msgstr "Errore interno" #: lib/pleroma/web/oauth/fallback_controller.ex:29 #, elixir-format msgid "Invalid Username/Password" -msgstr "Nome utente/parola d'ordine invalidi" +msgstr "Nome utente/password invalidi" #: lib/pleroma/web/twitter_api/twitter_api.ex:118 #, elixir-format @@ -455,7 +455,7 @@ msgstr "Gestore OAuth non supportato: %{provider}." #: lib/pleroma/uploaders/uploader.ex:72 #, elixir-format msgid "Uploader callback timeout" -msgstr "Callback caricatmento scaduta" +msgstr "Callback caricamento scaduta" #: lib/pleroma/web/uploader_controller.ex:23 #, elixir-format @@ -496,7 +496,7 @@ msgstr "Parametro mancante: %{name}" #: lib/pleroma/web/oauth/oauth_controller.ex:322 #, elixir-format msgid "Password reset is required" -msgstr "Necessario reimpostare parola d'ordine" +msgstr "Necessario reimpostare password" #: lib/pleroma/tests/auth_test_controller.ex:9 #: lib/pleroma/web/activity_pub/activity_pub_controller.ex:6 lib/pleroma/web/admin_api/admin_api_controller.ex:6 @@ -540,34 +540,32 @@ msgstr "" #: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:210 #, elixir-format msgid "Unexpected error occurred while adding file to pack." -msgstr "Errore inaspettato durante l'aggiunta del file al pacchetto." +msgstr "Errore inatteso durante l'aggiunta del file al pacchetto." #: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:138 #, elixir-format msgid "Unexpected error occurred while creating pack." -msgstr "Errore inaspettato durante la creazione del pacchetto." +msgstr "Errore inatteso durante la creazione del pacchetto." #: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:278 #, elixir-format msgid "Unexpected error occurred while removing file from pack." -msgstr "Errore inaspettato durante la rimozione del file dal pacchetto." +msgstr "Errore inatteso durante la rimozione del file dal pacchetto." #: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:250 #, elixir-format msgid "Unexpected error occurred while updating file in pack." -msgstr "Errore inaspettato durante l'aggiornamento del file nel pacchetto." +msgstr "Errore inatteso durante l'aggiornamento del file nel pacchetto." #: lib/pleroma/web/pleroma_api/controllers/emoji_api_controller.ex:179 #, elixir-format msgid "Unexpected error occurred while updating pack metadata." -msgstr "Errore inaspettato durante l'aggiornamento dei metadati del pacchetto." +msgstr "Errore inatteso durante l'aggiornamento dei metadati del pacchetto." #: lib/pleroma/plugs/user_is_admin_plug.ex:21 #, elixir-format msgid "User is not an admin." -msgstr "" -"L'utente non è un amministratore." -"OAuth." +msgstr "L'utente non è un amministratore." #: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:61 #, elixir-format From b80f868c6b41d1407cf6e4f2df8913bdf7a954c0 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Sat, 13 Mar 2021 12:27:15 -0600 Subject: [PATCH 078/339] Prefer naming this function build_image_url/2 --- lib/pleroma/web/mastodon_api/views/status_view.ex | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 581b4e952..71f659ba0 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -386,7 +386,7 @@ def render("card.json", %{rich_media: rich_media, page_url: page_url}) do nil end - image_url = get_image_url(image_url_data, page_url_data) + image_url = build_image_url(image_url_data, page_url_data) %{ type: "link", @@ -548,8 +548,8 @@ defp build_application(_), do: nil # Avoid applying URI.merge unless necessary # TODO: revert to always attempting URI.merge(image_url_data, page_url_data) # when Elixir 1.12 is the minimum supported version - @spec get_image_url(struct() | nil, struct()) :: String.t() | nil - defp get_image_url( + @spec build_image_url(struct() | nil, struct()) :: String.t() | nil + defp build_image_url( %URI{scheme: image_scheme, host: image_host} = image_url_data, %URI{} = _page_url_data ) @@ -557,9 +557,9 @@ defp get_image_url( image_url_data |> to_string end - defp get_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do + defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do URI.merge(page_url_data, image_url_data) |> to_string end - defp get_image_url(_, _), do: nil + defp build_image_url(_, _), do: nil end From b1d4b2b81ec97143c41d16ac3f5bc2825b836f4b Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Mon, 15 Mar 2021 06:43:12 +0100 Subject: [PATCH 079/339] Add support for actor icon being a list (Bridgy) --- lib/pleroma/web/activity_pub/activity_pub.ex | 28 +++---- test/fixtures/bridgy/actor.json | 80 +++++++++++++++++++ .../web/activity_pub/activity_pub_test.exs | 27 +++++++ 3 files changed, 119 insertions(+), 16 deletions(-) create mode 100644 test/fixtures/bridgy/actor.json diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 5b45e2ca1..ff478f44c 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1250,21 +1250,17 @@ defp get_actor_url(url) when is_list(url) do defp get_actor_url(_url), do: nil + defp normalize_image(%{"url" => url}) do + %{ + "type" => "Image", + "url" => [%{"href" => url}] + } + end + + defp normalize_image(urls) when is_list(urls), do: urls |> List.first() |> normalize_image() + defp normalize_image(_), do: nil + defp object_to_user_data(data) do - avatar = - data["icon"]["url"] && - %{ - "type" => "Image", - "url" => [%{"href" => data["icon"]["url"]}] - } - - banner = - data["image"]["url"] && - %{ - "type" => "Image", - "url" => [%{"href" => data["image"]["url"]}] - } - fields = data |> Map.get("attachment", []) @@ -1308,13 +1304,13 @@ defp object_to_user_data(data) do ap_id: data["id"], uri: get_actor_url(data["url"]), ap_enabled: true, - banner: banner, + banner: normalize_image(data["image"]), fields: fields, emoji: emojis, is_locked: is_locked, is_discoverable: is_discoverable, invisible: invisible, - avatar: avatar, + avatar: normalize_image(data["icon"]), name: data["name"], follower_address: data["followers"], following_address: data["following"], diff --git a/test/fixtures/bridgy/actor.json b/test/fixtures/bridgy/actor.json new file mode 100644 index 000000000..5b2d8982b --- /dev/null +++ b/test/fixtures/bridgy/actor.json @@ -0,0 +1,80 @@ +{ + "id": "https://fed.brid.gy/jk.nipponalba.scot", + "url": "https://fed.brid.gy/r/https://jk.nipponalba.scot", + "urls": [ + { + "value": "https://jk.nipponalba.scot" + }, + { + "value": "https://social.nipponalba.scot/jk" + }, + { + "value": "https://px.nipponalba.scot/jk" + } + ], + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Person", + "name": "J K 🇯🇵🏴", + "image": [ + { + "url": "https://jk.nipponalba.scot/images/profile.jpg", + "type": "Image", + "name": "profile picture" + } + ], + "tag": [ + { + "type": "Tag", + "name": "Craft Beer" + }, + { + "type": "Tag", + "name": "Single Malt Whisky" + }, + { + "type": "Tag", + "name": "Homebrewing" + }, + { + "type": "Tag", + "name": "Scottish Politics" + }, + { + "type": "Tag", + "name": "Scottish History" + }, + { + "type": "Tag", + "name": "Japanese History" + }, + { + "type": "Tag", + "name": "Tech" + }, + { + "type": "Tag", + "name": "Veganism" + }, + { + "type": "Tag", + "name": "Cooking" + } + ], + "icon": [ + { + "url": "https://jk.nipponalba.scot/images/profile.jpg", + "type": "Image", + "name": "profile picture" + } + ], + "preferredUsername": "jk.nipponalba.scot", + "summary": "", + "publicKey": { + "id": "jk.nipponalba.scot", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDdarxwzxnNbJ2hneWOYHkYJowk\npyigQtxlUd0VjgSQHwxU9kWqfbrHBVADyTtcqi/4dAzQd3UnCI1TPNnn4LPZY9PW\noiWd3Zl1/EfLFxO7LU9GS7fcSLQkyj5JNhSlN3I8QPudZbybrgRDVZYooDe1D+52\n5KLGqC2ajrIVOiDRTQIDAQAB\n-----END PUBLIC KEY-----" + }, + "inbox": "https://fed.brid.gy/jk.nipponalba.scot/inbox", + "outbox": "https://fed.brid.gy/jk.nipponalba.scot/outbox", + "following": "https://fed.brid.gy/jk.nipponalba.scot/following", + "followers": "https://fed.brid.gy/jk.nipponalba.scot/followers" +} diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index f4023856c..57f12f821 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -208,6 +208,33 @@ test "works for guppe actors" do assert user.name == "Bernie2020 group" assert user.actor_type == "Group" end + + test "works for bridgy actors" do + user_id = "https://fed.brid.gy/jk.nipponalba.scot" + + Tesla.Mock.mock(fn + %{method: :get, url: ^user_id} -> + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/bridgy/actor.json"), + headers: [{"content-type", "application/activity+json"}] + } + end) + + {:ok, user} = ActivityPub.make_user_from_ap_id(user_id) + + assert user.actor_type == "Person" + + assert user.avatar == %{ + "type" => "Image", + "url" => [%{"href" => "https://jk.nipponalba.scot/images/profile.jpg"}] + } + + assert user.banner == %{ + "type" => "Image", + "url" => [%{"href" => "https://jk.nipponalba.scot/images/profile.jpg"}] + } + end end test "it fetches the appropriate tag-restricted posts" do From 7eecc3b61d6da64e0bfdc5b155cba0dae07b84d5 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Tue, 16 Feb 2021 23:23:35 +0100 Subject: [PATCH 080/339] OpenAPI: MastodonAPI Timeline Controller --- .../api_spec/operations/timeline_operation.ex | 3 ++- .../controllers/timeline_controller_test.exs | 16 ++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/web/api_spec/operations/timeline_operation.ex b/lib/pleroma/web/api_spec/operations/timeline_operation.ex index cae18c758..24d792916 100644 --- a/lib/pleroma/web/api_spec/operations/timeline_operation.ex +++ b/lib/pleroma/web/api_spec/operations/timeline_operation.ex @@ -115,7 +115,8 @@ def hashtag_operation do ], operationId: "TimelineController.hashtag", responses: %{ - 200 => Operation.response("Array of Status", "application/json", array_of_statuses()) + 200 => Operation.response("Array of Status", "application/json", array_of_statuses()), + 401 => Operation.response("Error", "application/json", ApiError) } } end diff --git a/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs index cc409451c..ed1286675 100644 --- a/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs @@ -905,10 +905,10 @@ defp ensure_authenticated_access(base_uri) do %{conn: auth_conn} = oauth_access(["read:statuses"]) res_conn = get(auth_conn, "#{base_uri}?local=true") - assert length(json_response(res_conn, 200)) == 1 + assert length(json_response_and_validate_schema(res_conn, 200)) == 1 res_conn = get(auth_conn, "#{base_uri}?local=false") - assert length(json_response(res_conn, 200)) == 2 + assert length(json_response_and_validate_schema(res_conn, 200)) == 2 end test "with default settings on private instances, returns 403 for unauthenticated users", %{ @@ -922,7 +922,7 @@ test "with default settings on private instances, returns 403 for unauthenticate for local <- [true, false] do res_conn = get(conn, "#{base_uri}?local=#{local}") - assert json_response(res_conn, :unauthorized) == error_response + assert json_response_and_validate_schema(res_conn, :unauthorized) == error_response end ensure_authenticated_access(base_uri) @@ -939,7 +939,7 @@ test "with `%{local: true, federated: true}`, returns 403 for unauthenticated us for local <- [true, false] do res_conn = get(conn, "#{base_uri}?local=#{local}") - assert json_response(res_conn, :unauthorized) == error_response + assert json_response_and_validate_schema(res_conn, :unauthorized) == error_response end ensure_authenticated_access(base_uri) @@ -951,10 +951,10 @@ test "with `%{local: false, federated: true}`, forbids unauthenticated access to clear_config([:restrict_unauthenticated, :timelines, :federated], true) res_conn = get(conn, "#{base_uri}?local=true") - assert length(json_response(res_conn, 200)) == 1 + assert length(json_response_and_validate_schema(res_conn, 200)) == 1 res_conn = get(conn, "#{base_uri}?local=false") - assert json_response(res_conn, :unauthorized) == error_response + assert json_response_and_validate_schema(res_conn, :unauthorized) == error_response ensure_authenticated_access(base_uri) end @@ -966,11 +966,11 @@ test "with `%{local: true, federated: false}`, forbids unauthenticated access to clear_config([:restrict_unauthenticated, :timelines, :federated], false) res_conn = get(conn, "#{base_uri}?local=true") - assert json_response(res_conn, :unauthorized) == error_response + assert json_response_and_validate_schema(res_conn, :unauthorized) == error_response # Note: local activities get delivered as part of federated timeline res_conn = get(conn, "#{base_uri}?local=false") - assert length(json_response(res_conn, 200)) == 2 + assert length(json_response_and_validate_schema(res_conn, 200)) == 2 ensure_authenticated_access(base_uri) end From 3123ecdd6e7a189f815624ee78be4f62487aa3db Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Tue, 16 Feb 2021 23:37:16 +0100 Subject: [PATCH 081/339] OpenAPI: MastodonAPI Media Controller --- lib/pleroma/web/api_spec/operations/media_operation.ex | 1 + .../web/mastodon_api/controllers/media_controller_test.exs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/media_operation.ex b/lib/pleroma/web/api_spec/operations/media_operation.ex index 85aa14869..1e245b291 100644 --- a/lib/pleroma/web/api_spec/operations/media_operation.ex +++ b/lib/pleroma/web/api_spec/operations/media_operation.ex @@ -105,6 +105,7 @@ def show_operation do responses: %{ 200 => Operation.response("Media", "application/json", Attachment), 401 => Operation.response("Media", "application/json", ApiError), + 403 => Operation.response("Media", "application/json", ApiError), 422 => Operation.response("Media", "application/json", ApiError) } } diff --git a/test/pleroma/web/mastodon_api/controllers/media_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/media_controller_test.exs index 6c8f984d5..39d7f99f6 100644 --- a/test/pleroma/web/mastodon_api/controllers/media_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/media_controller_test.exs @@ -140,7 +140,7 @@ test "it returns 403 if media object requested by non-owner", %{object: object, conn |> get("/api/v1/media/#{object.id}") - |> json_response(403) + |> json_response_and_validate_schema(403) end end end From e47f83cfc822716c00f3fcaffe73f31208749601 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Tue, 16 Feb 2021 23:39:07 +0100 Subject: [PATCH 082/339] OpenAPI: MastodonAPI Conversation Controller --- .../mastodon_api/controllers/conversation_controller_test.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/pleroma/web/mastodon_api/controllers/conversation_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/conversation_controller_test.exs index 3176f1296..00797a9ea 100644 --- a/test/pleroma/web/mastodon_api/controllers/conversation_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/conversation_controller_test.exs @@ -214,7 +214,8 @@ test "(vanilla) Mastodon frontend behaviour", %{user: user_one, conn: conn} do res_conn = get(conn, "/api/v1/statuses/#{direct.id}/context") - assert %{"ancestors" => [], "descendants" => []} == json_response(res_conn, 200) + assert %{"ancestors" => [], "descendants" => []} == + json_response_and_validate_schema(res_conn, 200) end test "Removes a conversation", %{user: user_one, conn: conn} do From 3a8404820d803ccea44071178cc90f6aafcee80b Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Tue, 16 Feb 2021 23:40:50 +0100 Subject: [PATCH 083/339] Verify MastoFE Controller put_settings response --- test/pleroma/web/mastodon_api/masto_fe_controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/pleroma/web/mastodon_api/masto_fe_controller_test.exs b/test/pleroma/web/mastodon_api/masto_fe_controller_test.exs index ea66c708f..e679d781a 100644 --- a/test/pleroma/web/mastodon_api/masto_fe_controller_test.exs +++ b/test/pleroma/web/mastodon_api/masto_fe_controller_test.exs @@ -20,7 +20,7 @@ test "put settings", %{conn: conn} do |> assign(:token, insert(:oauth_token, user: user, scopes: ["write:accounts"])) |> put("/api/web/settings", %{"data" => %{"programming" => "socks"}}) - assert _result = json_response(conn, 200) + assert %{} = json_response(conn, 200) user = User.get_cached_by_ap_id(user.ap_id) assert user.mastofe_settings == %{"programming" => "socks"} From 0c7c6463d13b8a4471b8721912c82fe1cbe3e91a Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Wed, 17 Feb 2021 00:35:26 +0100 Subject: [PATCH 084/339] OpenAPI: MastodonAPI Account Controller, excluding OAuth --- .../controllers/account_controller_test.exs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs index a327c0d1d..3036e25b3 100644 --- a/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/account_controller_test.exs @@ -514,11 +514,11 @@ test "paginates a user's statuses", %{user: user, conn: conn} do {:ok, post_2} = CommonAPI.post(user, %{status: "second post"}) response_1 = get(conn, "/api/v1/accounts/#{user.id}/statuses?limit=1") - assert [res] = json_response(response_1, 200) + assert [res] = json_response_and_validate_schema(response_1, 200) assert res["id"] == post_2.id response_2 = get(conn, "/api/v1/accounts/#{user.id}/statuses?limit=1&max_id=#{res["id"]}") - assert [res] = json_response(response_2, 200) + assert [res] = json_response_and_validate_schema(response_2, 200) assert res["id"] == post_1.id refute response_1 == response_2 @@ -881,7 +881,7 @@ test "following without reblogs" do assert [] == conn |> get("/api/v1/timelines/home") - |> json_response(200) + |> json_response_and_validate_schema(200) assert %{"showing_reblogs" => true} = conn @@ -892,7 +892,7 @@ test "following without reblogs" do assert [%{"id" => ^reblog_id}] = conn |> get("/api/v1/timelines/home") - |> json_response(200) + |> json_response_and_validate_schema(200) end test "following with reblogs" do @@ -910,7 +910,7 @@ test "following with reblogs" do assert [%{"id" => ^reblog_id}] = conn |> get("/api/v1/timelines/home") - |> json_response(200) + |> json_response_and_validate_schema(200) assert %{"showing_reblogs" => false} = conn @@ -921,7 +921,7 @@ test "following with reblogs" do assert [] == conn |> get("/api/v1/timelines/home") - |> json_response(200) + |> json_response_and_validate_schema(200) end test "following / unfollowing errors", %{user: user, conn: conn} do From ef5de5eb398b6d4cbc1ed338f2f41d3bfa1c5fe9 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Wed, 17 Feb 2021 00:45:01 +0100 Subject: [PATCH 085/339] OpenAPI: MastodonAPI Status Controller --- .../web/mastodon_api/controllers/status_controller_test.exs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs index f616f405e..4c0149a4c 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -81,6 +81,7 @@ test "posting a status", %{conn: conn} do "sensitive" => 0 }) + # Idempotency plug response means detection fail assert %{"id" => second_id} = json_response(conn_two, 200) assert id == second_id @@ -1542,7 +1543,7 @@ test "Repeated posts that are replies incorrectly have in_reply_to_id null", %{c |> assign(:token, insert(:oauth_token, user: user3, scopes: ["read:statuses"])) |> get("api/v1/timelines/home") - [reblogged_activity] = json_response(conn3, 200) + [reblogged_activity] = json_response_and_validate_schema(conn3, 200) assert reblogged_activity["reblog"]["in_reply_to_id"] == replied_to.id @@ -1896,7 +1897,7 @@ test "posting a local only status" do local = Pleroma.Constants.as_local_public() assert %{"content" => "cofe", "id" => id, "visibility" => "local"} = - json_response(conn_one, 200) + json_response_and_validate_schema(conn_one, 200) assert %Activity{id: ^id, data: %{"to" => [^local]}} = Activity.get_by_id(id) end From e4743847a18cb7cbb9e607232f25eb1cf63a4551 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Wed, 17 Feb 2021 01:07:56 +0100 Subject: [PATCH 086/339] OpenAPI: PleromaAPI UserImport Controller --- lib/pleroma/web/api_spec/operations/user_import_operation.ex | 1 + .../web/pleroma_api/controllers/user_import_controller_test.exs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/user_import_operation.ex b/lib/pleroma/web/api_spec/operations/user_import_operation.ex index 6292e2004..8df19f1fc 100644 --- a/lib/pleroma/web/api_spec/operations/user_import_operation.ex +++ b/lib/pleroma/web/api_spec/operations/user_import_operation.ex @@ -23,6 +23,7 @@ def follow_operation do requestBody: request_body("Parameters", import_request(), required: true), responses: %{ 200 => ok_response(), + 403 => Operation.response("Error", "application/json", ApiError), 500 => Operation.response("Error", "application/json", ApiError) }, security: [%{"oAuth" => ["write:follow"]}] diff --git a/test/pleroma/web/pleroma_api/controllers/user_import_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/user_import_controller_test.exs index 25a7f8374..d977bc3a2 100644 --- a/test/pleroma/web/pleroma_api/controllers/user_import_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/user_import_controller_test.exs @@ -83,7 +83,7 @@ test "requires 'follow' or 'write:follows' permissions" do assert %{"error" => "Insufficient permissions: follow | write:follows."} == json_response(conn, 403) else - assert json_response(conn, 200) + assert json_response_and_validate_schema(conn, 200) end end end From a22c53810b36c5382c805e1c5ed7e1cf3d747ebc Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Wed, 17 Feb 2021 01:19:25 +0100 Subject: [PATCH 087/339] Remove deprecated /api/qvitter/statuses/notifications/read --- CHANGELOG.md | 3 ++ lib/pleroma/web/router.ex | 6 --- lib/pleroma/web/twitter_api/controller.ex | 33 ------------- .../web/twitter_api/controller_test.exs | 49 ------------------- 4 files changed, 3 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50484aaef..ce0bb1cb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - The `application` metadata returned with statuses is no longer hardcoded. Apps that want to display these details will now have valid data for new posts after this change. +### Removed +- **Breaking**: Remove deprecated `/api/qvitter/statuses/notifications/read` (replaced by `/api/v1/pleroma/notifications/read`) + ## Unreleased (Patch) ## [2.3.0] - 2020-03-01 diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index de0bd27d7..ce2d701d7 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -620,12 +620,6 @@ defmodule Pleroma.Web.Router do get("/oauth_tokens", TwitterAPI.Controller, :oauth_tokens) delete("/oauth_tokens/:id", TwitterAPI.Controller, :revoke_token) - - post( - "/qvitter/statuses/notifications/read", - TwitterAPI.Controller, - :mark_notifications_as_read - ) end scope "/", Pleroma.Web do diff --git a/lib/pleroma/web/twitter_api/controller.ex b/lib/pleroma/web/twitter_api/controller.ex index 077bfa70d..e32713311 100644 --- a/lib/pleroma/web/twitter_api/controller.ex +++ b/lib/pleroma/web/twitter_api/controller.ex @@ -5,7 +5,6 @@ defmodule Pleroma.Web.TwitterAPI.Controller do use Pleroma.Web, :controller - alias Pleroma.Notification alias Pleroma.User alias Pleroma.Web.OAuth.Token alias Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug @@ -14,11 +13,6 @@ defmodule Pleroma.Web.TwitterAPI.Controller do require Logger - plug( - OAuthScopesPlug, - %{scopes: ["write:notifications"]} when action == :mark_notifications_as_read - ) - plug( :skip_plug, [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug] when action == :confirm_email @@ -67,31 +61,4 @@ defp json_reply(conn, status, json) do |> put_resp_content_type("application/json") |> send_resp(status, json) end - - def mark_notifications_as_read( - %{assigns: %{user: user}} = conn, - %{"latest_id" => latest_id} = params - ) do - Notification.set_read_up_to(user, latest_id) - - notifications = Notification.for_user(user, params) - - conn - # XXX: This is a hack because pleroma-fe still uses that API. - |> put_view(Pleroma.Web.MastodonAPI.NotificationView) - |> render("index.json", %{notifications: notifications, for: user}) - end - - def mark_notifications_as_read(%{assigns: %{user: _user}} = conn, _) do - bad_request_reply(conn, "You need to specify latest_id") - end - - defp bad_request_reply(conn, error_message) do - json = error_json(conn, error_message) - json_reply(conn, 400, json) - end - - defp error_json(conn, error_message) do - %{"error" => error_message, "request" => conn.request_path} |> Jason.encode!() - end end diff --git a/test/pleroma/web/twitter_api/controller_test.exs b/test/pleroma/web/twitter_api/controller_test.exs index 583c904b2..bca9e2dad 100644 --- a/test/pleroma/web/twitter_api/controller_test.exs +++ b/test/pleroma/web/twitter_api/controller_test.exs @@ -7,59 +7,10 @@ defmodule Pleroma.Web.TwitterAPI.ControllerTest do alias Pleroma.Repo alias Pleroma.User - alias Pleroma.Web.CommonAPI alias Pleroma.Web.OAuth.Token import Pleroma.Factory - describe "POST /api/qvitter/statuses/notifications/read" do - test "without valid credentials", %{conn: conn} do - conn = post(conn, "/api/qvitter/statuses/notifications/read", %{"latest_id" => 1_234_567}) - assert json_response(conn, 403) == %{"error" => "Invalid credentials."} - end - - test "with credentials, without any params" do - %{conn: conn} = oauth_access(["write:notifications"]) - - conn = post(conn, "/api/qvitter/statuses/notifications/read") - - assert json_response(conn, 400) == %{ - "error" => "You need to specify latest_id", - "request" => "/api/qvitter/statuses/notifications/read" - } - end - - test "with credentials, with params" do - %{user: current_user, conn: conn} = - oauth_access(["read:notifications", "write:notifications"]) - - other_user = insert(:user) - - {:ok, _activity} = - CommonAPI.post(other_user, %{ - status: "Hey @#{current_user.nickname}" - }) - - response_conn = - conn - |> get("/api/v1/notifications") - - [notification] = json_response(response_conn, 200) - - assert notification["pleroma"]["is_seen"] == false - - response_conn = - conn - |> post("/api/qvitter/statuses/notifications/read", %{"latest_id" => notification["id"]}) - - [notification] = response = json_response(response_conn, 200) - - assert length(response) == 1 - - assert notification["pleroma"]["is_seen"] == true - end - end - describe "GET /api/account/confirm_email/:id/:token" do setup do {:ok, user} = From 65cd9cb6384676c1660aa7f4da0f98ff7f43b999 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Wed, 17 Feb 2021 09:41:40 +0100 Subject: [PATCH 088/339] TwitterAPI: Remove unused read notification function --- .../web/twitter_api/controllers/util_controller.ex | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/lib/pleroma/web/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex index 940a645bb..60266aaab 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -10,7 +10,6 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do alias Pleroma.Config alias Pleroma.Emoji alias Pleroma.Healthcheck - alias Pleroma.Notification alias Pleroma.User alias Pleroma.Web.CommonAPI alias Pleroma.Web.Plugs.OAuthScopesPlug @@ -30,7 +29,6 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do ] ) - plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :notifications_read) def remote_subscribe(conn, %{"nickname" => nick, "profile" => _}) do with %User{} = user <- User.get_cached_by_nickname(nick), @@ -62,17 +60,6 @@ def remote_subscribe(conn, %{"user" => %{"nickname" => nick, "profile" => profil end end - def notifications_read(%{assigns: %{user: user}} = conn, %{"id" => notification_id}) do - with {:ok, _} <- Notification.read_one(user, notification_id) do - json(conn, %{status: "success"}) - else - {:error, message} -> - conn - |> put_resp_content_type("application/json") - |> send_resp(403, Jason.encode!(%{"error" => message})) - end - end - def frontend_configurations(conn, _params) do render(conn, "frontend_configurations.json") end From 55bdfb075c1cc5226948e3ff9d39fdae27aa9257 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Wed, 24 Feb 2021 23:40:33 +0100 Subject: [PATCH 089/339] OpenAPI: TwitterAPI Util Controller --- .../operations/twitter_util_operation.ex | 219 ++++++++++++++++++ .../controllers/util_controller.ex | 24 +- .../web/twitter_api/util_controller_test.exs | 204 +++++++++------- 3 files changed, 360 insertions(+), 87 deletions(-) create mode 100644 lib/pleroma/web/api_spec/operations/twitter_util_operation.ex diff --git a/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex b/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex new file mode 100644 index 000000000..62c9826f6 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex @@ -0,0 +1,219 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ApiSpec.TwitterUtilOperation do + alias OpenApiSpex.Operation + alias OpenApiSpex.Schema + alias Pleroma.Web.ApiSpec.Schemas.ApiError + alias Pleroma.Web.ApiSpec.Schemas.BooleanLike + + def open_api_operation(action) do + operation = String.to_existing_atom("#{action}_operation") + apply(__MODULE__, operation, []) + end + + def emoji_operation do + %Operation{ + tags: ["Emojis"], + summary: "List all custom emojis", + operationId: "UtilController.emoji", + parameters: [], + responses: %{ + 200 => + Operation.response("List", "application/json", %Schema{ + type: :object, + additionalProperties: %Schema{ + type: :object, + properties: %{ + image_url: %Schema{type: :string}, + tags: %Schema{type: :array, items: %Schema{type: :string}} + } + }, + example: %{ + "firefox" => %{ + "image_url" => "/emoji/firefox.png", + "tag" => ["Fun"] + } + } + }) + } + } + end + + def frontend_configurations_operation do + %Operation{ + tags: ["Configuration"], + summary: "Dump frontend configurations", + operationId: "UtilController.frontend_configurations", + parameters: [], + responses: %{ + 200 => + Operation.response("List", "application/json", %Schema{ + type: :object, + additionalProperties: %Schema{type: :object} + }) + } + } + end + + def change_password_operation do + %Operation{ + tags: ["Accounts"], + summary: "Change account password", + security: [%{"oAuth" => ["write:accounts"]}], + operationId: "UtilController.change_password", + parameters: [ + Operation.parameter(:password, :query, :string, "Current password", required: true), + Operation.parameter(:new_password, :query, :string, "New password", required: true), + Operation.parameter( + :new_password_confirmation, + :query, + :string, + "New password, confirmation", + required: true + ) + ], + responses: %{ + 200 => + Operation.response("Success", "application/json", %Schema{ + type: :object, + properties: %{status: %Schema{type: :string, example: "success"}} + }), + 400 => Operation.response("Error", "application/json", ApiError), + 403 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def change_email_operation do + %Operation{ + tags: ["Accounts"], + summary: "Change account email", + security: [%{"oAuth" => ["write:accounts"]}], + operationId: "UtilController.change_email", + parameters: [ + Operation.parameter(:password, :query, :string, "Current password", required: true), + Operation.parameter(:email, :query, :string, "New email", required: true) + ], + requestBody: nil, + responses: %{ + 200 => + Operation.response("Success", "application/json", %Schema{ + type: :object, + properties: %{status: %Schema{type: :string, example: "success"}} + }), + 400 => Operation.response("Error", "application/json", ApiError), + 403 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def update_notificaton_settings_operation do + %Operation{ + tags: ["Accounts"], + summary: "Update Notification Settings", + security: [%{"oAuth" => ["write:accounts"]}], + operationId: "UtilController.update_notificaton_settings", + parameters: [ + Operation.parameter( + :block_from_strangers, + :query, + BooleanLike, + "blocks notifications from accounts you do not follow" + ), + Operation.parameter( + :hide_notification_contents, + :query, + BooleanLike, + "removes the contents of a message from the push notification" + ) + ], + requestBody: nil, + responses: %{ + 200 => + Operation.response("Success", "application/json", %Schema{ + type: :object, + properties: %{status: %Schema{type: :string, example: "success"}} + }), + 400 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def disable_account_operation do + %Operation{ + tags: ["Accounts"], + summary: "Disable Account", + security: [%{"oAuth" => ["write:accounts"]}], + operationId: "UtilController.disable_account", + parameters: [ + Operation.parameter(:password, :query, :string, "Password") + ], + responses: %{ + 200 => + Operation.response("Success", "application/json", %Schema{ + type: :object, + properties: %{status: %Schema{type: :string, example: "success"}} + }), + 403 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def delete_account_operation do + %Operation{ + tags: ["Accounts"], + summary: "Delete Account", + security: [%{"oAuth" => ["write:accounts"]}], + operationId: "UtilController.delete_account", + parameters: [ + Operation.parameter(:password, :query, :string, "Password") + ], + responses: %{ + 200 => + Operation.response("Success", "application/json", %Schema{ + type: :object, + properties: %{status: %Schema{type: :string, example: "success"}} + }), + 403 => Operation.response("Error", "application/json", ApiError) + } + } + end + + def captcha_operation do + %Operation{ + summary: "Get a captcha", + operationId: "UtilController.captcha", + parameters: [], + responses: %{ + 200 => Operation.response("Success", "application/json", %Schema{type: :object}) + } + } + end + + def healthcheck_operation do + %Operation{ + tags: ["Accounts"], + summary: "Disable Account", + security: [%{"oAuth" => ["write:accounts"]}], + operationId: "UtilController.healthcheck", + parameters: [], + responses: %{ + 200 => Operation.response("Healthy", "application/json", %Schema{type: :object}), + 503 => + Operation.response("Disabled or Unhealthy", "application/json", %Schema{type: :object}) + } + } + end + + def remote_subscribe_operation do + %Operation{ + tags: ["Accounts"], + summary: "Remote Subscribe", + operationId: "UtilController.remote_subscribe", + parameters: [], + responses: %{200 => Operation.response("Web Page", "test/html", %Schema{type: :string})} + } + end +end diff --git a/lib/pleroma/web/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex index 60266aaab..a2e69666e 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -15,6 +15,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do alias Pleroma.Web.Plugs.OAuthScopesPlug alias Pleroma.Web.WebFinger + plug(Pleroma.Web.ApiSpec.CastAndValidate when action != :remote_subscribe) plug(Pleroma.Web.Plugs.FederatingPlug when action == :remote_subscribe) plug( @@ -29,6 +30,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do ] ) + defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.TwitterUtilOperation def remote_subscribe(conn, %{"nickname" => nick, "profile" => _}) do with %User{} = user <- User.get_cached_by_nickname(nick), @@ -79,13 +81,17 @@ def update_notificaton_settings(%{assigns: %{user: user}} = conn, params) do end end - def change_password(%{assigns: %{user: user}} = conn, params) do - case CommonAPI.Utils.confirm_current_password(user, params["password"]) do + def change_password(%{assigns: %{user: user}} = conn, %{ + password: password, + new_password: new_password, + new_password_confirmation: new_password_confirmation + }) do + case CommonAPI.Utils.confirm_current_password(user, password) do {:ok, user} -> with {:ok, _user} <- User.reset_password(user, %{ - password: params["new_password"], - password_confirmation: params["new_password_confirmation"] + password: new_password, + password_confirmation: new_password_confirmation }) do json(conn, %{status: "success"}) else @@ -102,10 +108,10 @@ def change_password(%{assigns: %{user: user}} = conn, params) do end end - def change_email(%{assigns: %{user: user}} = conn, params) do - case CommonAPI.Utils.confirm_current_password(user, params["password"]) do + def change_email(%{assigns: %{user: user}} = conn, %{password: password, email: email}) do + case CommonAPI.Utils.confirm_current_password(user, password) do {:ok, user} -> - with {:ok, _user} <- User.change_email(user, params["email"]) do + with {:ok, _user} <- User.change_email(user, email) do json(conn, %{status: "success"}) else {:error, changeset} -> @@ -122,7 +128,7 @@ def change_email(%{assigns: %{user: user}} = conn, params) do end def delete_account(%{assigns: %{user: user}} = conn, params) do - password = params["password"] || "" + password = params[:password] || "" case CommonAPI.Utils.confirm_current_password(user, password) do {:ok, user} -> @@ -135,7 +141,7 @@ def delete_account(%{assigns: %{user: user}} = conn, params) do end def disable_account(%{assigns: %{user: user}} = conn, params) do - case CommonAPI.Utils.confirm_current_password(user, params["password"]) do + case CommonAPI.Utils.confirm_current_password(user, params[:password]) do {:ok, user} -> User.set_activation_async(user, false) json(conn, %{status: "success"}) diff --git a/test/pleroma/web/twitter_api/util_controller_test.exs b/test/pleroma/web/twitter_api/util_controller_test.exs index bdbc478c3..cc17940b5 100644 --- a/test/pleroma/web/twitter_api/util_controller_test.exs +++ b/test/pleroma/web/twitter_api/util_controller_test.exs @@ -25,11 +25,14 @@ defmodule Pleroma.Web.TwitterAPI.UtilControllerTest do test "it updates notification settings", %{user: user, conn: conn} do conn - |> put("/api/pleroma/notification_settings", %{ - "block_from_strangers" => true, - "bar" => 1 - }) - |> json_response(:ok) + |> put( + "/api/pleroma/notification_settings?#{ + URI.encode_query(%{ + block_from_strangers: true + }) + }" + ) + |> json_response_and_validate_schema(:ok) user = refresh_record(user) @@ -41,8 +44,14 @@ test "it updates notification settings", %{user: user, conn: conn} do test "it updates notification settings to enable hiding contents", %{user: user, conn: conn} do conn - |> put("/api/pleroma/notification_settings", %{"hide_notification_contents" => "1"}) - |> json_response(:ok) + |> put( + "/api/pleroma/notification_settings?#{ + URI.encode_query(%{ + hide_notification_contents: 1 + }) + }" + ) + |> json_response_and_validate_schema(:ok) user = refresh_record(user) @@ -70,7 +79,7 @@ test "returns everything in :pleroma, :frontend_configurations", %{conn: conn} d response = conn |> get("/api/pleroma/frontend_configurations") - |> json_response(:ok) + |> json_response_and_validate_schema(:ok) assert response == Jason.encode!(config |> Enum.into(%{})) |> Jason.decode!() end @@ -81,7 +90,7 @@ test "returns json with custom emoji with tags", %{conn: conn} do emoji = conn |> get("/api/pleroma/emoji") - |> json_response(200) + |> json_response_and_validate_schema(200) assert Enum.all?(emoji, fn {_key, @@ -103,7 +112,7 @@ test "returns 503 when healthcheck disabled", %{conn: conn} do response = conn |> get("/api/pleroma/healthcheck") - |> json_response(503) + |> json_response_and_validate_schema(503) assert response == %{} end @@ -116,7 +125,7 @@ test "returns 200 when healthcheck enabled and all ok", %{conn: conn} do response = conn |> get("/api/pleroma/healthcheck") - |> json_response(200) + |> json_response_and_validate_schema(200) assert %{ "active" => _, @@ -136,7 +145,7 @@ test "returns 503 when healthcheck enabled and health is false", %{conn: conn} d response = conn |> get("/api/pleroma/healthcheck") - |> json_response(503) + |> json_response_and_validate_schema(503) assert %{ "active" => _, @@ -155,8 +164,8 @@ test "returns 503 when healthcheck enabled and health is false", %{conn: conn} d test "with valid permissions and password, it disables the account", %{conn: conn, user: user} do response = conn - |> post("/api/pleroma/disable_account", %{"password" => "test"}) - |> json_response(:ok) + |> post("/api/pleroma/disable_account?password=test") + |> json_response_and_validate_schema(:ok) assert response == %{"status" => "success"} ObanHelpers.perform_all() @@ -171,8 +180,8 @@ test "with valid permissions and invalid password, it returns an error", %{conn: response = conn - |> post("/api/pleroma/disable_account", %{"password" => "test1"}) - |> json_response(:ok) + |> post("/api/pleroma/disable_account?password=test1") + |> json_response_and_validate_schema(:ok) assert response == %{"error" => "Invalid password."} user = User.get_cached_by_id(user.id) @@ -252,54 +261,61 @@ test "without permissions", %{conn: conn} do conn = conn |> assign(:token, nil) - |> post("/api/pleroma/change_email") + |> post( + "/api/pleroma/change_email?#{ + URI.encode_query(%{password: "hi", email: "test@test.com"}) + }" + ) - assert json_response(conn, 403) == %{"error" => "Insufficient permissions: write:accounts."} + assert json_response_and_validate_schema(conn, 403) == %{ + "error" => "Insufficient permissions: write:accounts." + } end test "with proper permissions and invalid password", %{conn: conn} do conn = - post(conn, "/api/pleroma/change_email", %{ - "password" => "hi", - "email" => "test@test.com" - }) + post( + conn, + "/api/pleroma/change_email?#{ + URI.encode_query(%{password: "hi", email: "test@test.com"}) + }" + ) - assert json_response(conn, 200) == %{"error" => "Invalid password."} + assert json_response_and_validate_schema(conn, 200) == %{"error" => "Invalid password."} end test "with proper permissions, valid password and invalid email", %{ conn: conn } do conn = - post(conn, "/api/pleroma/change_email", %{ - "password" => "test", - "email" => "foobar" - }) + post( + conn, + "/api/pleroma/change_email?#{URI.encode_query(%{password: "test", email: "foobar"})}" + ) - assert json_response(conn, 200) == %{"error" => "Email has invalid format."} + assert json_response_and_validate_schema(conn, 200) == %{ + "error" => "Email has invalid format." + } end test "with proper permissions, valid password and no email", %{ conn: conn } do - conn = - post(conn, "/api/pleroma/change_email", %{ - "password" => "test" - }) + conn = post(conn, "/api/pleroma/change_email?#{URI.encode_query(%{password: "test"})}") - assert json_response(conn, 200) == %{"error" => "Email can't be blank."} + assert %{"error" => "Missing field: email."} = json_response_and_validate_schema(conn, 400) end test "with proper permissions, valid password and blank email", %{ conn: conn } do conn = - post(conn, "/api/pleroma/change_email", %{ - "password" => "test", - "email" => "" - }) + post( + conn, + "/api/pleroma/change_email?#{URI.encode_query(%{password: "test", email: ""})}" + ) - assert json_response(conn, 200) == %{"error" => "Email can't be blank."} + assert json_response_and_validate_schema(conn, 200) == %{"error" => "Email can't be blank."} end test "with proper permissions, valid password and non unique email", %{ @@ -308,24 +324,28 @@ test "with proper permissions, valid password and non unique email", %{ user = insert(:user) conn = - post(conn, "/api/pleroma/change_email", %{ - "password" => "test", - "email" => user.email - }) + post( + conn, + "/api/pleroma/change_email?#{URI.encode_query(%{password: "test", email: user.email})}" + ) - assert json_response(conn, 200) == %{"error" => "Email has already been taken."} + assert json_response_and_validate_schema(conn, 200) == %{ + "error" => "Email has already been taken." + } end test "with proper permissions, valid password and valid email", %{ conn: conn } do conn = - post(conn, "/api/pleroma/change_email", %{ - "password" => "test", - "email" => "cofe@foobar.com" - }) + post( + conn, + "/api/pleroma/change_email?#{ + URI.encode_query(%{password: "test", email: "cofe@foobar.com"}) + }" + ) - assert json_response(conn, 200) == %{"status" => "success"} + assert json_response_and_validate_schema(conn, 200) == %{"status" => "success"} end end @@ -336,20 +356,35 @@ test "without permissions", %{conn: conn} do conn = conn |> assign(:token, nil) - |> post("/api/pleroma/change_password") + |> post( + "/api/pleroma/change_password?#{ + URI.encode_query(%{ + password: "hi", + new_password: "newpass", + new_password_confirmation: "newpass" + }) + }" + ) - assert json_response(conn, 403) == %{"error" => "Insufficient permissions: write:accounts."} + assert json_response_and_validate_schema(conn, 403) == %{ + "error" => "Insufficient permissions: write:accounts." + } end test "with proper permissions and invalid password", %{conn: conn} do conn = - post(conn, "/api/pleroma/change_password", %{ - "password" => "hi", - "new_password" => "newpass", - "new_password_confirmation" => "newpass" - }) + post( + conn, + "/api/pleroma/change_password?#{ + URI.encode_query(%{ + password: "hi", + new_password: "newpass", + new_password_confirmation: "newpass" + }) + }" + ) - assert json_response(conn, 200) == %{"error" => "Invalid password."} + assert json_response_and_validate_schema(conn, 200) == %{"error" => "Invalid password."} end test "with proper permissions, valid password and new password and confirmation not matching", @@ -357,13 +392,18 @@ test "with proper permissions, valid password and new password and confirmation conn: conn } do conn = - post(conn, "/api/pleroma/change_password", %{ - "password" => "test", - "new_password" => "newpass", - "new_password_confirmation" => "notnewpass" - }) + post( + conn, + "/api/pleroma/change_password?#{ + URI.encode_query(%{ + password: "test", + new_password: "newpass", + new_password_confirmation: "notnewpass" + }) + }" + ) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "error" => "New password does not match confirmation." } end @@ -372,13 +412,14 @@ test "with proper permissions, valid password and invalid new password", %{ conn: conn } do conn = - post(conn, "/api/pleroma/change_password", %{ - "password" => "test", - "new_password" => "", - "new_password_confirmation" => "" - }) + post( + conn, + "/api/pleroma/change_password?#{ + URI.encode_query(%{password: "test", new_password: "", new_password_confirmation: ""}) + }" + ) - assert json_response(conn, 200) == %{ + assert json_response_and_validate_schema(conn, 200) == %{ "error" => "New password can't be blank." } end @@ -388,13 +429,18 @@ test "with proper permissions, valid password and matching new password and conf user: user } do conn = - post(conn, "/api/pleroma/change_password", %{ - "password" => "test", - "new_password" => "newpass", - "new_password_confirmation" => "newpass" - }) + post( + conn, + "/api/pleroma/change_password?#{ + URI.encode_query(%{ + password: "test", + new_password: "newpass", + new_password_confirmation: "newpass" + }) + }" + ) - assert json_response(conn, 200) == %{"status" => "success"} + assert json_response_and_validate_schema(conn, 200) == %{"status" => "success"} fetched_user = User.get_cached_by_id(user.id) assert Pleroma.Password.Pbkdf2.verify_pass("newpass", fetched_user.password_hash) == true end @@ -409,7 +455,7 @@ test "without permissions", %{conn: conn} do |> assign(:token, nil) |> post("/api/pleroma/delete_account") - assert json_response(conn, 403) == + assert json_response_and_validate_schema(conn, 403) == %{"error" => "Insufficient permissions: write:accounts."} end @@ -417,14 +463,16 @@ test "with proper permissions and wrong or missing password", %{conn: conn} do for params <- [%{"password" => "hi"}, %{}] do ret_conn = post(conn, "/api/pleroma/delete_account", params) - assert json_response(ret_conn, 200) == %{"error" => "Invalid password."} + assert json_response_and_validate_schema(ret_conn, 200) == %{ + "error" => "Invalid password." + } end end test "with proper permissions and valid password", %{conn: conn, user: user} do - conn = post(conn, "/api/pleroma/delete_account", %{"password" => "test"}) + conn = post(conn, "/api/pleroma/delete_account?password=test") ObanHelpers.perform_all() - assert json_response(conn, 200) == %{"status" => "success"} + assert json_response_and_validate_schema(conn, 200) == %{"status" => "success"} user = User.get_by_id(user.id) refute user.is_active From d7e51206a251b9da0180a4df3c879531ac302e1a Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Thu, 18 Mar 2021 13:49:03 +0300 Subject: [PATCH 090/339] respect content-type header in finger request --- lib/pleroma/web/web_finger.ex | 90 +++++++++++-------- lib/pleroma/web/xml.ex | 2 +- .../fixtures/tesla_mock/xn--q9jyb4c_host_meta | 4 - test/pleroma/web/web_finger_test.exs | 67 ++++++++++++++ test/support/http_request_mock.ex | 47 +++++----- 5 files changed, 141 insertions(+), 69 deletions(-) delete mode 100644 test/fixtures/tesla_mock/xn--q9jyb4c_host_meta diff --git a/lib/pleroma/web/web_finger.ex b/lib/pleroma/web/web_finger.ex index 15002b29f..21b10e654 100644 --- a/lib/pleroma/web/web_finger.ex +++ b/lib/pleroma/web/web_finger.ex @@ -94,52 +94,56 @@ def represent_user(user, "XML") do |> XmlBuilder.to_doc() end - defp webfinger_from_xml(doc) do - subject = XML.string_from_xpath("//Subject", doc) + defp webfinger_from_xml(body) do + with {:ok, doc} <- XML.parse_document(body) do + subject = XML.string_from_xpath("//Subject", doc) - subscribe_address = - ~s{//Link[@rel="http://ostatus.org/schema/1.0/subscribe"]/@template} - |> XML.string_from_xpath(doc) + subscribe_address = + ~s{//Link[@rel="http://ostatus.org/schema/1.0/subscribe"]/@template} + |> XML.string_from_xpath(doc) - ap_id = - ~s{//Link[@rel="self" and @type="application/activity+json"]/@href} - |> XML.string_from_xpath(doc) + ap_id = + ~s{//Link[@rel="self" and @type="application/activity+json"]/@href} + |> XML.string_from_xpath(doc) - data = %{ - "subject" => subject, - "subscribe_address" => subscribe_address, - "ap_id" => ap_id - } + data = %{ + "subject" => subject, + "subscribe_address" => subscribe_address, + "ap_id" => ap_id + } - {:ok, data} + {:ok, data} + end end - defp webfinger_from_json(doc) do - data = - Enum.reduce(doc["links"], %{"subject" => doc["subject"]}, fn link, data -> - case {link["type"], link["rel"]} do - {"application/activity+json", "self"} -> - Map.put(data, "ap_id", link["href"]) + defp webfinger_from_json(body) do + with {:ok, doc} <- Jason.decode(body) do + data = + Enum.reduce(doc["links"], %{"subject" => doc["subject"]}, fn link, data -> + case {link["type"], link["rel"]} do + {"application/activity+json", "self"} -> + Map.put(data, "ap_id", link["href"]) - {"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"", "self"} -> - Map.put(data, "ap_id", link["href"]) + {"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"", "self"} -> + Map.put(data, "ap_id", link["href"]) - {nil, "http://ostatus.org/schema/1.0/subscribe"} -> - Map.put(data, "subscribe_address", link["template"]) + {nil, "http://ostatus.org/schema/1.0/subscribe"} -> + Map.put(data, "subscribe_address", link["template"]) - _ -> - Logger.debug("Unhandled type: #{inspect(link["type"])}") - data - end - end) + _ -> + Logger.debug("Unhandled type: #{inspect(link["type"])}") + data + end + end) - {:ok, data} + {:ok, data} + end end def get_template_from_xml(body) do xpath = "//Link[@rel='lrdd']/@template" - with doc when doc != :error <- XML.parse_document(body), + with {:ok, doc} <- XML.parse_document(body), template when template != nil <- XML.string_from_xpath(xpath, doc) do {:ok, template} end @@ -192,15 +196,23 @@ def finger(account) do address, [{"accept", "application/xrd+xml,application/jrd+json"}] ), - {:ok, %{status: status, body: body}} when status in 200..299 <- response do - doc = XML.parse_document(body) + {:ok, %{status: status, body: body, headers: headers}} when status in 200..299 <- + response do + case List.keyfind(headers, "content-type", 0) do + {_, content_type} -> + case Plug.Conn.Utils.media_type(content_type) do + {:ok, "application", subtype, _} when subtype in ~w(xrd+xml xml) -> + webfinger_from_xml(body) - if doc != :error do - webfinger_from_xml(doc) - else - with {:ok, doc} <- Jason.decode(body) do - webfinger_from_json(doc) - end + {:ok, "application", subtype, _} when subtype in ~w(jrd+json json) -> + webfinger_from_json(body) + + _ -> + {:error, {:content_type, content_type}} + end + + _ -> + {:error, {:content_type, nil}} end else e -> diff --git a/lib/pleroma/web/xml.ex b/lib/pleroma/web/xml.ex index 2b34611ac..0ab6e9d32 100644 --- a/lib/pleroma/web/xml.ex +++ b/lib/pleroma/web/xml.ex @@ -31,7 +31,7 @@ def parse_document(text) do |> :binary.bin_to_list() |> :xmerl_scan.string(quiet: true) - doc + {:ok, doc} rescue _e -> Logger.debug("Couldn't parse XML: #{inspect(text)}") diff --git a/test/fixtures/tesla_mock/xn--q9jyb4c_host_meta b/test/fixtures/tesla_mock/xn--q9jyb4c_host_meta deleted file mode 100644 index 45d260e55..000000000 --- a/test/fixtures/tesla_mock/xn--q9jyb4c_host_meta +++ /dev/null @@ -1,4 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0"> - <Link rel="lrdd" template="https://zetsubou.xn--q9jyb4c/.well-known/webfinger?resource={uri}" type="application/xrd+xml" /> -</XRD> diff --git a/test/pleroma/web/web_finger_test.exs b/test/pleroma/web/web_finger_test.exs index 84477d5a1..2d7b4a40b 100644 --- a/test/pleroma/web/web_finger_test.exs +++ b/test/pleroma/web/web_finger_test.exs @@ -45,6 +45,26 @@ test "returns error for nonsensical input" do assert {:error, _} = WebFinger.finger("pleroma.social") end + test "returns error when there is no content-type header" do + Tesla.Mock.mock(fn + %{url: "http://social.heldscal.la/.well-known/host-meta"} -> + {:ok, + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/social.heldscal.la_host_meta") + }} + + %{ + url: + "https://social.heldscal.la/.well-known/webfinger?resource=acct:invalid_content@social.heldscal.la" + } -> + {:ok, %Tesla.Env{status: 200, body: ""}} + end) + + user = "invalid_content@social.heldscal.la" + assert {:error, {:content_type, nil}} = WebFinger.finger(user) + end + test "returns error when fails parse xml or json" do user = "invalid_content@social.heldscal.la" assert {:error, %Jason.DecodeError{}} = WebFinger.finger(user) @@ -113,5 +133,52 @@ test "it works with idna domains as link" do ap_id = "https://" <> to_string(:idna.encode("zetsubou.みんな")) <> "/users/lain" {:ok, _data} = WebFinger.finger(ap_id) end + + test "respects json content-type" do + Tesla.Mock.mock(fn + %{ + url: + "https://mastodon.social/.well-known/webfinger?resource=acct:emelie@mastodon.social" + } -> + {:ok, + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/webfinger_emelie.json"), + headers: [{"content-type", "application/jrd+json"}] + }} + + %{url: "http://mastodon.social/.well-known/host-meta"} -> + {:ok, + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/mastodon.social_host_meta") + }} + end) + + {:ok, _data} = WebFinger.finger("emelie@mastodon.social") + end + + test "respects xml content-type" do + Tesla.Mock.mock(fn + %{ + url: "https://pawoo.net/.well-known/webfinger?resource=acct:pekorino@pawoo.net" + } -> + {:ok, + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/https___pawoo.net_users_pekorino.xml"), + headers: [{"content-type", "application/xrd+xml"}] + }} + + %{url: "http://pawoo.net/.well-known/host-meta"} -> + {:ok, + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/pawoo.net_host_meta") + }} + end) + + {:ok, _data} = WebFinger.finger("pekorino@pawoo.net") + end end end diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex index 1328d6225..1e98020f0 100644 --- a/test/support/http_request_mock.ex +++ b/test/support/http_request_mock.ex @@ -122,7 +122,7 @@ def get( %Tesla.Env{ status: 200, body: File.read!("test/fixtures/tesla_mock/mike@osada.macgirvin.com.json"), - headers: activitypub_object_headers() + headers: [{"content-type", "application/jrd+json"}] }} end @@ -187,7 +187,8 @@ def get( {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/lain_squeet.me_webfinger.xml") + body: File.read!("test/fixtures/tesla_mock/lain_squeet.me_webfinger.xml"), + headers: [{"content-type", "application/xrd+xml"}] }} end @@ -526,22 +527,6 @@ def get( }} end - def get("http://zetsubou.xn--q9jyb4c/.well-known/host-meta", _, _, _) do - {:ok, - %Tesla.Env{ - status: 200, - body: File.read!("test/fixtures/tesla_mock/xn--q9jyb4c_host_meta") - }} - end - - def get("https://zetsubou.xn--q9jyb4c/.well-known/host-meta", _, _, _) do - {:ok, - %Tesla.Env{ - status: 200, - body: File.read!("test/fixtures/tesla_mock/xn--q9jyb4c_host_meta") - }} - end - def get("http://pleroma.soykaf.com/.well-known/host-meta", _, _, _) do {:ok, %Tesla.Env{ @@ -786,7 +771,8 @@ def get( {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/shp@social.heldscal.la.xml") + body: File.read!("test/fixtures/tesla_mock/shp@social.heldscal.la.xml"), + headers: [{"content-type", "application/xrd+xml"}] }} end @@ -796,7 +782,7 @@ def get( _, [{"accept", "application/xrd+xml,application/jrd+json"}] ) do - {:ok, %Tesla.Env{status: 200, body: ""}} + {:ok, %Tesla.Env{status: 200, body: "", headers: [{"content-type", "application/jrd+json"}]}} end def get("http://framatube.org/.well-known/host-meta", _, _, _) do @@ -816,7 +802,7 @@ def get( {:ok, %Tesla.Env{ status: 200, - headers: [{"content-type", "application/json"}], + headers: [{"content-type", "application/jrd+json"}], body: File.read!("test/fixtures/tesla_mock/framasoft@framatube.org.json") }} end @@ -876,7 +862,7 @@ def get( {:ok, %Tesla.Env{ status: 200, - headers: [{"content-type", "application/json"}], + headers: [{"content-type", "application/jrd+json"}], body: File.read!("test/fixtures/tesla_mock/kaniini@gerzilla.de.json") }} end @@ -1074,7 +1060,8 @@ def get( {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/lain.xml") + body: File.read!("test/fixtures/lain.xml"), + headers: [{"content-type", "application/xrd+xml"}] }} end @@ -1087,7 +1074,16 @@ def get( {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/lain.xml") + body: File.read!("test/fixtures/lain.xml"), + headers: [{"content-type", "application/xrd+xml"}] + }} + end + + def get("http://zetsubou.xn--q9jyb4c/.well-known/host-meta", _, _, _) do + {:ok, + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/host-meta-zetsubou.xn--q9jyb4c.xml") }} end @@ -1153,7 +1149,8 @@ def get("https://mstdn.jp/.well-known/webfinger?resource=acct:kpherox@mstdn.jp", {:ok, %Tesla.Env{ status: 200, - body: File.read!("test/fixtures/tesla_mock/kpherox@mstdn.jp.xml") + body: File.read!("test/fixtures/tesla_mock/kpherox@mstdn.jp.xml"), + headers: [{"content-type", "application/xrd+xml"}] }} end From ef5b0510eb3e2c77c94fc5a6168180141a73361f Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Sat, 20 Mar 2021 08:29:02 +0300 Subject: [PATCH 091/339] updating timex --- mix.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mix.lock b/mix.lock index 6034ce5a8..19b90660b 100644 --- a/mix.lock +++ b/mix.lock @@ -52,7 +52,7 @@ "gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm", "29bd14a88030980849c7ed2447b8db6d6c9278a28b11a44cafe41b791205440f"}, "gen_stage": {:hex, :gen_stage, "0.14.3", "d0c66f1c87faa301c1a85a809a3ee9097a4264b2edf7644bf5c123237ef732bf", [:mix], [], "hexpm"}, "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"}, - "gettext": {:hex, :gettext, "0.18.0", "406d6b9e0e3278162c2ae1de0a60270452c553536772167e2d701f028116f870", [:mix], [], "hexpm", "c3f850be6367ebe1a08616c2158affe4a23231c70391050bf359d5f92f66a571"}, + "gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"}, "gun": {:git, "https://github.com/ninenines/gun.git", "921c47146b2d9567eac7e9a4d2ccc60fffd4f327", [ref: "921c47146b2d9567eac7e9a4d2ccc60fffd4f327"]}, "hackney": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/hackney.git", "7d7119f0651515d6d7669c78393fd90950a3ec6e", [ref: "7d7119f0651515d6d7669c78393fd90950a3ec6e"]}, "html_entities": {:hex, :html_entities, "0.5.1", "1c9715058b42c35a2ab65edc5b36d0ea66dd083767bef6e3edb57870ef556549", [:mix], [], "hexpm", "30efab070904eb897ff05cd52fa61c1025d7f8ef3a9ca250bc4e6513d16c32de"}, @@ -117,9 +117,9 @@ "syslog": {:hex, :syslog, "1.1.0", "6419a232bea84f07b56dc575225007ffe34d9fdc91abe6f1b2f254fd71d8efc2", [:rebar3], [], "hexpm", "4c6a41373c7e20587be33ef841d3de6f3beba08519809329ecc4d27b15b659e1"}, "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, "tesla": {:hex, :tesla, "1.4.0", "1081bef0124b8bdec1c3d330bbe91956648fb008cf0d3950a369cda466a31a87", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "bf1374a5569f5fca8e641363b63f7347d680d91388880979a33bc12a6eb3e0aa"}, - "timex": {:hex, :timex, "3.6.2", "845cdeb6119e2fef10751c0b247b6c59d86d78554c83f78db612e3290f819bc2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "26030b46199d02a590be61c2394b37ea25a3664c02fafbeca0b24c972025d47a"}, + "timex": {:hex, :timex, "3.7.3", "df8a2ea814749d700d6878ab9eacac9fdb498ecee2f507cb0002ec172bc24d0f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8691c1d86ca3a7bc14a156e2199dc8927be95d1a8f0e3b69e4bb2d6262c53ac6"}, "trailing_format_plug": {:hex, :trailing_format_plug, "0.0.7", "64b877f912cf7273bed03379936df39894149e35137ac9509117e59866e10e45", [:mix], [{:plug, "> 0.12.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bd4fde4c15f3e993a999e019d64347489b91b7a9096af68b2bdadd192afa693f"}, - "tzdata": {:hex, :tzdata, "1.0.4", "a3baa4709ea8dba552dca165af6ae97c624a2d6ac14bd265165eaa8e8af94af6", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "b02637db3df1fd66dd2d3c4f194a81633d0e4b44308d36c1b2fdfd1e4e6f169b"}, + "tzdata": {:hex, :tzdata, "1.0.5", "69f1ee029a49afa04ad77801febaf69385f3d3e3d1e4b56b9469025677b89a28", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "55519aa2a99e5d2095c1e61cc74c9be69688f8ab75c27da724eb8279ff402a5a"}, "ueberauth": {:hex, :ueberauth, "0.6.3", "d42ace28b870e8072cf30e32e385579c57b9cc96ec74fa1f30f30da9c14f3cc0", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "afc293d8a1140d6591b53e3eaf415ca92842cb1d32fad3c450c6f045f7f91b60"}, "unicode_util_compat": {:git, "https://github.com/benoitc/unicode_util_compat.git", "38d7bc105f51159e8ea3279c40121db9db1e652f", [tag: "0.3.1"]}, "unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm", "6c7729a2d214806450d29766abc2afaa7a2cbecf415be64f36a6691afebb50e5"}, From d3660b24d37862bb58cf309c582cfe7432fd7bb6 Mon Sep 17 00:00:00 2001 From: rinpatch <rin@patch.cx> Date: Mon, 22 Mar 2021 20:07:07 +0300 Subject: [PATCH 092/339] Copy emoji in the subject from parent post Sometimes people put emoji in the subject, which results in the subject looking broken if someone replies to it from a server that does not have the said emoji under the same shortcode. This patch solves the problem by extending the emoji set available in the summary to that of the parent post. --- lib/pleroma/web/common_api/activity_draft.ex | 27 ++++++++++ .../fixtures/tesla_mock/emoji-in-summary.json | 49 +++++++++++++++++++ test/pleroma/web/common_api_test.exs | 26 ++++++++++ test/support/http_request_mock.ex | 9 ++++ 4 files changed, 111 insertions(+) create mode 100644 test/fixtures/tesla_mock/emoji-in-summary.json diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex index 8668b600e..80a9fa7bb 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.CommonAPI.ActivityDraft do alias Pleroma.Activity alias Pleroma.Conversation.Participation + alias Pleroma.Object alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI.Utils @@ -186,6 +187,32 @@ defp sensitive(draft) do defp object(draft) do emoji = Map.merge(Pleroma.Emoji.Formatter.get_emoji_map(draft.full_payload), draft.emoji) + # Sometimes people create posts with subject containing emoji, + # since subjects are usually copied this will result in a broken + # subject when someone replies from an instance that does not have + # the emoji or has it under different shortcode. This is an attempt + # to mitigate this by copying emoji from inReplyTo if they are present + # in the subject. + summary_emoji = + with %Activity{} <- draft.in_reply_to, + %Object{data: %{"tag" => [_ | _] = tag}} <- Object.normalize(draft.in_reply_to) do + Enum.reduce(tag, %{}, fn + %{"type" => "Emoji", "name" => name, "icon" => %{"url" => url}}, acc -> + if String.contains?(draft.summary, name) do + Map.put(acc, name, url) + else + acc + end + + _, acc -> + acc + end) + else + _ -> %{} + end + + emoji = Map.merge(emoji, summary_emoji) + object = Utils.make_note_data(draft) |> Map.put("emoji", emoji) diff --git a/test/fixtures/tesla_mock/emoji-in-summary.json b/test/fixtures/tesla_mock/emoji-in-summary.json new file mode 100644 index 000000000..f77c6e2e8 --- /dev/null +++ b/test/fixtures/tesla_mock/emoji-in-summary.json @@ -0,0 +1,49 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://patch.cx/schemas/litepub-0.1.jsonld", + { + "@language": "und" + } + ], + "actor": "https://patch.cx/users/rin", + "attachment": [], + "attributedTo": "https://patch.cx/users/rin", + "cc": [ + "https://patch.cx/users/rin/followers" + ], + "content": ":joker_disapprove: <br><br>just grabbing a test fixture, nevermind me", + "context": "https://patch.cx/contexts/2c3ce4b4-18b1-4b1a-8965-3932027b5326", + "conversation": "https://patch.cx/contexts/2c3ce4b4-18b1-4b1a-8965-3932027b5326", + "id": "https://patch.cx/objects/a399c28e-c821-4820-bc3e-4afeb044c16f", + "published": "2021-03-22T16:54:46.461939Z", + "sensitive": null, + "source": ":joker_disapprove: \r\n\r\njust grabbing a test fixture, nevermind me", + "summary": ":joker_smile: ", + "tag": [ + { + "icon": { + "type": "Image", + "url": "https://patch.cx/emoji/custom/joker_disapprove.png" + }, + "id": "https://patch.cx/emoji/custom/joker_disapprove.png", + "name": ":joker_disapprove:", + "type": "Emoji", + "updated": "1970-01-01T00:00:00Z" + }, + { + "icon": { + "type": "Image", + "url": "https://patch.cx/emoji/custom/joker_smile.png" + }, + "id": "https://patch.cx/emoji/custom/joker_smile.png", + "name": ":joker_smile:", + "type": "Emoji", + "updated": "1970-01-01T00:00:00Z" + } + ], + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type": "Note" +} diff --git a/test/pleroma/web/common_api_test.exs b/test/pleroma/web/common_api_test.exs index 9d005697c..6619f8fc8 100644 --- a/test/pleroma/web/common_api_test.exs +++ b/test/pleroma/web/common_api_test.exs @@ -25,6 +25,11 @@ defmodule Pleroma.Web.CommonAPITest do require Pleroma.Constants + setup_all do + Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) + :ok + end + setup do: clear_config([:instance, :safe_dm_mentions]) setup do: clear_config([:instance, :limit]) setup do: clear_config([:instance, :max_pinned_statuses]) @@ -517,6 +522,27 @@ test "it adds an emoji on an external site" do assert url == "#{Pleroma.Web.base_url()}/emoji/blank.png" end + test "it copies emoji from the subject of the parent post" do + %Object{} = + object = + Object.normalize("https://patch.cx/objects/a399c28e-c821-4820-bc3e-4afeb044c16f", + fetch: true + ) + + activity = Activity.get_create_by_object_ap_id(object.data["id"]) + user = insert(:user) + + {:ok, reply_activity} = + CommonAPI.post(user, %{ + in_reply_to_id: activity.id, + status: ":joker_disapprove:", + spoiler_text: ":joker_smile:" + }) + + assert Object.normalize(reply_activity).data["emoji"][":joker_smile:"] + refute Object.normalize(reply_activity).data["emoji"][":joker_disapprove:"] + end + test "deactivated users can't post" do user = insert(:user, is_active: false) assert {:error, _} = CommonAPI.post(user, %{status: "ye"}) diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex index 1e98020f0..eb692fab5 100644 --- a/test/support/http_request_mock.ex +++ b/test/support/http_request_mock.ex @@ -1278,6 +1278,15 @@ def get("https://osada.macgirvin.com/", _, "", [{"accept", "text/html"}]) do }} end + def get("https://patch.cx/objects/a399c28e-c821-4820-bc3e-4afeb044c16f", _, _, _) do + {:ok, + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/emoji-in-summary.json"), + headers: activitypub_object_headers() + }} + end + def get(url, query, body, headers) do {:error, "Mock response not implemented for GET #{inspect(url)}, #{query}, #{inspect(body)}, #{ From 03843a53868860c0b6b2bebcf262bde746482f7e Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Tue, 23 Mar 2021 14:23:37 +0300 Subject: [PATCH 093/339] migrating config to tmp folder --- docs/administration/CLI_tasks/config.md | 10 +++-- lib/mix/tasks/pleroma/config.ex | 49 ++++++++++++++++++------- test/mix/tasks/pleroma/config_test.exs | 38 +++++++++++++++++++ 3 files changed, 80 insertions(+), 17 deletions(-) diff --git a/docs/administration/CLI_tasks/config.md b/docs/administration/CLI_tasks/config.md index 000ed4d98..fc9f3cbd5 100644 --- a/docs/administration/CLI_tasks/config.md +++ b/docs/administration/CLI_tasks/config.md @@ -32,16 +32,20 @@ config :pleroma, configurable_from_database: false ``` -To delete transferred settings from database optional flag `-d` can be used. `<env>` is `prod` by default. +Options: + +- `<path>` - where to save migrated config. E.g. `--path=/tmp`. If file saved into non standart folder, you must manually copy file into directory where Pleroma can read it. For OTP install path will be `PLEROMA_CONFIG_PATH` or `/etc/pleroma`. For installation from source - `config` directory in the pleroma folder. +- `<env>` - environment, for which is migrated config. By default is `prod`. +- To delete transferred settings from database optional flag `-d` can be used === "OTP" ```sh - ./bin/pleroma_ctl config migrate_from_db [--env=<env>] [-d] + ./bin/pleroma_ctl config migrate_from_db [--env=<env>] [-d] [--path=<path>] ``` === "From Source" ```sh - mix pleroma.config migrate_from_db [--env=<env>] [-d] + mix pleroma.config migrate_from_db [--env=<env>] [-d] [--path=<path>] ``` ## Dump all of the config settings defined in the database diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index 1962154b9..ac89702ae 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -27,7 +27,7 @@ def run(["migrate_from_db" | options]) do {opts, _} = OptionParser.parse!(options, - strict: [env: :string, delete: :boolean], + strict: [env: :string, delete: :boolean, path: :string], aliases: [d: :delete] ) @@ -259,18 +259,43 @@ defp create(group, settings) do defp migrate_from_db(opts) do env = opts[:env] || Pleroma.Config.get(:env) + filename = "#{env}.exported_from_db.secret.exs" + config_path = - if Pleroma.Config.get(:release) do - :config_path - |> Pleroma.Config.get() - |> Path.dirname() - else - "config" + cond do + opts[:path] -> + opts[:path] + + Pleroma.Config.get(:release) -> + :config_path + |> Pleroma.Config.get() + |> Path.dirname() + + true -> + "config" end - |> Path.join("#{env}.exported_from_db.secret.exs") + |> Path.join(filename) - file = File.open!(config_path, [:write, :utf8]) + with {:ok, file} <- File.open(config_path, [:write, :utf8]) do + write_config(file, config_path, opts) + shell_info("Database configuration settings have been exported to #{config_path}") + else + _ -> + shell_error("Impossible to save settings to this directory #{Path.dirname(config_path)}") + tmp_config_path = Path.join("/tmp", filename) + file = File.open!(tmp_config_path) + shell_info( + "Saving database configuration settings to #{tmp_config_path}. Copy it to the #{ + Path.dirname(config_path) + } manually." + ) + + write_config(file, tmp_config_path, opts) + end + end + + defp write_config(file, path, opts) do IO.write(file, config_header()) ConfigDB @@ -278,11 +303,7 @@ defp migrate_from_db(opts) do |> Enum.each(&write_and_delete(&1, file, opts[:delete])) :ok = File.close(file) - System.cmd("mix", ["format", config_path]) - - shell_info( - "Database configuration settings have been exported to config/#{env}.exported_from_db.secret.exs" - ) + System.cmd("mix", ["format", path]) end if Code.ensure_loaded?(Config.Reader) do diff --git a/test/mix/tasks/pleroma/config_test.exs b/test/mix/tasks/pleroma/config_test.exs index 21f8f2286..3ed1e94b8 100644 --- a/test/mix/tasks/pleroma/config_test.exs +++ b/test/mix/tasks/pleroma/config_test.exs @@ -200,6 +200,44 @@ test "load a settings with large values and pass to file", %{temp_file: temp_fil end end + describe "migrate_from_db/1" do + setup do: clear_config(:configurable_from_database, true) + + setup do + insert_config_record(:pleroma, :setting_first, key: "value", key2: ["Activity"]) + insert_config_record(:pleroma, :setting_second, key: "value2", key2: [Repo]) + insert_config_record(:quack, :level, :info) + + path = "test/instance_static" + file_path = Path.join(path, "temp.exported_from_db.secret.exs") + + on_exit(fn -> File.rm!(file_path) end) + + [file_path: file_path] + end + + test "with path parameter", %{file_path: file_path} do + MixTask.run(["migrate_from_db", "--env", "temp", "--path", Path.dirname(file_path)]) + + file = File.read!(file_path) + assert file =~ "config :pleroma, :setting_first," + assert file =~ "config :pleroma, :setting_second," + assert file =~ "config :quack, :level, :info" + end + + test "release", %{file_path: file_path} do + clear_config(:release, true) + clear_config(:config_path, file_path) + + MixTask.run(["migrate_from_db", "--env", "temp"]) + + file = File.read!(file_path) + assert file =~ "config :pleroma, :setting_first," + assert file =~ "config :pleroma, :setting_second," + assert file =~ "config :quack, :level, :info" + end + end + describe "operations on database config" do setup do: clear_config(:configurable_from_database, true) From 4cd34d019764fdd68829ebd4282118abc4534133 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Tue, 23 Mar 2021 17:27:02 +0300 Subject: [PATCH 094/339] suggestion --- lib/mix/tasks/pleroma/config.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mix/tasks/pleroma/config.ex b/lib/mix/tasks/pleroma/config.ex index ac89702ae..22502a522 100644 --- a/lib/mix/tasks/pleroma/config.ex +++ b/lib/mix/tasks/pleroma/config.ex @@ -282,7 +282,7 @@ defp migrate_from_db(opts) do else _ -> shell_error("Impossible to save settings to this directory #{Path.dirname(config_path)}") - tmp_config_path = Path.join("/tmp", filename) + tmp_config_path = Path.join(System.tmp_dir!(), filename) file = File.open!(tmp_config_path) shell_info( From ad907254fb47764869fecd5928bd863182421c8c Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Tue, 23 Mar 2021 19:37:25 +0300 Subject: [PATCH 095/339] changelog entry --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1fa22398..fb26c7a73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased (Patch) +### Fixed + +- Try to save exported ConfigDB settings (migrate_from_db) in the system temp directory if default location is not writable. + ## [2.3.0] - 2020-03-01 ### Security @@ -51,7 +55,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Pleroma API: Reroute `/api/pleroma/*` to `/api/v1/pleroma/*` </details> -- Improved hashtag timeline performance (requires a background migration). +- Improved hashtag timeline performance (requires a background migration). ### Added From b6a69b5efda5f75ad716252c69ae658a4e885b0a Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Wed, 24 Mar 2021 12:50:05 -0500 Subject: [PATCH 096/339] Return token's primary key with POST /oauth/token --- .../API/differences_in_mastoapi_responses.md | 24 +++++++++++++++++-- lib/pleroma/web/o_auth/o_auth_view.ex | 1 + .../web/o_auth/o_auth_controller_test.exs | 6 +++-- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/docs/development/API/differences_in_mastoapi_responses.md b/docs/development/API/differences_in_mastoapi_responses.md index a14fcb416..3552b12fb 100644 --- a/docs/development/API/differences_in_mastoapi_responses.md +++ b/docs/development/API/differences_in_mastoapi_responses.md @@ -255,9 +255,29 @@ This information is returned in the `/api/v1/accounts/verify_credentials` endpoi *Pleroma supports refreshing tokens.* -`POST /oauth/token` +### POST `/oauth/token` -Post here request with `grant_type=refresh_token` to obtain new access token. Returns an access token. +You can obtain access tokens for a user in a few additional ways. + +#### Refreshing a token + +To obtain a new access token from a refresh token, pass `grant_type=refresh_token` with the following extra parameters: + +- `refresh_token`: The refresh token. + +#### Getting a token with a password + +To obtain a token from a user's password, pass `grant_type=password` with the following extra parameters: + +- `username`: Username to authenticate. +- `password`: The user's password. + +#### Response body + +Additional fields are returned in the response: + +- `id`: The primary key of this token in Pleroma's database. +- `me` (user tokens only): The ActivityPub ID of the user who owns the token. ## Account Registration diff --git a/lib/pleroma/web/o_auth/o_auth_view.ex b/lib/pleroma/web/o_auth/o_auth_view.ex index 281bbcc3c..1419c96a2 100644 --- a/lib/pleroma/web/o_auth/o_auth_view.ex +++ b/lib/pleroma/web/o_auth/o_auth_view.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.OAuth.OAuthView do def render("token.json", %{token: token} = opts) do response = %{ + id: token.id, token_type: "Bearer", access_token: token.token, refresh_token: token.refresh_token, diff --git a/test/pleroma/web/o_auth/o_auth_controller_test.exs b/test/pleroma/web/o_auth/o_auth_controller_test.exs index 312500feb..0fdd5b8e9 100644 --- a/test/pleroma/web/o_auth/o_auth_controller_test.exs +++ b/test/pleroma/web/o_auth/o_auth_controller_test.exs @@ -805,10 +805,12 @@ test "issues a token for `password` grant_type with valid credentials, with full "client_secret" => app.client_secret }) - assert %{"access_token" => token} = json_response(conn, 200) + assert %{"id" => id, "access_token" => access_token} = json_response(conn, 200) - token = Repo.get_by(Token, token: token) + token = Repo.get_by(Token, token: access_token) assert token + assert token.id == id + assert token.token == access_token assert token.scopes == app.scopes end From 3ec1dbd9223aa44205e90967175f07cc532501ab Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Wed, 3 Feb 2021 16:09:28 +0300 Subject: [PATCH 097/339] Let pins federate - save object ids on pin, instead of activity ids - pins federation - removed pinned_activities field from the users table - activityPub endpoint for user pins - pulling remote users pins --- .../API/differences_in_mastoapi_responses.md | 1 + lib/pleroma/activity.ex | 79 +++++++------ lib/pleroma/activity/queries.ex | 5 + lib/pleroma/user.ex | 77 +++++++------ lib/pleroma/web/activity_pub/activity_pub.ex | 60 +++++++++- .../activity_pub/activity_pub_controller.ex | 8 ++ lib/pleroma/web/activity_pub/builder.ex | 32 ++++++ .../web/activity_pub/object_validator.ex | 11 ++ .../object_validators/pin_validator.ex | 42 +++++++ lib/pleroma/web/activity_pub/side_effects.ex | 56 +++++++++- .../web/activity_pub/transmogrifier.ex | 9 ++ .../web/activity_pub/views/user_view.ex | 21 ++++ .../api_spec/operations/status_operation.ex | 46 +++++++- lib/pleroma/web/api_spec/schemas/status.ex | 7 ++ lib/pleroma/web/common_api.ex | 57 +++++++--- .../controllers/fallback_controller.ex | 6 + .../controllers/status_controller.ex | 12 ++ .../web/mastodon_api/views/status_view.ex | 23 +++- lib/pleroma/web/router.ex | 1 + ...0202110641_add_pinned_objects_to_users.exs | 9 ++ ...03141144_add_featured_address_to_users.exs | 23 ++++ ..._pinned_activities_into_pinned_objects.exs | 28 +++++ ...21_remove_pinned_activities_from_users.exs | 15 +++ test/fixtures/collections/featured.json | 39 +++++++ test/fixtures/masto_pin.json | 41 +++++++ test/fixtures/statuses/note.json | 27 +++++ test/fixtures/users_mock/masto_featured.json | 18 +++ test/fixtures/users_mock/user.json | 41 +++++++ test/pleroma/user_test.exs | 45 ++++++++ .../activity_pub_controller_test.exs | 105 ++++++++++++++++++ .../web/activity_pub/activity_pub_test.exs | 77 +++++++++++++ .../web/activity_pub/transmogrifier_test.exs | 74 ++++++++++++ test/pleroma/web/common_api_test.exs | 60 ++++++++-- .../controllers/status_controller_test.exs | 32 ++++-- .../mastodon_api/views/status_view_test.exs | 3 +- .../remote_follow_controller_test.exs | 30 +++++ test/support/factory.ex | 6 +- test/support/http_request_mock.ex | 23 ++++ 38 files changed, 1127 insertions(+), 122 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/object_validators/pin_validator.ex create mode 100644 priv/repo/migrations/20210202110641_add_pinned_objects_to_users.exs create mode 100644 priv/repo/migrations/20210203141144_add_featured_address_to_users.exs create mode 100644 priv/repo/migrations/20210205145000_move_pinned_activities_into_pinned_objects.exs create mode 100644 priv/repo/migrations/20210206045221_remove_pinned_activities_from_users.exs create mode 100644 test/fixtures/collections/featured.json create mode 100644 test/fixtures/masto_pin.json create mode 100644 test/fixtures/statuses/note.json create mode 100644 test/fixtures/users_mock/masto_featured.json create mode 100644 test/fixtures/users_mock/user.json diff --git a/docs/development/API/differences_in_mastoapi_responses.md b/docs/development/API/differences_in_mastoapi_responses.md index a14fcb416..2ff56d3ca 100644 --- a/docs/development/API/differences_in_mastoapi_responses.md +++ b/docs/development/API/differences_in_mastoapi_responses.md @@ -38,6 +38,7 @@ Has these additional fields under the `pleroma` object: - `thread_muted`: true if the thread the post belongs to is muted - `emoji_reactions`: A list with emoji / reaction maps. The format is `{name: "☕", count: 1, me: true}`. Contains no information about the reacting users, for that use the `/statuses/:id/reactions` endpoint. - `parent_visible`: If the parent of this post is visible to the user or not. +- `pinned_at`: a datetime (iso8601) when status was pinned, `null` otherwise. ## Scheduled statuses diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index d59403884..a4cfca4c5 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -184,40 +184,48 @@ def get_by_ap_id_with_object(ap_id) do |> Repo.one() end - @spec get_by_id(String.t()) :: Activity.t() | nil - def get_by_id(id) do - case FlakeId.flake_id?(id) do - true -> - Activity - |> where([a], a.id == ^id) - |> restrict_deactivated_users() - |> Repo.one() + @doc """ + Gets activity by ID, doesn't load activities from deactivated actors by default. + """ + @spec get_by_id(String.t(), keyword()) :: t() | nil + def get_by_id(id, opts \\ [filter: [:restrict_deactivated]]), do: get_by_id_with_opts(id, opts) - _ -> - nil + @spec get_by_id_with_user_actor(String.t()) :: t() | nil + def get_by_id_with_user_actor(id), do: get_by_id_with_opts(id, preload: [:user_actor]) + + @spec get_by_id_with_object(String.t()) :: t() | nil + def get_by_id_with_object(id), do: get_by_id_with_opts(id, preload: [:object]) + + defp get_by_id_with_opts(id, opts) do + if FlakeId.flake_id?(id) do + query = Queries.by_id(id) + + with_filters_query = + if is_list(opts[:filter]) do + Enum.reduce(opts[:filter], query, fn + {:type, type}, acc -> Queries.by_type(acc, type) + :restrict_deactivated, acc -> restrict_deactivated_users(acc) + _, acc -> acc + end) + else + query + end + + with_preloads_query = + if is_list(opts[:preload]) do + Enum.reduce(opts[:preload], with_filters_query, fn + :user_actor, acc -> with_preloaded_user_actor(acc) + :object, acc -> with_preloaded_object(acc) + _, acc -> acc + end) + else + with_filters_query + end + + Repo.one(with_preloads_query) end end - def get_by_id_with_user_actor(id) do - case FlakeId.flake_id?(id) do - true -> - Activity - |> where([a], a.id == ^id) - |> with_preloaded_user_actor() - |> Repo.one() - - _ -> - nil - end - end - - def get_by_id_with_object(id) do - Activity - |> where(id: ^id) - |> with_preloaded_object() - |> Repo.one() - end - def all_by_ids_with_object(ids) do Activity |> where([a], a.id in ^ids) @@ -269,6 +277,11 @@ def get_create_by_object_ap_id_with_object(ap_id) when is_binary(ap_id) do def get_create_by_object_ap_id_with_object(_), do: nil + @spec create_by_id_with_object(String.t()) :: t() | nil + def create_by_id_with_object(id) do + get_by_id_with_opts(id, preload: [:object], filter: [type: "Create"]) + end + defp get_in_reply_to_activity_from_object(%Object{data: %{"inReplyTo" => ap_id}}) do get_create_by_object_ap_id_with_object(ap_id) end @@ -368,12 +381,6 @@ def direct_conversation_id(activity, for_user) do end end - @spec pinned_by_actor?(Activity.t()) :: boolean() - def pinned_by_actor?(%Activity{} = activity) do - actor = user_actor(activity) - activity.id in actor.pinned_activities - end - @spec get_by_object_ap_id_with_object(String.t()) :: t() | nil def get_by_object_ap_id_with_object(ap_id) when is_binary(ap_id) do ap_id diff --git a/lib/pleroma/activity/queries.ex b/lib/pleroma/activity/queries.ex index a6b02a889..4632651b0 100644 --- a/lib/pleroma/activity/queries.ex +++ b/lib/pleroma/activity/queries.ex @@ -14,6 +14,11 @@ defmodule Pleroma.Activity.Queries do alias Pleroma.Activity alias Pleroma.User + @spec by_id(query(), String.t()) :: query() + def by_id(query \\ Activity, id) do + from(a in query, where: a.id == ^id) + end + @spec by_ap_id(query, String.t()) :: query def by_ap_id(query \\ Activity, ap_id) do from( diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index c1aa0f716..b78777141 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -99,6 +99,7 @@ defmodule Pleroma.User do field(:local, :boolean, default: true) field(:follower_address, :string) field(:following_address, :string) + field(:featured_address, :string) field(:search_rank, :float, virtual: true) field(:search_type, :integer, virtual: true) field(:tags, {:array, :string}, default: []) @@ -130,7 +131,6 @@ defmodule Pleroma.User do field(:hide_followers, :boolean, default: false) field(:hide_follows, :boolean, default: false) field(:hide_favorites, :boolean, default: true) - field(:pinned_activities, {:array, :string}, default: []) field(:email_notifications, :map, default: %{"digest" => false}) field(:mascot, :map, default: nil) field(:emoji, :map, default: %{}) @@ -148,6 +148,7 @@ defmodule Pleroma.User do field(:accepts_chat_messages, :boolean, default: nil) field(:last_active_at, :naive_datetime) field(:disclose_client, :boolean, default: true) + field(:pinned_objects, :map, default: %{}) embeds_one( :notification_settings, @@ -372,8 +373,10 @@ def banner_url(user, options \\ []) do end # Should probably be renamed or removed + @spec ap_id(User.t()) :: String.t() def ap_id(%User{nickname: nickname}), do: "#{Web.base_url()}/users/#{nickname}" + @spec ap_followers(User.t()) :: String.t() def ap_followers(%User{follower_address: fa}) when is_binary(fa), do: fa def ap_followers(%User{} = user), do: "#{ap_id(user)}/followers" @@ -381,6 +384,11 @@ def ap_followers(%User{} = user), do: "#{ap_id(user)}/followers" def ap_following(%User{following_address: fa}) when is_binary(fa), do: fa def ap_following(%User{} = user), do: "#{ap_id(user)}/following" + @spec ap_featured_collection(User.t()) :: String.t() + def ap_featured_collection(%User{featured_address: fa}) when is_binary(fa), do: fa + + def ap_featured_collection(%User{} = user), do: "#{ap_id(user)}/collections/featured" + defp truncate_fields_param(params) do if Map.has_key?(params, :fields) do Map.put(params, :fields, Enum.map(params[:fields], &truncate_field/1)) @@ -443,6 +451,7 @@ def remote_user_changeset(struct \\ %User{local: false}, params) do :uri, :follower_address, :following_address, + :featured_address, :hide_followers, :hide_follows, :hide_followers_count, @@ -454,7 +463,8 @@ def remote_user_changeset(struct \\ %User{local: false}, params) do :invisible, :actor_type, :also_known_as, - :accepts_chat_messages + :accepts_chat_messages, + :pinned_objects ] ) |> cast(params, [:name], empty_values: []) @@ -686,7 +696,7 @@ def register_changeset_ldap(struct, params = %{password: password}) |> validate_format(:nickname, local_nickname_regex()) |> put_ap_id() |> unique_constraint(:ap_id) - |> put_following_and_follower_address() + |> put_following_and_follower_and_featured_address() end def register_changeset(struct, params \\ %{}, opts \\ []) do @@ -747,7 +757,7 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do |> put_password_hash |> put_ap_id() |> unique_constraint(:ap_id) - |> put_following_and_follower_address() + |> put_following_and_follower_and_featured_address() end def maybe_validate_required_email(changeset, true), do: changeset @@ -765,11 +775,16 @@ defp put_ap_id(changeset) do put_change(changeset, :ap_id, ap_id) end - defp put_following_and_follower_address(changeset) do - followers = ap_followers(%User{nickname: get_field(changeset, :nickname)}) + defp put_following_and_follower_and_featured_address(changeset) do + user = %User{nickname: get_field(changeset, :nickname)} + followers = ap_followers(user) + following = ap_following(user) + featured = ap_featured_collection(user) changeset |> put_change(:follower_address, followers) + |> put_change(:following_address, following) + |> put_change(:featured_address, featured) end defp autofollow_users(user) do @@ -2343,45 +2358,35 @@ def approval_changeset(user, set_approval: approved?) do cast(user, %{is_approved: approved?}, [:is_approved]) end - def add_pinnned_activity(user, %Pleroma.Activity{id: id}) do - if id not in user.pinned_activities do - max_pinned_statuses = Config.get([:instance, :max_pinned_statuses], 0) - params = %{pinned_activities: user.pinned_activities ++ [id]} - - # if pinned activity was scheduled for deletion, we remove job - if expiration = Pleroma.Workers.PurgeExpiredActivity.get_expiration(id) do - Oban.cancel_job(expiration.id) - end + @spec add_pinned_object_id(User.t(), String.t()) :: {:ok, User.t()} | {:error, term()} + def add_pinned_object_id(%User{} = user, object_id) do + if !user.pinned_objects[object_id] do + params = %{pinned_objects: Map.put(user.pinned_objects, object_id, NaiveDateTime.utc_now())} user - |> cast(params, [:pinned_activities]) - |> validate_length(:pinned_activities, - max: max_pinned_statuses, - message: "You have already pinned the maximum number of statuses" - ) + |> cast(params, [:pinned_objects]) + |> validate_change(:pinned_objects, fn :pinned_objects, pinned_objects -> + max_pinned_statuses = Config.get([:instance, :max_pinned_statuses], 0) + + if Enum.count(pinned_objects) <= max_pinned_statuses do + [] + else + [pinned_objects: "You have already pinned the maximum number of statuses"] + end + end) else change(user) end |> update_and_set_cache() end - def remove_pinnned_activity(user, %Pleroma.Activity{id: id, data: data}) do - params = %{pinned_activities: List.delete(user.pinned_activities, id)} - - # if pinned activity was scheduled for deletion, we reschedule it for deletion - if data["expires_at"] do - # MRF.ActivityExpirationPolicy used UTC timestamps for expires_at in original implementation - {:ok, expires_at} = - data["expires_at"] |> Pleroma.EctoType.ActivityPub.ObjectValidators.DateTime.cast() - - Pleroma.Workers.PurgeExpiredActivity.enqueue(%{ - activity_id: id, - expires_at: expires_at - }) - end - + @spec remove_pinned_object_id(User.t(), String.t()) :: {:ok, t()} | {:error, term()} + def remove_pinned_object_id(%User{} = user, object_id) do user - |> cast(params, [:pinned_activities]) + |> cast( + %{pinned_objects: Map.delete(user.pinned_objects, object_id)}, + [:pinned_objects] + ) |> update_and_set_cache() end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index efbf92c70..d0051d1cb 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -630,7 +630,7 @@ defp fetch_activities_for_user(user, reading_user, params) do |> Map.put(:type, ["Create", "Announce"]) |> Map.put(:user, reading_user) |> Map.put(:actor_id, user.ap_id) - |> Map.put(:pinned_activity_ids, user.pinned_activities) + |> Map.put(:pinned_object_ids, Map.keys(user.pinned_objects)) params = if User.blocks?(reading_user, user) do @@ -1075,8 +1075,18 @@ defp restrict_unlisted(query, %{restrict_unlisted: true}) do defp restrict_unlisted(query, _), do: query - defp restrict_pinned(query, %{pinned: true, pinned_activity_ids: ids}) do - from(activity in query, where: activity.id in ^ids) + defp restrict_pinned(query, %{pinned: true, pinned_object_ids: ids}) do + from( + [activity, object: o] in query, + where: + fragment( + "(?)->>'type' = 'Create' and coalesce((?)->'object'->>'id', (?)->>'object') = any (?)", + activity.data, + activity.data, + activity.data, + ^ids + ) + ) end defp restrict_pinned(query, _), do: query @@ -1419,6 +1429,9 @@ defp object_to_user_data(data) do invisible = data["invisible"] || false actor_type = data["type"] || "Person" + featured_address = data["featured"] + {:ok, pinned_objects} = fetch_and_prepare_featured_from_ap_id(featured_address) + public_key = if is_map(data["publicKey"]) && is_binary(data["publicKey"]["publicKeyPem"]) do data["publicKey"]["publicKeyPem"] @@ -1447,13 +1460,15 @@ defp object_to_user_data(data) do name: data["name"], follower_address: data["followers"], following_address: data["following"], + featured_address: featured_address, bio: data["summary"] || "", actor_type: actor_type, also_known_as: Map.get(data, "alsoKnownAs", []), public_key: public_key, inbox: data["inbox"], shared_inbox: shared_inbox, - accepts_chat_messages: accepts_chat_messages + accepts_chat_messages: accepts_chat_messages, + pinned_objects: pinned_objects } # nickname can be nil because of virtual actors @@ -1591,6 +1606,41 @@ def maybe_handle_clashing_nickname(data) do end end + def pin_data_from_featured_collection(%{ + "type" => type, + "orderedItems" => objects + }) + when type in ["OrderedCollection", "Collection"] do + Map.new(objects, fn %{"id" => object_ap_id} -> {object_ap_id, NaiveDateTime.utc_now()} end) + end + + def fetch_and_prepare_featured_from_ap_id(nil) do + {:ok, %{}} + end + + def fetch_and_prepare_featured_from_ap_id(ap_id) do + with {:ok, data} <- Fetcher.fetch_and_contain_remote_object_from_id(ap_id) do + {:ok, pin_data_from_featured_collection(data)} + else + e -> + Logger.error("Could not decode featured collection at fetch #{ap_id}, #{inspect(e)}") + {:ok, %{}} + end + end + + def pinned_fetch_task(nil), do: nil + + def pinned_fetch_task(%{pinned_objects: pins}) do + if Enum.all?(pins, fn {ap_id, _} -> + Object.get_cached_by_ap_id(ap_id) || + match?({:ok, _object}, Fetcher.fetch_object_from_id(ap_id)) + end) do + :ok + else + :error + end + end + def make_user_from_ap_id(ap_id) do user = User.get_cached_by_ap_id(ap_id) @@ -1598,6 +1648,8 @@ def make_user_from_ap_id(ap_id) do Transmogrifier.upgrade_user_from_ap_id(ap_id) else with {:ok, data} <- fetch_and_prepare_user_from_ap_id(ap_id) do + {:ok, _pid} = Task.start(fn -> pinned_fetch_task(data) end) + if user do user |> User.remote_user_changeset(data) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 9d3dcc7f9..5aa3b281a 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -543,4 +543,12 @@ def upload_media(%{assigns: %{user: %User{} = user}} = conn, %{"file" => file} = |> json(object.data) end end + + def pinned(conn, %{"nickname" => nickname}) do + with %User{} = user <- User.get_cached_by_nickname(nickname) do + conn + |> put_resp_header("content-type", "application/activity+json") + |> json(UserView.render("featured.json", %{user: user})) + end + end end diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index f56bfc600..91a45836f 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -273,4 +273,36 @@ defp object_action(actor, object) do "context" => object.data["context"] }, []} end + + @spec pin(User.t(), Object.t()) :: {:ok, map(), keyword()} + def pin(%User{} = user, object) do + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "target" => pinned_url(user.nickname), + "object" => object.data["id"], + "actor" => user.ap_id, + "type" => "Add", + "to" => [Pleroma.Constants.as_public()], + "cc" => [user.follower_address] + }, []} + end + + @spec unpin(User.t(), Object.t()) :: {:ok, map, keyword()} + def unpin(%User{} = user, object) do + {:ok, + %{ + "id" => Utils.generate_activity_id(), + "target" => pinned_url(user.nickname), + "object" => object.data["id"], + "actor" => user.ap_id, + "type" => "Remove", + "to" => [Pleroma.Constants.as_public()], + "cc" => [user.follower_address] + }, []} + end + + defp pinned_url(nickname) when is_binary(nickname) do + Pleroma.Web.Router.Helpers.activity_pub_url(Pleroma.Web.Endpoint, :pinned, nickname) + end end diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 297c19cc0..11432ef38 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -30,6 +30,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Web.ActivityPub.ObjectValidators.EventValidator alias Pleroma.Web.ActivityPub.ObjectValidators.FollowValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.PinValidator alias Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator alias Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator alias Pleroma.Web.ActivityPub.ObjectValidators.UpdateValidator @@ -234,6 +235,16 @@ def validate(%{"type" => "Announce"} = object, meta) do end end + def validate(%{"type" => type} = object, meta) when type in ~w(Add Remove) do + with {:ok, object} <- + object + |> PinValidator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object) + {:ok, object, meta} + end + end + def cast_and_apply(%{"type" => "ChatMessage"} = object) do ChatMessageValidator.cast_and_apply(object) end diff --git a/lib/pleroma/web/activity_pub/object_validators/pin_validator.ex b/lib/pleroma/web/activity_pub/object_validators/pin_validator.ex new file mode 100644 index 000000000..dca8cba6f --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/pin_validator.ex @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.PinValidator do + use Ecto.Schema + + import Ecto.Changeset + import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + + alias Pleroma.EctoType.ActivityPub.ObjectValidators + + @primary_key false + + embedded_schema do + field(:id, ObjectValidators.ObjectID, primary_key: true) + field(:target) + field(:object, ObjectValidators.ObjectID) + field(:actor, ObjectValidators.ObjectID) + field(:type) + field(:to, ObjectValidators.Recipients, default: []) + field(:cc, ObjectValidators.Recipients, default: []) + end + + def cast_and_validate(data) do + data + |> cast_data() + |> validate_data() + end + + defp cast_data(data) do + cast(%__MODULE__{}, data, __schema__(:fields)) + end + + defp validate_data(changeset) do + changeset + |> validate_required([:id, :target, :object, :actor, :type, :to, :cc]) + |> validate_inclusion(:type, ~w(Add Remove)) + |> validate_actor_presence() + |> validate_object_presence() + end +end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 0b9a9f0c5..9d22f9d3c 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -276,10 +276,10 @@ def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, result = case deleted_object do %Object{} -> - with {:ok, deleted_object, activity} <- Object.delete(deleted_object), + with {:ok, deleted_object, _activity} <- Object.delete(deleted_object), {_, actor} when is_binary(actor) <- {:actor, deleted_object.data["actor"]}, %User{} = user <- User.get_cached_by_ap_id(actor) do - User.remove_pinnned_activity(user, activity) + User.remove_pinned_object_id(user, deleted_object.data["id"]) {:ok, user} = ActivityPub.decrease_note_count_if_public(user, deleted_object) @@ -312,6 +312,58 @@ def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, end end + # Tasks this handles: + # - adds pin to user + # - removes expiration job for pinned activity, if was set for expiration + @impl true + def handle(%{data: %{"type" => "Add"} = data} = object, meta) do + with %User{} = user <- User.get_cached_by_ap_id(data["actor"]), + {:ok, _user} <- User.add_pinned_object_id(user, data["object"]) do + # if pinned activity was scheduled for deletion, we remove job + if expiration = Pleroma.Workers.PurgeExpiredActivity.get_expiration(meta[:activity_id]) do + Oban.cancel_job(expiration.id) + end + + {:ok, object, meta} + else + nil -> + {:error, :user_not_found} + + {:error, changeset} -> + if changeset.errors[:pinned_objects] do + {:error, :pinned_statuses_limit_reached} + else + changeset.errors + end + end + end + + # Tasks this handles: + # - removes pin from user + # - if activity had expiration, recreates activity expiration job + @impl true + def handle(%{data: %{"type" => "Remove"} = data} = object, meta) do + with %User{} = user <- User.get_cached_by_ap_id(data["actor"]), + {:ok, _user} <- User.remove_pinned_object_id(user, data["object"]) do + # if pinned activity was scheduled for deletion, we reschedule it for deletion + if meta[:expires_at] do + # MRF.ActivityExpirationPolicy used UTC timestamps for expires_at in original implementation + {:ok, expires_at} = + Pleroma.EctoType.ActivityPub.ObjectValidators.DateTime.cast(meta[:expires_at]) + + Pleroma.Workers.PurgeExpiredActivity.enqueue(%{ + activity_id: meta[:activity_id], + expires_at: expires_at + }) + end + + {:ok, object, meta} + else + nil -> {:error, :user_not_found} + error -> error + end + end + # Nothing to do @impl true def handle(object, meta) do diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 8c7d6a747..270cea6dc 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -556,6 +556,14 @@ def handle_incoming( end end + def handle_incoming(%{"type" => type} = data, _options) when type in ~w(Add Remove) do + with :ok <- ObjectValidator.fetch_actor_and_object(data), + %Object{} <- Object.normalize(data["object"], fetch: true), + {:ok, activity, _meta} <- Pipeline.common_pipeline(data, local: false) do + {:ok, activity} + end + end + def handle_incoming( %{"type" => "Delete"} = data, _options @@ -1000,6 +1008,7 @@ def upgrade_user_from_ap_id(ap_id) do with %User{local: false} = user <- User.get_cached_by_ap_id(ap_id), {:ok, data} <- ActivityPub.fetch_and_prepare_user_from_ap_id(ap_id), {:ok, user} <- update_user(user, data) do + {:ok, _pid} = Task.start(fn -> ActivityPub.pinned_fetch_task(user) end) TransmogrifierWorker.enqueue("user_upgrade", %{"user_id" => user.id}) {:ok, user} else diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index 8adc9878a..462f3b4a7 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -6,8 +6,10 @@ defmodule Pleroma.Web.ActivityPub.UserView do use Pleroma.Web, :view alias Pleroma.Keys + alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User + alias Pleroma.Web.ActivityPub.ObjectView alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.Endpoint @@ -97,6 +99,7 @@ def render("user.json", %{user: user}) do "followers" => "#{user.ap_id}/followers", "inbox" => "#{user.ap_id}/inbox", "outbox" => "#{user.ap_id}/outbox", + "featured" => "#{user.ap_id}/collections/featured", "preferredUsername" => user.nickname, "name" => user.name, "summary" => user.bio, @@ -245,6 +248,24 @@ def render("activity_collection_page.json", %{ |> Map.merge(pagination) end + def render("featured.json", %{ + user: %{featured_address: featured_address, pinned_objects: pinned_objects} + }) do + objects = + pinned_objects + |> Enum.sort_by(fn {_, pinned_at} -> pinned_at end, &>=/2) + |> Enum.map(fn {id, _} -> + ObjectView.render("object.json", %{object: Object.get_cached_by_ap_id(id)}) + end) + + %{ + "id" => featured_address, + "type" => "OrderedCollection", + "orderedItems" => objects + } + |> Map.merge(Utils.make_json_ld_header()) + end + defp maybe_put_total_items(map, false, _total), do: map defp maybe_put_total_items(map, true, total) do diff --git a/lib/pleroma/web/api_spec/operations/status_operation.ex b/lib/pleroma/web/api_spec/operations/status_operation.ex index 4bdb8e281..802fbef3e 100644 --- a/lib/pleroma/web/api_spec/operations/status_operation.ex +++ b/lib/pleroma/web/api_spec/operations/status_operation.ex @@ -182,7 +182,34 @@ def pin_operation do parameters: [id_param()], responses: %{ 200 => status_response(), - 400 => Operation.response("Error", "application/json", ApiError) + 400 => + Operation.response("Bad Request", "application/json", %Schema{ + allOf: [ApiError], + title: "Unprocessable Entity", + example: %{ + "error" => "You have already pinned the maximum number of statuses" + } + }), + 404 => + Operation.response("Not found", "application/json", %Schema{ + allOf: [ApiError], + title: "Unprocessable Entity", + example: %{ + "error" => "Record not found" + } + }), + 422 => + Operation.response( + "Unprocessable Entity", + "application/json", + %Schema{ + allOf: [ApiError], + title: "Unprocessable Entity", + example: %{ + "error" => "Someone else's status cannot be pinned" + } + } + ) } } end @@ -197,7 +224,22 @@ def unpin_operation do parameters: [id_param()], responses: %{ 200 => status_response(), - 400 => Operation.response("Error", "application/json", ApiError) + 400 => + Operation.response("Bad Request", "application/json", %Schema{ + allOf: [ApiError], + title: "Unprocessable Entity", + example: %{ + "error" => "You have already pinned the maximum number of statuses" + } + }), + 404 => + Operation.response("Not found", "application/json", %Schema{ + allOf: [ApiError], + title: "Unprocessable Entity", + example: %{ + "error" => "Record not found" + } + }) } } end diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index 42fa98718..3d042dc19 100644 --- a/lib/pleroma/web/api_spec/schemas/status.ex +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -194,6 +194,13 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do parent_visible: %Schema{ type: :boolean, description: "`true` if the parent post is visible to the user" + }, + pinned_at: %Schema{ + type: :string, + format: "date-time", + nullable: true, + description: + "A datetime (ISO 8601) that states when the post was pinned or `null` if the post is not pinned" } } }, diff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex index b003e30c7..d35a0f219 100644 --- a/lib/pleroma/web/common_api.ex +++ b/lib/pleroma/web/common_api.ex @@ -411,29 +411,54 @@ def post(user, %{status: _} = data) do end end - def pin(id, %{ap_id: user_ap_id} = user) do - with %Activity{ - actor: ^user_ap_id, - data: %{"type" => "Create"}, - object: %Object{data: %{"type" => object_type}} - } = activity <- Activity.get_by_id_with_object(id), - true <- object_type in ["Note", "Article", "Question"], - true <- Visibility.is_public?(activity), - {:ok, _user} <- User.add_pinnned_activity(user, activity) do + @spec pin(String.t(), User.t()) :: {:ok, Activity.t()} | {:error, term()} + def pin(id, %User{ap_id: actor} = user) do + with %Activity{} = activity <- create_activity_by_id(id), + true <- activity_belongs_to_actor(activity, actor), + true <- object_type_is_allowed_for_pin(activity.object), + true <- activity_is_public(activity), + {:ok, pin_data, _} <- Builder.pin(user, activity.object), + {:ok, _pin, _} <- + Pipeline.common_pipeline(pin_data, local: true, activity_id: id) do {:ok, activity} else - {:error, %{errors: [pinned_activities: {err, _}]}} -> {:error, err} - _ -> {:error, dgettext("errors", "Could not pin")} + {:error, {:execute_side_effects, error}} -> error + error -> error end end + defp create_activity_by_id(id) do + with nil <- Activity.create_by_id_with_object(id) do + {:error, :not_found} + end + end + + defp activity_belongs_to_actor(%{actor: actor}, actor), do: true + defp activity_belongs_to_actor(_, _), do: {:error, :ownership_error} + + defp object_type_is_allowed_for_pin(%{data: %{"type" => type}}) do + with false <- type in ["Note", "Article", "Question"] do + {:error, :not_allowed} + end + end + + defp activity_is_public(activity) do + with false <- Visibility.is_public?(activity) do + {:error, :visibility_error} + end + end + + @spec unpin(String.t(), User.t()) :: {:ok, User.t()} | {:error, term()} def unpin(id, user) do - with %Activity{data: %{"type" => "Create"}} = activity <- Activity.get_by_id(id), - {:ok, _user} <- User.remove_pinnned_activity(user, activity) do + with %Activity{} = activity <- create_activity_by_id(id), + {:ok, unpin_data, _} <- Builder.unpin(user, activity.object), + {:ok, _unpin, _} <- + Pipeline.common_pipeline(unpin_data, + local: true, + activity_id: activity.id, + expires_at: activity.data["expires_at"] + ) do {:ok, activity} - else - {:error, %{errors: [pinned_activities: {err, _}]}} -> {:error, err} - _ -> {:error, dgettext("errors", "Could not unpin")} end end diff --git a/lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex b/lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex index d25f84837..84621500e 100644 --- a/lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex @@ -30,6 +30,12 @@ def call(conn, {:error, error_message}) do |> json(%{error: error_message}) end + def call(conn, {:error, status, message}) do + conn + |> put_status(status) + |> json(%{error: message}) + end + def call(conn, _) do conn |> put_status(:internal_server_error) diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index b051fca74..724dc5c5d 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -260,6 +260,18 @@ def unfavourite(%{assigns: %{user: user}} = conn, %{id: activity_id}) do def pin(%{assigns: %{user: user}} = conn, %{id: ap_id_or_id}) do with {:ok, activity} <- CommonAPI.pin(ap_id_or_id, user) do try_render(conn, "show.json", activity: activity, for: user, as: :activity) + else + {:error, :pinned_statuses_limit_reached} -> + {:error, "You have already pinned the maximum number of statuses"} + + {:error, :ownership_error} -> + {:error, :unprocessable_entity, "Someone else's status cannot be pinned"} + + {:error, :visibility_error} -> + {:error, :unprocessable_entity, "Non-public status cannot be pinned"} + + error -> + error end end diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 3753588f2..d0247fa4a 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -152,6 +152,8 @@ def render( |> Enum.filter(& &1) |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end) + {pinned?, pinned_at} = pin_data(activity_object, user) + %{ id: to_string(activity.id), uri: object.data["id"], @@ -173,7 +175,7 @@ def render( favourited: present?(favorited), bookmarked: present?(bookmarked), muted: false, - pinned: pinned?(activity, user), + pinned: pinned?, sensitive: false, spoiler_text: "", visibility: get_visibility(activity), @@ -184,7 +186,8 @@ def render( language: nil, emojis: [], pleroma: %{ - local: activity.local + local: activity.local, + pinned_at: pinned_at } } end @@ -316,6 +319,8 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} fn for_user, user -> User.mutes?(for_user, user) end ) + {pinned?, pinned_at} = pin_data(object, user) + %{ id: to_string(activity.id), uri: object.data["id"], @@ -339,7 +344,7 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} favourited: present?(favorited), bookmarked: present?(bookmarked), muted: muted, - pinned: pinned?(activity, user), + pinned: pinned?, sensitive: sensitive, spoiler_text: summary, visibility: get_visibility(object), @@ -360,7 +365,8 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} direct_conversation_id: direct_conversation_id, thread_muted: thread_muted?, emoji_reactions: emoji_reactions, - parent_visible: visible_for_user?(reply_to, opts[:for]) + parent_visible: visible_for_user?(reply_to, opts[:for]), + pinned_at: pinned_at } } end @@ -529,8 +535,13 @@ defp present?(nil), do: false defp present?(false), do: false defp present?(_), do: true - defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}), - do: id in pinned_activities + defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do + if pinned_at = pinned_objects[object_id] do + {true, Utils.to_masto_date(pinned_at)} + else + {false, nil} + end + end defp build_emoji_map(emoji, users, current_user) do %{ diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index de0bd27d7..ccf2ef796 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -704,6 +704,7 @@ defmodule Pleroma.Web.Router do # The following two are S2S as well, see `ActivityPub.fetch_follow_information_for_user/1`: get("/users/:nickname/followers", ActivityPubController, :followers) get("/users/:nickname/following", ActivityPubController, :following) + get("/users/:nickname/collections/featured", ActivityPubController, :pinned) end scope "/", Pleroma.Web.ActivityPub do diff --git a/priv/repo/migrations/20210202110641_add_pinned_objects_to_users.exs b/priv/repo/migrations/20210202110641_add_pinned_objects_to_users.exs new file mode 100644 index 000000000..644527246 --- /dev/null +++ b/priv/repo/migrations/20210202110641_add_pinned_objects_to_users.exs @@ -0,0 +1,9 @@ +defmodule Pleroma.Repo.Migrations.AddPinnedObjectsToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add(:pinned_objects, :map) + end + end +end diff --git a/priv/repo/migrations/20210203141144_add_featured_address_to_users.exs b/priv/repo/migrations/20210203141144_add_featured_address_to_users.exs new file mode 100644 index 000000000..0f6a21611 --- /dev/null +++ b/priv/repo/migrations/20210203141144_add_featured_address_to_users.exs @@ -0,0 +1,23 @@ +defmodule Pleroma.Repo.Migrations.AddFeaturedAddressToUsers do + use Ecto.Migration + + def up do + alter table(:users) do + add(:featured_address, :string) + end + + create(index(:users, [:featured_address])) + + execute(""" + + update users set featured_address = concat(ap_id, '/collections/featured') where local = true and featured_address is null; + + """) + end + + def down do + alter table(:users) do + remove(:featured_address) + end + end +end diff --git a/priv/repo/migrations/20210205145000_move_pinned_activities_into_pinned_objects.exs b/priv/repo/migrations/20210205145000_move_pinned_activities_into_pinned_objects.exs new file mode 100644 index 000000000..9aee545e3 --- /dev/null +++ b/priv/repo/migrations/20210205145000_move_pinned_activities_into_pinned_objects.exs @@ -0,0 +1,28 @@ +defmodule Pleroma.Repo.Migrations.MovePinnedActivitiesIntoPinnedObjects do + use Ecto.Migration + + import Ecto.Query + + alias Pleroma.Repo + alias Pleroma.User + + def up do + from(u in User) + |> select([u], {u.id, fragment("?.pinned_activities", u)}) + |> Repo.stream() + |> Stream.each(fn {user_id, pinned_activities_ids} -> + pinned_activities = Pleroma.Activity.all_by_ids_with_object(pinned_activities_ids) + + pins = + Map.new(pinned_activities, fn %{object: %{data: %{"id" => object_id}}} -> + {object_id, NaiveDateTime.utc_now()} + end) + + from(u in User, where: u.id == ^user_id) + |> Repo.update_all(set: [pinned_objects: pins]) + end) + |> Stream.run() + end + + def down, do: :noop +end diff --git a/priv/repo/migrations/20210206045221_remove_pinned_activities_from_users.exs b/priv/repo/migrations/20210206045221_remove_pinned_activities_from_users.exs new file mode 100644 index 000000000..a3ee93f48 --- /dev/null +++ b/priv/repo/migrations/20210206045221_remove_pinned_activities_from_users.exs @@ -0,0 +1,15 @@ +defmodule Pleroma.Repo.Migrations.RemovePinnedActivitiesFromUsers do + use Ecto.Migration + + def up do + alter table(:users) do + remove(:pinned_activities) + end + end + + def down do + alter table(:users) do + add(:pinned_activities, {:array, :string}, default: []) + end + end +end diff --git a/test/fixtures/collections/featured.json b/test/fixtures/collections/featured.json new file mode 100644 index 000000000..56f8f56fa --- /dev/null +++ b/test/fixtures/collections/featured.json @@ -0,0 +1,39 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://{{domain}}/schemas/litepub-0.1.jsonld", + { + "@language": "und" + } + ], + "id": "https://{{domain}}/users/{{nickname}}/collections/featured", + "orderedItems": [ + { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://{{domain}}/schemas/litepub-0.1.jsonld", + { + "@language": "und" + } + ], + "actor": "https://{{domain}}/users/{{nickname}}", + "attachment": [], + "attributedTo": "https://{{domain}}/users/{{nickname}}", + "cc": [ + "https://{{domain}}/users/{{nickname}}/followers" + ], + "content": "", + "id": "https://{{domain}}/objects/{{object_id}}", + "published": "2021-02-12T15:13:43.915429Z", + "sensitive": false, + "source": "", + "summary": "", + "tag": [], + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type": "Note" + } + ], + "type": "OrderedCollection" +} diff --git a/test/fixtures/masto_pin.json b/test/fixtures/masto_pin.json new file mode 100644 index 000000000..e57a34375 --- /dev/null +++ b/test/fixtures/masto_pin.json @@ -0,0 +1,41 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "Emoji": "toot:Emoji", + "Hashtag": "as:Hashtag", + "PropertyValue": "schema:PropertyValue", + "alsoKnownAs": { + "@id": "as:alsoKnownAs", + "@type": "@id" + }, + "atomUri": "ostatus:atomUri", + "conversation": "ostatus:conversation", + "featured": { + "@id": "toot:featured", + "@type": "@id" + }, + "focalPoint": { + "@container": "@list", + "@id": "toot:focalPoint" + }, + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "movedTo": { + "@id": "as:movedTo", + "@type": "@id" + }, + "ostatus": "http://ostatus.org#", + "schema": "http://schema.org#", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "value": "schema:value" + } + ], + "id": "https://example.com/users/nickname/statuses/{{id}}", + "actor": "https://example.com/users/nickname", + "object": "https://example.com/users/nickname/statuses/101355175004496751", + "target": "https://example.com/users/nickname/collections/featured", + "type": "{{type}}" +} diff --git a/test/fixtures/statuses/note.json b/test/fixtures/statuses/note.json new file mode 100644 index 000000000..41735cbc5 --- /dev/null +++ b/test/fixtures/statuses/note.json @@ -0,0 +1,27 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://example.com/schemas/litepub-0.1.jsonld", + { + "@language": "und" + } + ], + "actor": "https://example.com/users/{{nickname}}", + "attachment": [], + "attributedTo": "https://example.com/users/{{nickname}}", + "cc": [ + "https://example.com/users/{{nickname}}/followers" + ], + "content": "Content", + "context": "https://example.com/contexts/e4b180e1-7403-477f-aeb4-de57e7a3fe7f", + "conversation": "https://example.com/contexts/e4b180e1-7403-477f-aeb4-de57e7a3fe7f", + "id": "https://example.com/objects/{{object_id}}", + "published": "2019-12-15T22:00:05.279583Z", + "sensitive": false, + "summary": "", + "tag": [], + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type": "Note" +} diff --git a/test/fixtures/users_mock/masto_featured.json b/test/fixtures/users_mock/masto_featured.json new file mode 100644 index 000000000..646a343ad --- /dev/null +++ b/test/fixtures/users_mock/masto_featured.json @@ -0,0 +1,18 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "votersCount": "toot:votersCount" + } + ], + "id": "https://{{domain}}/users/{{nickname}}/collections/featured", + "type": "OrderedCollection", + "totalItems": 0, + "orderedItems": [] +} diff --git a/test/fixtures/users_mock/user.json b/test/fixtures/users_mock/user.json new file mode 100644 index 000000000..da2483d02 --- /dev/null +++ b/test/fixtures/users_mock/user.json @@ -0,0 +1,41 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://example.com/schemas/litepub-0.1.jsonld", + { + "@language": "und" + } + ], + "attachment": [], + "endpoints": { + "oauthAuthorizationEndpoint": "https://example.com/oauth/authorize", + "oauthRegistrationEndpoint": "https://example.com/api/v1/apps", + "oauthTokenEndpoint": "https://example.com/oauth/token", + "sharedInbox": "https://example.com/inbox" + }, + "followers": "https://example.com/users/{{nickname}}/followers", + "following": "https://example.com/users/{{nickname}}/following", + "icon": { + "type": "Image", + "url": "https://example.com/media/4e914f5b84e4a259a3f6c2d2edc9ab642f2ab05f3e3d9c52c81fc2d984b3d51e.jpg" + }, + "id": "https://example.com/users/{{nickname}}", + "image": { + "type": "Image", + "url": "https://example.com/media/f739efddefeee49c6e67e947c4811fdc911785c16ae43da4c3684051fbf8da6a.jpg?name=f739efddefeee49c6e67e947c4811fdc911785c16ae43da4c3684051fbf8da6a.jpg" + }, + "inbox": "https://example.com/users/{{nickname}}/inbox", + "manuallyApprovesFollowers": false, + "name": "{{nickname}}", + "outbox": "https://example.com/users/{{nickname}}/outbox", + "preferredUsername": "{{nickname}}", + "publicKey": { + "id": "https://example.com/users/{{nickname}}#main-key", + "owner": "https://example.com/users/{{nickname}}", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5DLtwGXNZElJyxFGfcVc\nXANhaMadj/iYYQwZjOJTV9QsbtiNBeIK54PJrYuU0/0YIdrvS1iqheX5IwXRhcwa\nhm3ZyLz7XeN9st7FBni4BmZMBtMpxAuYuu5p/jbWy13qAiYOhPreCx0wrWgm/lBD\n9mkgaxIxPooBE0S4ZWEJIDIV1Vft3AWcRUyWW1vIBK0uZzs6GYshbQZB952S0yo4\nFzI1hABGHncH8UvuFauh4EZ8tY7/X5I0pGRnDOcRN1dAht5w5yTA+6r5kebiFQjP\nIzN/eCO/a9Flrj9YGW7HDNtjSOH0A31PLRGlJtJO3yK57dnf5ppyCZGfL4emShQo\ncQIDAQAB\n-----END PUBLIC KEY-----\n\n" + }, + "summary": "your friendly neighborhood pleroma developer<br>I like cute things and distributed systems, and really hate delete and redrafts", + "tag": [], + "type": "Person", + "url": "https://example.com/users/{{nickname}}" +} diff --git a/test/pleroma/user_test.exs b/test/pleroma/user_test.exs index 6f5bcab57..d81c1b8eb 100644 --- a/test/pleroma/user_test.exs +++ b/test/pleroma/user_test.exs @@ -2338,4 +2338,49 @@ test "active_user_count/1" do assert User.active_user_count(6) == 3 assert User.active_user_count(1) == 1 end + + describe "pins" do + setup do + user = insert(:user) + + [user: user, object_id: object_id_from_created_activity(user)] + end + + test "unique pins", %{user: user, object_id: object_id} do + assert {:ok, %{pinned_objects: %{^object_id => pinned_at1} = pins} = updated_user} = + User.add_pinned_object_id(user, object_id) + + assert Enum.count(pins) == 1 + + assert {:ok, %{pinned_objects: %{^object_id => pinned_at2} = pins}} = + User.add_pinned_object_id(updated_user, object_id) + + assert pinned_at1 == pinned_at2 + + assert Enum.count(pins) == 1 + end + + test "respects max_pinned_statuses limit", %{user: user, object_id: object_id} do + clear_config([:instance, :max_pinned_statuses], 1) + {:ok, updated} = User.add_pinned_object_id(user, object_id) + + object_id2 = object_id_from_created_activity(user) + + {:error, %{errors: errors}} = User.add_pinned_object_id(updated, object_id2) + assert Keyword.has_key?(errors, :pinned_objects) + end + + test "remove_pinned_object_id/2", %{user: user, object_id: object_id} do + assert {:ok, updated} = User.add_pinned_object_id(user, object_id) + + {:ok, after_remove} = User.remove_pinned_object_id(updated, object_id) + assert after_remove.pinned_objects == %{} + end + end + + defp object_id_from_created_activity(user) do + %{id: id} = insert(:note_activity, user: user) + %{object: %{data: %{"id" => object_id}}} = Activity.get_by_id_with_object(id) + object_id + end end diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index 19e04d472..a9cbf90c3 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -636,6 +636,86 @@ test "without valid signature, " <> |> post("/inbox", non_create_data) |> json_response(400) end + + test "accepts Add/Remove activities", %{conn: conn} do + object_id = "c61d6733-e256-4fe1-ab13-1e369789423f" + + status = + File.read!("test/fixtures/statuses/note.json") + |> String.replace("{{nickname}}", "lain") + |> String.replace("{{object_id}}", object_id) + + object_url = "https://example.com/objects/#{object_id}" + + user = + File.read!("test/fixtures/users_mock/user.json") + |> String.replace("{{nickname}}", "lain") + + actor = "https://example.com/users/lain" + + Tesla.Mock.mock(fn + %{ + method: :get, + url: ^object_url + } -> + %Tesla.Env{ + status: 200, + body: status, + headers: [{"content-type", "application/activity+json"}] + } + + %{ + method: :get, + url: ^actor + } -> + %Tesla.Env{ + status: 200, + body: user, + headers: [{"content-type", "application/activity+json"}] + } + end) + + data = %{ + "id" => "https://example.com/objects/d61d6733-e256-4fe1-ab13-1e369789423f", + "actor" => actor, + "object" => object_url, + "target" => "https://example.com/users/lain/collections/featured", + "type" => "Add", + "to" => [Pleroma.Constants.as_public()] + } + + assert "ok" == + conn + |> assign(:valid_signature, true) + |> put_req_header("content-type", "application/activity+json") + |> post("/inbox", data) + |> json_response(200) + + ObanHelpers.perform(all_enqueued(worker: ReceiverWorker)) + assert Activity.get_by_ap_id(data["id"]) + user = User.get_cached_by_ap_id(data["actor"]) + assert user.pinned_objects[data["object"]] + + data = %{ + "id" => "https://example.com/objects/d61d6733-e256-4fe1-ab13-1e369789423d", + "actor" => actor, + "object" => object_url, + "target" => "https://example.com/users/lain/collections/featured", + "type" => "Remove", + "to" => [Pleroma.Constants.as_public()] + } + + assert "ok" == + conn + |> assign(:valid_signature, true) + |> put_req_header("content-type", "application/activity+json") + |> post("/inbox", data) + |> json_response(200) + + ObanHelpers.perform(all_enqueued(worker: ReceiverWorker)) + user = refresh_record(user) + refute user.pinned_objects[data["object"]] + end end describe "/users/:nickname/inbox" do @@ -1772,4 +1852,29 @@ test "POST /api/ap/upload_media", %{conn: conn} do |> json_response(403) end end + + test "pinned collection", %{conn: conn} do + clear_config([:instance, :max_pinned_statuses], 2) + user = insert(:user) + objects = insert_list(2, :note, user: user) + + Enum.reduce(objects, user, fn %{data: %{"id" => object_id}}, user -> + {:ok, updated} = User.add_pinned_object_id(user, object_id) + updated + end) + + %{nickname: nickname, featured_address: featured_address, pinned_objects: pinned_objects} = + refresh_record(user) + + %{"id" => ^featured_address, "orderedItems" => items} = + conn + |> get("/users/#{nickname}/collections/featured") + |> json_response(200) + + object_ids = Enum.map(items, & &1["id"]) + + assert Enum.all?(pinned_objects, fn {obj_id, _} -> + obj_id in object_ids + end) + end end diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index c7fa452f7..081d00d45 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -235,6 +235,83 @@ test "works for bridgy actors" do "url" => [%{"href" => "https://jk.nipponalba.scot/images/profile.jpg"}] } end + + test "fetches user featured collection" do + ap_id = "https://example.com/users/lain" + + featured_url = "https://example.com/users/lain/collections/featured" + + user_data = + "test/fixtures/users_mock/user.json" + |> File.read!() + |> String.replace("{{nickname}}", "lain") + |> Jason.decode!() + |> Map.put("featured", featured_url) + |> Jason.encode!() + + object_id = Ecto.UUID.generate() + + featured_data = + "test/fixtures/collections/featured.json" + |> File.read!() + |> String.replace("{{domain}}", "example.com") + |> String.replace("{{nickname}}", "lain") + |> String.replace("{{object_id}}", object_id) + + object_url = "https://example.com/objects/#{object_id}" + + object_data = + "test/fixtures/statuses/note.json" + |> File.read!() + |> String.replace("{{object_id}}", object_id) + |> String.replace("{{nickname}}", "lain") + + Tesla.Mock.mock(fn + %{ + method: :get, + url: ^ap_id + } -> + %Tesla.Env{ + status: 200, + body: user_data, + headers: [{"content-type", "application/activity+json"}] + } + + %{ + method: :get, + url: ^featured_url + } -> + %Tesla.Env{ + status: 200, + body: featured_data, + headers: [{"content-type", "application/activity+json"}] + } + end) + + Tesla.Mock.mock_global(fn + %{ + method: :get, + url: ^object_url + } -> + %Tesla.Env{ + status: 200, + body: object_data, + headers: [{"content-type", "application/activity+json"}] + } + end) + + {:ok, user} = ActivityPub.make_user_from_ap_id(ap_id) + Process.sleep(50) + + assert user.featured_address == featured_url + assert Map.has_key?(user.pinned_objects, object_url) + + in_db = Pleroma.User.get_by_ap_id(ap_id) + assert in_db.featured_address == featured_url + assert Map.has_key?(user.pinned_objects, object_url) + + assert %{data: %{"id" => ^object_url}} = Object.get_by_ap_id(object_url) + end end test "it fetches the appropriate tag-restricted posts" do diff --git a/test/pleroma/web/activity_pub/transmogrifier_test.exs b/test/pleroma/web/activity_pub/transmogrifier_test.exs index 4c3fcb44a..28d7e1e3c 100644 --- a/test/pleroma/web/activity_pub/transmogrifier_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier_test.exs @@ -6,6 +6,8 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do use Oban.Testing, repo: Pleroma.Repo use Pleroma.DataCase + require Pleroma.Constants + alias Pleroma.Activity alias Pleroma.Object alias Pleroma.Tests.ObanHelpers @@ -106,6 +108,78 @@ test "it accepts Move activities" do assert activity.data["target"] == new_user.ap_id assert activity.data["type"] == "Move" end + + test "it accepts Add/Remove activities" do + user = + "test/fixtures/users_mock/user.json" + |> File.read!() + |> String.replace("{{nickname}}", "lain") + + object_id = "c61d6733-e256-4fe1-ab13-1e369789423f" + + object = + "test/fixtures/statuses/note.json" + |> File.read!() + |> String.replace("{{nickname}}", "lain") + |> String.replace("{{object_id}}", object_id) + + object_url = "https://example.com/objects/#{object_id}" + + actor = "https://example.com/users/lain" + + Tesla.Mock.mock(fn + %{ + method: :get, + url: ^actor + } -> + %Tesla.Env{ + status: 200, + body: user, + headers: [{"content-type", "application/activity+json"}] + } + + %{ + method: :get, + url: ^object_url + } -> + %Tesla.Env{ + status: 200, + body: object, + headers: [{"content-type", "application/activity+json"}] + } + end) + + message = %{ + "id" => "https://example.com/objects/d61d6733-e256-4fe1-ab13-1e369789423f", + "actor" => actor, + "object" => object_url, + "target" => "https://example.com/users/lain/collections/featured", + "type" => "Add", + "to" => [Pleroma.Constants.as_public()], + "cc" => ["https://example.com/users/lain/followers"] + } + + assert {:ok, activity} = Transmogrifier.handle_incoming(message) + assert activity.data == message + user = User.get_cached_by_ap_id(actor) + assert user.pinned_objects[object_url] + + remove = %{ + "id" => "http://localhost:400/objects/d61d6733-e256-4fe1-ab13-1e369789423d", + "actor" => actor, + "object" => object_url, + "target" => "http://example.com/users/lain/collections/featured", + "type" => "Remove", + "to" => [Pleroma.Constants.as_public()], + "cc" => ["https://example.com/users/lain/followers"] + } + + assert {:ok, activity} = Transmogrifier.handle_incoming(remove) + assert activity.data == remove + + user = refresh_record(user) + refute user.pinned_objects[object_url] + end end describe "prepare outgoing" do diff --git a/test/pleroma/web/common_api_test.exs b/test/pleroma/web/common_api_test.exs index 6619f8fc8..fa55c2832 100644 --- a/test/pleroma/web/common_api_test.exs +++ b/test/pleroma/web/common_api_test.exs @@ -827,13 +827,17 @@ test "favoriting a status twice returns ok, but without the like activity" do [user: user, activity: activity] end + test "activity not found error", %{user: user} do + assert {:error, :not_found} = CommonAPI.pin("id", user) + end + test "pin status", %{user: user, activity: activity} do assert {:ok, ^activity} = CommonAPI.pin(activity.id, user) - id = activity.id + %{data: %{"id" => object_id}} = Object.normalize(activity) user = refresh_record(user) - assert %User{pinned_activities: [^id]} = user + assert user.pinned_objects |> Map.keys() == [object_id] end test "pin poll", %{user: user} do @@ -845,10 +849,11 @@ test "pin poll", %{user: user} do assert {:ok, ^activity} = CommonAPI.pin(activity.id, user) - id = activity.id + %{data: %{"id" => object_id}} = Object.normalize(activity) + user = refresh_record(user) - assert %User{pinned_activities: [^id]} = user + assert user.pinned_objects |> Map.keys() == [object_id] end test "unlisted statuses can be pinned", %{user: user} do @@ -859,7 +864,7 @@ test "unlisted statuses can be pinned", %{user: user} do test "only self-authored can be pinned", %{activity: activity} do user = insert(:user) - assert {:error, "Could not pin"} = CommonAPI.pin(activity.id, user) + assert {:error, :ownership_error} = CommonAPI.pin(activity.id, user) end test "max pinned statuses", %{user: user, activity: activity_one} do @@ -869,8 +874,12 @@ test "max pinned statuses", %{user: user, activity: activity_one} do user = refresh_record(user) - assert {:error, "You have already pinned the maximum number of statuses"} = - CommonAPI.pin(activity_two.id, user) + assert {:error, :pinned_statuses_limit_reached} = CommonAPI.pin(activity_two.id, user) + end + + test "only public can be pinned", %{user: user} do + {:ok, activity} = CommonAPI.post(user, %{status: "private status", visibility: "private"}) + {:error, :visibility_error} = CommonAPI.pin(activity.id, user) end test "unpin status", %{user: user, activity: activity} do @@ -884,7 +893,7 @@ test "unpin status", %{user: user, activity: activity} do user = refresh_record(user) - assert %User{pinned_activities: []} = user + assert user.pinned_objects == %{} end test "should unpin when deleting a status", %{user: user, activity: activity} do @@ -896,7 +905,40 @@ test "should unpin when deleting a status", %{user: user, activity: activity} do user = refresh_record(user) - assert %User{pinned_activities: []} = user + assert user.pinned_objects == %{} + end + + test "ephemeral activity won't be deleted if was pinned", %{user: user} do + {:ok, activity} = CommonAPI.post(user, %{status: "Hello!", expires_in: 601}) + + assert Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) + + {:ok, _activity} = CommonAPI.pin(activity.id, user) + refute Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) + + user = refresh_record(user) + {:ok, _} = CommonAPI.unpin(activity.id, user) + + # recreates expiration job on unpin + assert Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) + end + + test "ephemeral activity deletion job won't be deleted on pinning error", %{ + user: user, + activity: activity + } do + clear_config([:instance, :max_pinned_statuses], 1) + + {:ok, _activity} = CommonAPI.pin(activity.id, user) + + {:ok, activity2} = CommonAPI.post(user, %{status: "another status", expires_in: 601}) + + assert Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity2.id) + + user = refresh_record(user) + {:error, :pinned_statuses_limit_reached} = CommonAPI.pin(activity2.id, user) + + assert Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity2.id) end end diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs index f616f405e..e0d642910 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -1223,6 +1223,13 @@ test "pin status", %{conn: conn, user: user, activity: activity} do |> json_response_and_validate_schema(200) end + test "non authenticated user", %{activity: activity} do + assert build_conn() + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/pin") + |> json_response(403) == %{"error" => "Invalid credentials."} + end + test "/pin: returns 400 error when activity is not public", %{conn: conn, user: user} do {:ok, dm} = CommonAPI.post(user, %{status: "test", visibility: "direct"}) @@ -1231,7 +1238,18 @@ test "/pin: returns 400 error when activity is not public", %{conn: conn, user: |> put_req_header("content-type", "application/json") |> post("/api/v1/statuses/#{dm.id}/pin") - assert json_response_and_validate_schema(conn, 400) == %{"error" => "Could not pin"} + assert json_response_and_validate_schema(conn, 422) == %{ + "error" => "Non-public status cannot be pinned" + } + end + + test "pin by another user", %{activity: activity} do + %{conn: conn} = oauth_access(["write:accounts"]) + + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/#{activity.id}/pin") + |> json_response(422) == %{"error" => "Someone else's status cannot be pinned"} end test "unpin status", %{conn: conn, user: user, activity: activity} do @@ -1252,13 +1270,11 @@ test "unpin status", %{conn: conn, user: user, activity: activity} do |> json_response_and_validate_schema(200) end - test "/unpin: returns 400 error when activity is not exist", %{conn: conn} do - conn = - conn - |> put_req_header("content-type", "application/json") - |> post("/api/v1/statuses/1/unpin") - - assert json_response_and_validate_schema(conn, 400) == %{"error" => "Could not unpin"} + test "/unpin: returns 404 error when activity doesn't exist", %{conn: conn} do + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/statuses/1/unpin") + |> json_response_and_validate_schema(404) == %{"error" => "Record not found"} end test "max pinned statuses", %{conn: conn, user: user, activity: activity_one} do diff --git a/test/pleroma/web/mastodon_api/views/status_view_test.exs b/test/pleroma/web/mastodon_api/views/status_view_test.exs index 4172cc294..fbea25079 100644 --- a/test/pleroma/web/mastodon_api/views/status_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/status_view_test.exs @@ -286,7 +286,8 @@ test "a note activity" do direct_conversation_id: nil, thread_muted: false, emoji_reactions: [], - parent_visible: false + parent_visible: false, + pinned_at: nil } } diff --git a/test/pleroma/web/twitter_api/remote_follow_controller_test.exs b/test/pleroma/web/twitter_api/remote_follow_controller_test.exs index f389c272b..fa3b29006 100644 --- a/test/pleroma/web/twitter_api/remote_follow_controller_test.exs +++ b/test/pleroma/web/twitter_api/remote_follow_controller_test.exs @@ -27,6 +27,16 @@ test "adds status to pleroma instance if the `acct` is a status", %{conn: conn} body: File.read!("test/fixtures/tesla_mock/status.emelie.json") } + %{method: :get, url: "https://mastodon.social/users/emelie/collections/featured"} -> + %Tesla.Env{ + status: 200, + headers: [{"content-type", "application/activity+json"}], + body: + File.read!("test/fixtures/users_mock/masto_featured.json") + |> String.replace("{{domain}}", "mastodon.social") + |> String.replace("{{nickname}}", "emelie") + } + %{method: :get, url: "https://mastodon.social/users/emelie"} -> %Tesla.Env{ status: 200, @@ -52,6 +62,16 @@ test "show follow account page if the `acct` is a account link", %{conn: conn} d headers: [{"content-type", "application/activity+json"}], body: File.read!("test/fixtures/tesla_mock/emelie.json") } + + %{method: :get, url: "https://mastodon.social/users/emelie/collections/featured"} -> + %Tesla.Env{ + status: 200, + headers: [{"content-type", "application/activity+json"}], + body: + File.read!("test/fixtures/users_mock/masto_featured.json") + |> String.replace("{{domain}}", "mastodon.social") + |> String.replace("{{nickname}}", "emelie") + } end) response = @@ -70,6 +90,16 @@ test "show follow page if the `acct` is a account link", %{conn: conn} do headers: [{"content-type", "application/activity+json"}], body: File.read!("test/fixtures/tesla_mock/emelie.json") } + + %{method: :get, url: "https://mastodon.social/users/emelie/collections/featured"} -> + %Tesla.Env{ + status: 200, + headers: [{"content-type", "application/activity+json"}], + body: + File.read!("test/fixtures/users_mock/masto_featured.json") + |> String.replace("{{domain}}", "mastodon.social") + |> String.replace("{{nickname}}", "emelie") + } end) user = insert(:user) diff --git a/test/support/factory.ex b/test/support/factory.ex index af4fff45b..883cedf3c 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -48,13 +48,15 @@ def user_factory(attrs \\ %{}) do %{ ap_id: ap_id, follower_address: ap_id <> "/followers", - following_address: ap_id <> "/following" + following_address: ap_id <> "/following", + featured_address: ap_id <> "/collections/featured" } else %{ ap_id: User.ap_id(user), follower_address: User.ap_followers(user), - following_address: User.ap_following(user) + following_address: User.ap_following(user), + featured_address: User.ap_featured_collection(user) } end diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex index eb692fab5..9e9f1c86c 100644 --- a/test/support/http_request_mock.ex +++ b/test/support/http_request_mock.ex @@ -89,6 +89,18 @@ def get("https://mastodon.sdf.org/users/rinpatch", _, _, _) do }} end + def get("https://mastodon.sdf.org/users/rinpatch/collections/featured", _, _, _) do + {:ok, + %Tesla.Env{ + status: 200, + body: + File.read!("test/fixtures/users_mock/masto_featured.json") + |> String.replace("{{domain}}", "mastodon.sdf.org") + |> String.replace("{{nickname}}", "rinpatch"), + headers: [{"content-type", "application/activity+json"}] + }} + end + def get("https://patch.cx/objects/tesla_mock/poll_attachment", _, _, _) do {:ok, %Tesla.Env{ @@ -905,6 +917,17 @@ def get("https://mastodon.social/users/lambadalambda", _, _, _) do }} end + def get("https://mastodon.social/users/lambadalambda/collections/featured", _, _, _) do + {:ok, + %Tesla.Env{ + status: 200, + body: + File.read!("test/fixtures/users_mock/masto_featured.json") + |> String.replace("{{domain}}", "mastodon.social") + |> String.replace("{{nickname}}", "lambadalambda") + }} + end + def get("https://apfed.club/channel/indio", _, _, _) do {:ok, %Tesla.Env{ From 17f28c0507e3c34ce75e63747eed9abb66713e6e Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Thu, 25 Feb 2021 14:00:44 +0300 Subject: [PATCH 098/339] mastodon pins --- lib/pleroma/object/containment.ex | 8 ++ .../web/activity_pub/transmogrifier.ex | 17 +++- test/fixtures/statuses/masto-note.json | 47 +++++++++++ .../activity_pub_controller_test.exs | 78 +++++++++++++++++++ 4 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/statuses/masto-note.json diff --git a/lib/pleroma/object/containment.ex b/lib/pleroma/object/containment.ex index fb0398f92..040537acf 100644 --- a/lib/pleroma/object/containment.ex +++ b/lib/pleroma/object/containment.ex @@ -71,6 +71,14 @@ def contain_origin_from_id(id, %{"id" => other_id} = _params) when is_binary(oth compare_uris(id_uri, other_uri) end + # Mastodon pin activities don't have an id, so we check the object field, which will be pinned. + def contain_origin_from_id(id, %{"object" => object}) when is_binary(object) do + id_uri = URI.parse(id) + object_uri = URI.parse(object) + + compare_uris(id_uri, object_uri) + end + def contain_origin_from_id(_id, _data), do: :error def contain_child(%{"object" => %{"id" => id, "attributedTo" => _} = object}), diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 270cea6dc..b662f5379 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -557,10 +557,19 @@ def handle_incoming( end def handle_incoming(%{"type" => type} = data, _options) when type in ~w(Add Remove) do - with :ok <- ObjectValidator.fetch_actor_and_object(data), - %Object{} <- Object.normalize(data["object"], fetch: true), - {:ok, activity, _meta} <- Pipeline.common_pipeline(data, local: false) do - {:ok, activity} + with {:ok, user} <- ObjectValidator.fetch_actor(data), + %Object{} <- Object.normalize(data["object"], fetch: true) do + # Mastodon sends pin/unpin objects without id, to, cc fields + data = + data + |> Map.put_new("id", Utils.generate_activity_id()) + |> Map.put_new("to", [Pleroma.Constants.as_public()]) + |> Map.put_new("cc", [user.follower_address]) + + case Pipeline.common_pipeline(data, local: false) do + {:ok, activity, _meta} -> {:ok, activity} + error -> error + end end end diff --git a/test/fixtures/statuses/masto-note.json b/test/fixtures/statuses/masto-note.json new file mode 100644 index 000000000..6b96de473 --- /dev/null +++ b/test/fixtures/statuses/masto-note.json @@ -0,0 +1,47 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + "ostatus": "http://ostatus.org#", + "atomUri": "ostatus:atomUri", + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "sensitive": "as:sensitive", + "toot": "http://joinmastodon.org/ns#", + "votersCount": "toot:votersCount" + } + ], + "id": "https://example.com/users/{{nickname}}/statuses/{{status_id}}", + "type": "Note", + "summary": null, + "inReplyTo": null, + "published": "2021-02-24T12:40:49Z", + "url": "https://example.com/@{{nickname}}/{{status_id}}", + "attributedTo": "https://example.com/users/{{nickname}}", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://example.com/users/{{nickname}}/followers" + ], + "sensitive": false, + "atomUri": "https://example.com/users/{{nickname}}/statuses/{{status_id}}", + "inReplyToAtomUri": null, + "conversation": "tag:example.com,2021-02-24:objectId=15:objectType=Conversation", + "content": "<p></p>", + "contentMap": { + "en": "<p></p>" + }, + "attachment": [], + "tag": [], + "replies": { + "id": "https://example.com/users/{{nickname}}/statuses/{{status_id}}/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "https://example.com/users/{{nickname}}/statuses/{{status_id}}/replies?only_other_accounts=true&page=true", + "partOf": "https://example.com/users/{{nickname}}/statuses/{{status_id}}/replies", + "items": [] + } + } +} diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index a9cbf90c3..d9fa25d94 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -716,6 +716,84 @@ test "accepts Add/Remove activities", %{conn: conn} do user = refresh_record(user) refute user.pinned_objects[data["object"]] end + + test "mastodon pin/unpin", %{conn: conn} do + status_id = "105786274556060421" + + status = + File.read!("test/fixtures/statuses/masto-note.json") + |> String.replace("{{nickname}}", "lain") + |> String.replace("{{status_id}}", status_id) + + status_url = "https://example.com/users/lain/statuses/#{status_id}" + + user = + File.read!("test/fixtures/users_mock/user.json") + |> String.replace("{{nickname}}", "lain") + + actor = "https://example.com/users/lain" + + Tesla.Mock.mock(fn + %{ + method: :get, + url: ^status_url + } -> + %Tesla.Env{ + status: 200, + body: status, + headers: [{"content-type", "application/activity+json"}] + } + + %{ + method: :get, + url: ^actor + } -> + %Tesla.Env{ + status: 200, + body: user, + headers: [{"content-type", "application/activity+json"}] + } + end) + + data = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "actor" => actor, + "object" => status_url, + "target" => "https://example.com/users/lain/collections/featured", + "type" => "Add" + } + + assert "ok" == + conn + |> assign(:valid_signature, true) + |> put_req_header("content-type", "application/activity+json") + |> post("/inbox", data) + |> json_response(200) + + ObanHelpers.perform(all_enqueued(worker: ReceiverWorker)) + assert Activity.get_by_object_ap_id_with_object(data["object"]) + user = User.get_cached_by_ap_id(data["actor"]) + assert user.pinned_objects[data["object"]] + + data = %{ + "actor" => actor, + "object" => status_url, + "target" => "https://example.com/users/lain/collections/featured", + "type" => "Remove" + } + + assert "ok" == + conn + |> assign(:valid_signature, true) + |> put_req_header("content-type", "application/activity+json") + |> post("/inbox", data) + |> json_response(200) + + ObanHelpers.perform(all_enqueued(worker: ReceiverWorker)) + assert Activity.get_by_object_ap_id_with_object(data["object"]) + user = refresh_record(user) + refute user.pinned_objects[data["object"]] + end end describe "/users/:nickname/inbox" do From ff612750b1bae5223bca76b34a39e7d2bd05770c Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Tue, 2 Mar 2021 17:24:06 +0300 Subject: [PATCH 099/339] validator renaming & add validation for target --- lib/pleroma/web/activity_pub/object_validator.ex | 4 ++-- .../{pin_validator.ex => add_remove_validator.ex} | 13 ++++++++++++- .../object_validators/common_validations.ex | 8 ++++++++ .../web/activity_pub/transmogrifier_test.exs | 2 +- .../controllers/status_controller_test.exs | 6 +++--- test/support/http_request_mock.ex | 3 ++- 6 files changed, 28 insertions(+), 8 deletions(-) rename lib/pleroma/web/activity_pub/object_validators/{pin_validator.ex => add_remove_validator.ex} (73%) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 11432ef38..14c3e8531 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -17,6 +17,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Object.Containment alias Pleroma.User alias Pleroma.Web.ActivityPub.ObjectValidators.AcceptRejectValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.AddRemoveValidator alias Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator alias Pleroma.Web.ActivityPub.ObjectValidators.AnswerValidator alias Pleroma.Web.ActivityPub.ObjectValidators.ArticleNoteValidator @@ -30,7 +31,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Web.ActivityPub.ObjectValidators.EventValidator alias Pleroma.Web.ActivityPub.ObjectValidators.FollowValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator - alias Pleroma.Web.ActivityPub.ObjectValidators.PinValidator alias Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator alias Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator alias Pleroma.Web.ActivityPub.ObjectValidators.UpdateValidator @@ -238,7 +238,7 @@ def validate(%{"type" => "Announce"} = object, meta) do def validate(%{"type" => type} = object, meta) when type in ~w(Add Remove) do with {:ok, object} <- object - |> PinValidator.cast_and_validate() + |> AddRemoveValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do object = stringify_keys(object) {:ok, object, meta} diff --git a/lib/pleroma/web/activity_pub/object_validators/pin_validator.ex b/lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex similarity index 73% rename from lib/pleroma/web/activity_pub/object_validators/pin_validator.ex rename to lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex index dca8cba6f..73d1c03f0 100644 --- a/lib/pleroma/web/activity_pub/object_validators/pin_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex @@ -2,7 +2,7 @@ # Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.ActivityPub.ObjectValidators.PinValidator do +defmodule Pleroma.Web.ActivityPub.ObjectValidators.AddRemoveValidator do use Ecto.Schema import Ecto.Changeset @@ -37,6 +37,17 @@ defp validate_data(changeset) do |> validate_required([:id, :target, :object, :actor, :type, :to, :cc]) |> validate_inclusion(:type, ~w(Add Remove)) |> validate_actor_presence() + |> validate_collection_belongs_to_actor() |> validate_object_presence() end + + defp validate_collection_belongs_to_actor(changeset) do + validate_change(changeset, :target, fn :target, target -> + if String.starts_with?(target, changeset.changes[:actor]) do + [] + else + [target: "collection doesn't belong to actor"] + end + end) + end end diff --git a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex index 093549a45..940430588 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do alias Pleroma.Object alias Pleroma.User + @spec validate_any_presence(Ecto.Changeset.t(), [atom()]) :: Ecto.Changeset.t() def validate_any_presence(cng, fields) do non_empty = fields @@ -29,6 +30,7 @@ def validate_any_presence(cng, fields) do end end + @spec validate_actor_presence(Ecto.Changeset.t(), keyword()) :: Ecto.Changeset.t() def validate_actor_presence(cng, options \\ []) do field_name = Keyword.get(options, :field_name, :actor) @@ -47,6 +49,7 @@ def validate_actor_presence(cng, options \\ []) do end) end + @spec validate_object_presence(Ecto.Changeset.t(), keyword()) :: Ecto.Changeset.t() def validate_object_presence(cng, options \\ []) do field_name = Keyword.get(options, :field_name, :object) allowed_types = Keyword.get(options, :allowed_types, false) @@ -68,6 +71,7 @@ def validate_object_presence(cng, options \\ []) do end) end + @spec validate_object_or_user_presence(Ecto.Changeset.t(), keyword()) :: Ecto.Changeset.t() def validate_object_or_user_presence(cng, options \\ []) do field_name = Keyword.get(options, :field_name, :object) options = Keyword.put(options, :field_name, field_name) @@ -83,6 +87,7 @@ def validate_object_or_user_presence(cng, options \\ []) do if actor_cng.valid?, do: actor_cng, else: object_cng end + @spec validate_host_match(Ecto.Changeset.t(), [atom()]) :: Ecto.Changeset.t() def validate_host_match(cng, fields \\ [:id, :actor]) do if same_domain?(cng, fields) do cng @@ -95,6 +100,7 @@ def validate_host_match(cng, fields \\ [:id, :actor]) do end end + @spec validate_fields_match(Ecto.Changeset.t(), [atom()]) :: Ecto.Changeset.t() def validate_fields_match(cng, fields) do if map_unique?(cng, fields) do cng @@ -122,12 +128,14 @@ defp map_unique?(cng, fields, func \\ & &1) do end) end + @spec same_domain?(Ecto.Changeset.t(), [atom()]) :: boolean() def same_domain?(cng, fields \\ [:actor, :object]) do map_unique?(cng, fields, fn value -> URI.parse(value).host end) end # This figures out if a user is able to create, delete or modify something # based on the domain and superuser status + @spec validate_modification_rights(Ecto.Changeset.t()) :: Ecto.Changeset.t() def validate_modification_rights(cng) do actor = User.get_cached_by_ap_id(get_field(cng, :actor)) diff --git a/test/pleroma/web/activity_pub/transmogrifier_test.exs b/test/pleroma/web/activity_pub/transmogrifier_test.exs index 28d7e1e3c..9bc27f89e 100644 --- a/test/pleroma/web/activity_pub/transmogrifier_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier_test.exs @@ -168,7 +168,7 @@ test "it accepts Add/Remove activities" do "id" => "http://localhost:400/objects/d61d6733-e256-4fe1-ab13-1e369789423d", "actor" => actor, "object" => object_url, - "target" => "http://example.com/users/lain/collections/featured", + "target" => "https://example.com/users/lain/collections/featured", "type" => "Remove", "to" => [Pleroma.Constants.as_public()], "cc" => ["https://example.com/users/lain/followers"] diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs index e0d642910..99ad87d05 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -1209,15 +1209,15 @@ test "returns 404 error for a wrong id", %{conn: conn} do setup do: clear_config([:instance, :max_pinned_statuses], 1) test "pin status", %{conn: conn, user: user, activity: activity} do - id_str = to_string(activity.id) + id = activity.id - assert %{"id" => ^id_str, "pinned" => true} = + assert %{"id" => ^id, "pinned" => true} = conn |> put_req_header("content-type", "application/json") |> post("/api/v1/statuses/#{activity.id}/pin") |> json_response_and_validate_schema(200) - assert [%{"id" => ^id_str, "pinned" => true}] = + assert [%{"id" => ^id, "pinned" => true}] = conn |> get("/api/v1/accounts/#{user.id}/statuses?pinned=true") |> json_response_and_validate_schema(200) diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex index 9e9f1c86c..8807c2d14 100644 --- a/test/support/http_request_mock.ex +++ b/test/support/http_request_mock.ex @@ -924,7 +924,8 @@ def get("https://mastodon.social/users/lambadalambda/collections/featured", _, _ body: File.read!("test/fixtures/users_mock/masto_featured.json") |> String.replace("{{domain}}", "mastodon.social") - |> String.replace("{{nickname}}", "lambadalambda") + |> String.replace("{{nickname}}", "lambadalambda"), + headers: activitypub_object_headers() }} end From d1d2744ee3e6015064cf50ac5725bfe45b682466 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Wed, 3 Mar 2021 15:41:05 +0300 Subject: [PATCH 100/339] featured_address valition in AddRemoveValidator --- .../web/activity_pub/object_validator.ex | 2 +- .../object_validators/add_remove_validator.ex | 12 +++++----- .../web/activity_pub/transmogrifier.ex | 7 ++++-- lib/pleroma/web/common_api.ex | 13 +++++++---- test/fixtures/users_mock/user.json | 1 + .../activity_pub_controller_test.exs | 22 +++++++++++++++++++ .../web/activity_pub/transmogrifier_test.exs | 11 ++++++++++ 7 files changed, 55 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 14c3e8531..3ca9136aa 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -238,7 +238,7 @@ def validate(%{"type" => "Announce"} = object, meta) do def validate(%{"type" => type} = object, meta) when type in ~w(Add Remove) do with {:ok, object} <- object - |> AddRemoveValidator.cast_and_validate() + |> AddRemoveValidator.cast_and_validate(meta) |> Ecto.Changeset.apply_action(:insert) do object = stringify_keys(object) {:ok, object, meta} diff --git a/lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex b/lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex index 73d1c03f0..885282f32 100644 --- a/lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex @@ -22,28 +22,28 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AddRemoveValidator do field(:cc, ObjectValidators.Recipients, default: []) end - def cast_and_validate(data) do + def cast_and_validate(data, meta) do data |> cast_data() - |> validate_data() + |> validate_data(meta) end defp cast_data(data) do cast(%__MODULE__{}, data, __schema__(:fields)) end - defp validate_data(changeset) do + defp validate_data(changeset, meta) do changeset |> validate_required([:id, :target, :object, :actor, :type, :to, :cc]) |> validate_inclusion(:type, ~w(Add Remove)) |> validate_actor_presence() - |> validate_collection_belongs_to_actor() + |> validate_collection_belongs_to_actor(meta) |> validate_object_presence() end - defp validate_collection_belongs_to_actor(changeset) do + defp validate_collection_belongs_to_actor(changeset, meta) do validate_change(changeset, :target, fn :target, target -> - if String.starts_with?(target, changeset.changes[:actor]) do + if target == meta[:featured_address] do [] else [target: "collection doesn't belong to actor"] diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index b662f5379..fa62e0db2 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -557,7 +557,7 @@ def handle_incoming( end def handle_incoming(%{"type" => type} = data, _options) when type in ~w(Add Remove) do - with {:ok, user} <- ObjectValidator.fetch_actor(data), + with {:ok, %User{} = user} <- ObjectValidator.fetch_actor(data), %Object{} <- Object.normalize(data["object"], fetch: true) do # Mastodon sends pin/unpin objects without id, to, cc fields data = @@ -566,7 +566,10 @@ def handle_incoming(%{"type" => type} = data, _options) when type in ~w(Add Remo |> Map.put_new("to", [Pleroma.Constants.as_public()]) |> Map.put_new("cc", [user.follower_address]) - case Pipeline.common_pipeline(data, local: false) do + case Pipeline.common_pipeline(data, + local: false, + featured_address: user.featured_address + ) do {:ok, activity, _meta} -> {:ok, activity} error -> error end diff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex index d35a0f219..175d690cc 100644 --- a/lib/pleroma/web/common_api.ex +++ b/lib/pleroma/web/common_api.ex @@ -412,14 +412,18 @@ def post(user, %{status: _} = data) do end @spec pin(String.t(), User.t()) :: {:ok, Activity.t()} | {:error, term()} - def pin(id, %User{ap_id: actor} = user) do + def pin(id, %User{} = user) do with %Activity{} = activity <- create_activity_by_id(id), - true <- activity_belongs_to_actor(activity, actor), + true <- activity_belongs_to_actor(activity, user.ap_id), true <- object_type_is_allowed_for_pin(activity.object), true <- activity_is_public(activity), {:ok, pin_data, _} <- Builder.pin(user, activity.object), {:ok, _pin, _} <- - Pipeline.common_pipeline(pin_data, local: true, activity_id: id) do + Pipeline.common_pipeline(pin_data, + local: true, + activity_id: id, + featured_address: user.featured_address + ) do {:ok, activity} else {:error, {:execute_side_effects, error}} -> error @@ -456,7 +460,8 @@ def unpin(id, user) do Pipeline.common_pipeline(unpin_data, local: true, activity_id: activity.id, - expires_at: activity.data["expires_at"] + expires_at: activity.data["expires_at"], + featured_address: user.featured_address ) do {:ok, activity} end diff --git a/test/fixtures/users_mock/user.json b/test/fixtures/users_mock/user.json index da2483d02..c722a1145 100644 --- a/test/fixtures/users_mock/user.json +++ b/test/fixtures/users_mock/user.json @@ -34,6 +34,7 @@ "owner": "https://example.com/users/{{nickname}}", "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5DLtwGXNZElJyxFGfcVc\nXANhaMadj/iYYQwZjOJTV9QsbtiNBeIK54PJrYuU0/0YIdrvS1iqheX5IwXRhcwa\nhm3ZyLz7XeN9st7FBni4BmZMBtMpxAuYuu5p/jbWy13qAiYOhPreCx0wrWgm/lBD\n9mkgaxIxPooBE0S4ZWEJIDIV1Vft3AWcRUyWW1vIBK0uZzs6GYshbQZB952S0yo4\nFzI1hABGHncH8UvuFauh4EZ8tY7/X5I0pGRnDOcRN1dAht5w5yTA+6r5kebiFQjP\nIzN/eCO/a9Flrj9YGW7HDNtjSOH0A31PLRGlJtJO3yK57dnf5ppyCZGfL4emShQo\ncQIDAQAB\n-----END PUBLIC KEY-----\n\n" }, + "featured": "https://example.com/users/{{nickname}}/collections/featured", "summary": "your friendly neighborhood pleroma developer<br>I like cute things and distributed systems, and really hate delete and redrafts", "tag": [], "type": "Person", diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index d9fa25d94..cea4b3a97 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -673,6 +673,17 @@ test "accepts Add/Remove activities", %{conn: conn} do body: user, headers: [{"content-type", "application/activity+json"}] } + + %{method: :get, url: "https://example.com/users/lain/collections/featured"} -> + %Tesla.Env{ + status: 200, + body: + "test/fixtures/users_mock/masto_featured.json" + |> File.read!() + |> String.replace("{{domain}}", "example.com") + |> String.replace("{{nickname}}", "lain"), + headers: [{"content-type", "application/activity+json"}] + } end) data = %{ @@ -753,6 +764,17 @@ test "mastodon pin/unpin", %{conn: conn} do body: user, headers: [{"content-type", "application/activity+json"}] } + + %{method: :get, url: "https://example.com/users/lain/collections/featured"} -> + %Tesla.Env{ + status: 200, + body: + "test/fixtures/users_mock/masto_featured.json" + |> File.read!() + |> String.replace("{{domain}}", "example.com") + |> String.replace("{{nickname}}", "lain"), + headers: [{"content-type", "application/activity+json"}] + } end) data = %{ diff --git a/test/pleroma/web/activity_pub/transmogrifier_test.exs b/test/pleroma/web/activity_pub/transmogrifier_test.exs index 9bc27f89e..fb8284aaf 100644 --- a/test/pleroma/web/activity_pub/transmogrifier_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier_test.exs @@ -147,6 +147,17 @@ test "it accepts Add/Remove activities" do body: object, headers: [{"content-type", "application/activity+json"}] } + + %{method: :get, url: "https://example.com/users/lain/collections/featured"} -> + %Tesla.Env{ + status: 200, + body: + "test/fixtures/users_mock/masto_featured.json" + |> File.read!() + |> String.replace("{{domain}}", "example.com") + |> String.replace("{{nickname}}", "lain"), + headers: [{"content-type", "application/activity+json"}] + } end) message = %{ From 3adb43cc20751540ea590645b31b985807684202 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Wed, 3 Mar 2021 18:04:06 +0300 Subject: [PATCH 101/339] refetch user on incoming add/remove activity if featured_address is nil --- .../web/activity_pub/transmogrifier.ex | 8 ++ .../web/mastodon_api/views/status_view.ex | 2 +- .../web/activity_pub/transmogrifier_test.exs | 78 +++++++++++++++++++ test/support/factory.ex | 4 +- 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index fa62e0db2..c4b11a655 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -558,6 +558,8 @@ def handle_incoming( def handle_incoming(%{"type" => type} = data, _options) when type in ~w(Add Remove) do with {:ok, %User{} = user} <- ObjectValidator.fetch_actor(data), + # maybe locally user doesn't have featured_address + {:ok, user} <- maybe_refetch_user(user), %Object{} <- Object.normalize(data["object"], fetch: true) do # Mastodon sends pin/unpin objects without id, to, cc fields data = @@ -669,6 +671,12 @@ def handle_incoming( def handle_incoming(_, _), do: :error + defp maybe_refetch_user(%User{featured_address: address} = user) when is_binary(address) do + {:ok, user} + end + + defp maybe_refetch_user(%User{ap_id: ap_id}), do: upgrade_user_from_ap_id(ap_id) + @spec get_obj_helper(String.t(), Keyword.t()) :: {:ok, Object.t()} | nil def get_obj_helper(id, options \\ []) do options = Keyword.put(options, :fetch, true) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index d0247fa4a..814b3d142 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -152,7 +152,7 @@ def render( |> Enum.filter(& &1) |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end) - {pinned?, pinned_at} = pin_data(activity_object, user) + {pinned?, pinned_at} = pin_data(object, user) %{ id: to_string(activity.id), diff --git a/test/pleroma/web/activity_pub/transmogrifier_test.exs b/test/pleroma/web/activity_pub/transmogrifier_test.exs index fb8284aaf..07ed3920f 100644 --- a/test/pleroma/web/activity_pub/transmogrifier_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier_test.exs @@ -191,6 +191,84 @@ test "it accepts Add/Remove activities" do user = refresh_record(user) refute user.pinned_objects[object_url] end + + test "Add/Remove activities for remote users without featured address" do + user = insert(:user, local: false, domain: "example.com") + + user = + user + |> Ecto.Changeset.change(featured_address: nil) + |> Repo.update!() + + %{host: host} = URI.parse(user.ap_id) + + user_data = + "test/fixtures/users_mock/user.json" + |> File.read!() + |> String.replace("{{nickname}}", user.nickname) + + object_id = "c61d6733-e256-4fe1-ab13-1e369789423f" + + object = + "test/fixtures/statuses/note.json" + |> File.read!() + |> String.replace("{{nickname}}", user.nickname) + |> String.replace("{{object_id}}", object_id) + + object_url = "https://#{host}/objects/#{object_id}" + + actor = "https://#{host}/users/#{user.nickname}" + + featured = "https://#{host}/users/#{user.nickname}/collections/featured" + + Tesla.Mock.mock(fn + %{ + method: :get, + url: ^actor + } -> + %Tesla.Env{ + status: 200, + body: user_data, + headers: [{"content-type", "application/activity+json"}] + } + + %{ + method: :get, + url: ^object_url + } -> + %Tesla.Env{ + status: 200, + body: object, + headers: [{"content-type", "application/activity+json"}] + } + + %{method: :get, url: ^featured} -> + %Tesla.Env{ + status: 200, + body: + "test/fixtures/users_mock/masto_featured.json" + |> File.read!() + |> String.replace("{{domain}}", "#{host}") + |> String.replace("{{nickname}}", user.nickname), + headers: [{"content-type", "application/activity+json"}] + } + end) + + message = %{ + "id" => "https://#{host}/objects/d61d6733-e256-4fe1-ab13-1e369789423f", + "actor" => actor, + "object" => object_url, + "target" => "https://#{host}/users/#{user.nickname}/collections/featured", + "type" => "Add", + "to" => [Pleroma.Constants.as_public()], + "cc" => ["https://#{host}/users/#{user.nickname}/followers"] + } + + assert {:ok, activity} = Transmogrifier.handle_incoming(message) + assert activity.data == message + user = User.get_cached_by_ap_id(actor) + assert user.pinned_objects[object_url] + end end describe "prepare outgoing" do diff --git a/test/support/factory.ex b/test/support/factory.ex index 883cedf3c..867076d6a 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -41,7 +41,7 @@ def user_factory(attrs \\ %{}) do urls = if attrs[:local] == false do - base_domain = Enum.random(["domain1.com", "domain2.com", "domain3.com"]) + base_domain = attrs[:domain] || Enum.random(["domain1.com", "domain2.com", "domain3.com"]) ap_id = "https://#{base_domain}/users/#{user.nickname}" @@ -60,6 +60,8 @@ def user_factory(attrs \\ %{}) do } end + attrs = Map.delete(attrs, :domain) + user |> Map.put(:raw_bio, user.bio) |> Map.merge(urls) From 16c96966e9f7a039a969c06bdd6c4e18ab8d432c Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Tue, 9 Mar 2021 08:59:50 +0300 Subject: [PATCH 102/339] not needed --- test/fixtures/masto_pin.json | 41 ------------------------------------ 1 file changed, 41 deletions(-) delete mode 100644 test/fixtures/masto_pin.json diff --git a/test/fixtures/masto_pin.json b/test/fixtures/masto_pin.json deleted file mode 100644 index e57a34375..000000000 --- a/test/fixtures/masto_pin.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1", - { - "Emoji": "toot:Emoji", - "Hashtag": "as:Hashtag", - "PropertyValue": "schema:PropertyValue", - "alsoKnownAs": { - "@id": "as:alsoKnownAs", - "@type": "@id" - }, - "atomUri": "ostatus:atomUri", - "conversation": "ostatus:conversation", - "featured": { - "@id": "toot:featured", - "@type": "@id" - }, - "focalPoint": { - "@container": "@list", - "@id": "toot:focalPoint" - }, - "inReplyToAtomUri": "ostatus:inReplyToAtomUri", - "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", - "movedTo": { - "@id": "as:movedTo", - "@type": "@id" - }, - "ostatus": "http://ostatus.org#", - "schema": "http://schema.org#", - "sensitive": "as:sensitive", - "toot": "http://joinmastodon.org/ns#", - "value": "schema:value" - } - ], - "id": "https://example.com/users/nickname/statuses/{{id}}", - "actor": "https://example.com/users/nickname", - "object": "https://example.com/users/nickname/statuses/101355175004496751", - "target": "https://example.com/users/nickname/collections/featured", - "type": "{{type}}" -} From 8f0778166c2e7c76975d14937ef61c05d399b560 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Tue, 9 Mar 2021 09:00:20 +0300 Subject: [PATCH 103/339] moving fixture into mastodon folder --- test/fixtures/{ => mastodon}/collections/featured.json | 0 test/pleroma/web/activity_pub/activity_pub_test.exs | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename test/fixtures/{ => mastodon}/collections/featured.json (100%) diff --git a/test/fixtures/collections/featured.json b/test/fixtures/mastodon/collections/featured.json similarity index 100% rename from test/fixtures/collections/featured.json rename to test/fixtures/mastodon/collections/featured.json diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index 081d00d45..64e12066e 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -252,7 +252,7 @@ test "fetches user featured collection" do object_id = Ecto.UUID.generate() featured_data = - "test/fixtures/collections/featured.json" + "test/fixtures/mastodon/collections/featured.json" |> File.read!() |> String.replace("{{domain}}", "example.com") |> String.replace("{{nickname}}", "lain") From 5ae9b05600dd3dffc628ba25fe01b271f7bc0122 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Tue, 9 Mar 2021 09:00:44 +0300 Subject: [PATCH 104/339] separate test file for featured collection --- .../add_remove_handling_test.exs | 172 ++++++++++++++++++ .../web/activity_pub/transmogrifier_test.exs | 163 ----------------- 2 files changed, 172 insertions(+), 163 deletions(-) create mode 100644 test/pleroma/web/activity_pub/transmogrifier/add_remove_handling_test.exs diff --git a/test/pleroma/web/activity_pub/transmogrifier/add_remove_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/add_remove_handling_test.exs new file mode 100644 index 000000000..fc7757125 --- /dev/null +++ b/test/pleroma/web/activity_pub/transmogrifier/add_remove_handling_test.exs @@ -0,0 +1,172 @@ +defmodule Pleroma.Web.ActivityPub.Transmogrifier.AddRemoveHandlingTest do + use Oban.Testing, repo: Pleroma.Repo + use Pleroma.DataCase, async: true + + require Pleroma.Constants + + import Pleroma.Factory + + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Transmogrifier + + test "it accepts Add/Remove activities" do + user = + "test/fixtures/users_mock/user.json" + |> File.read!() + |> String.replace("{{nickname}}", "lain") + + object_id = "c61d6733-e256-4fe1-ab13-1e369789423f" + + object = + "test/fixtures/statuses/note.json" + |> File.read!() + |> String.replace("{{nickname}}", "lain") + |> String.replace("{{object_id}}", object_id) + + object_url = "https://example.com/objects/#{object_id}" + + actor = "https://example.com/users/lain" + + Tesla.Mock.mock(fn + %{ + method: :get, + url: ^actor + } -> + %Tesla.Env{ + status: 200, + body: user, + headers: [{"content-type", "application/activity+json"}] + } + + %{ + method: :get, + url: ^object_url + } -> + %Tesla.Env{ + status: 200, + body: object, + headers: [{"content-type", "application/activity+json"}] + } + + %{method: :get, url: "https://example.com/users/lain/collections/featured"} -> + %Tesla.Env{ + status: 200, + body: + "test/fixtures/users_mock/masto_featured.json" + |> File.read!() + |> String.replace("{{domain}}", "example.com") + |> String.replace("{{nickname}}", "lain"), + headers: [{"content-type", "application/activity+json"}] + } + end) + + message = %{ + "id" => "https://example.com/objects/d61d6733-e256-4fe1-ab13-1e369789423f", + "actor" => actor, + "object" => object_url, + "target" => "https://example.com/users/lain/collections/featured", + "type" => "Add", + "to" => [Pleroma.Constants.as_public()], + "cc" => ["https://example.com/users/lain/followers"] + } + + assert {:ok, activity} = Transmogrifier.handle_incoming(message) + assert activity.data == message + user = User.get_cached_by_ap_id(actor) + assert user.pinned_objects[object_url] + + remove = %{ + "id" => "http://localhost:400/objects/d61d6733-e256-4fe1-ab13-1e369789423d", + "actor" => actor, + "object" => object_url, + "target" => "https://example.com/users/lain/collections/featured", + "type" => "Remove", + "to" => [Pleroma.Constants.as_public()], + "cc" => ["https://example.com/users/lain/followers"] + } + + assert {:ok, activity} = Transmogrifier.handle_incoming(remove) + assert activity.data == remove + + user = refresh_record(user) + refute user.pinned_objects[object_url] + end + + test "Add/Remove activities for remote users without featured address" do + user = insert(:user, local: false, domain: "example.com") + + user = + user + |> Ecto.Changeset.change(featured_address: nil) + |> Repo.update!() + + %{host: host} = URI.parse(user.ap_id) + + user_data = + "test/fixtures/users_mock/user.json" + |> File.read!() + |> String.replace("{{nickname}}", user.nickname) + + object_id = "c61d6733-e256-4fe1-ab13-1e369789423f" + + object = + "test/fixtures/statuses/note.json" + |> File.read!() + |> String.replace("{{nickname}}", user.nickname) + |> String.replace("{{object_id}}", object_id) + + object_url = "https://#{host}/objects/#{object_id}" + + actor = "https://#{host}/users/#{user.nickname}" + + featured = "https://#{host}/users/#{user.nickname}/collections/featured" + + Tesla.Mock.mock(fn + %{ + method: :get, + url: ^actor + } -> + %Tesla.Env{ + status: 200, + body: user_data, + headers: [{"content-type", "application/activity+json"}] + } + + %{ + method: :get, + url: ^object_url + } -> + %Tesla.Env{ + status: 200, + body: object, + headers: [{"content-type", "application/activity+json"}] + } + + %{method: :get, url: ^featured} -> + %Tesla.Env{ + status: 200, + body: + "test/fixtures/users_mock/masto_featured.json" + |> File.read!() + |> String.replace("{{domain}}", "#{host}") + |> String.replace("{{nickname}}", user.nickname), + headers: [{"content-type", "application/activity+json"}] + } + end) + + message = %{ + "id" => "https://#{host}/objects/d61d6733-e256-4fe1-ab13-1e369789423f", + "actor" => actor, + "object" => object_url, + "target" => "https://#{host}/users/#{user.nickname}/collections/featured", + "type" => "Add", + "to" => [Pleroma.Constants.as_public()], + "cc" => ["https://#{host}/users/#{user.nickname}/followers"] + } + + assert {:ok, activity} = Transmogrifier.handle_incoming(message) + assert activity.data == message + user = User.get_cached_by_ap_id(actor) + assert user.pinned_objects[object_url] + end +end diff --git a/test/pleroma/web/activity_pub/transmogrifier_test.exs b/test/pleroma/web/activity_pub/transmogrifier_test.exs index 07ed3920f..4c3fcb44a 100644 --- a/test/pleroma/web/activity_pub/transmogrifier_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier_test.exs @@ -6,8 +6,6 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do use Oban.Testing, repo: Pleroma.Repo use Pleroma.DataCase - require Pleroma.Constants - alias Pleroma.Activity alias Pleroma.Object alias Pleroma.Tests.ObanHelpers @@ -108,167 +106,6 @@ test "it accepts Move activities" do assert activity.data["target"] == new_user.ap_id assert activity.data["type"] == "Move" end - - test "it accepts Add/Remove activities" do - user = - "test/fixtures/users_mock/user.json" - |> File.read!() - |> String.replace("{{nickname}}", "lain") - - object_id = "c61d6733-e256-4fe1-ab13-1e369789423f" - - object = - "test/fixtures/statuses/note.json" - |> File.read!() - |> String.replace("{{nickname}}", "lain") - |> String.replace("{{object_id}}", object_id) - - object_url = "https://example.com/objects/#{object_id}" - - actor = "https://example.com/users/lain" - - Tesla.Mock.mock(fn - %{ - method: :get, - url: ^actor - } -> - %Tesla.Env{ - status: 200, - body: user, - headers: [{"content-type", "application/activity+json"}] - } - - %{ - method: :get, - url: ^object_url - } -> - %Tesla.Env{ - status: 200, - body: object, - headers: [{"content-type", "application/activity+json"}] - } - - %{method: :get, url: "https://example.com/users/lain/collections/featured"} -> - %Tesla.Env{ - status: 200, - body: - "test/fixtures/users_mock/masto_featured.json" - |> File.read!() - |> String.replace("{{domain}}", "example.com") - |> String.replace("{{nickname}}", "lain"), - headers: [{"content-type", "application/activity+json"}] - } - end) - - message = %{ - "id" => "https://example.com/objects/d61d6733-e256-4fe1-ab13-1e369789423f", - "actor" => actor, - "object" => object_url, - "target" => "https://example.com/users/lain/collections/featured", - "type" => "Add", - "to" => [Pleroma.Constants.as_public()], - "cc" => ["https://example.com/users/lain/followers"] - } - - assert {:ok, activity} = Transmogrifier.handle_incoming(message) - assert activity.data == message - user = User.get_cached_by_ap_id(actor) - assert user.pinned_objects[object_url] - - remove = %{ - "id" => "http://localhost:400/objects/d61d6733-e256-4fe1-ab13-1e369789423d", - "actor" => actor, - "object" => object_url, - "target" => "https://example.com/users/lain/collections/featured", - "type" => "Remove", - "to" => [Pleroma.Constants.as_public()], - "cc" => ["https://example.com/users/lain/followers"] - } - - assert {:ok, activity} = Transmogrifier.handle_incoming(remove) - assert activity.data == remove - - user = refresh_record(user) - refute user.pinned_objects[object_url] - end - - test "Add/Remove activities for remote users without featured address" do - user = insert(:user, local: false, domain: "example.com") - - user = - user - |> Ecto.Changeset.change(featured_address: nil) - |> Repo.update!() - - %{host: host} = URI.parse(user.ap_id) - - user_data = - "test/fixtures/users_mock/user.json" - |> File.read!() - |> String.replace("{{nickname}}", user.nickname) - - object_id = "c61d6733-e256-4fe1-ab13-1e369789423f" - - object = - "test/fixtures/statuses/note.json" - |> File.read!() - |> String.replace("{{nickname}}", user.nickname) - |> String.replace("{{object_id}}", object_id) - - object_url = "https://#{host}/objects/#{object_id}" - - actor = "https://#{host}/users/#{user.nickname}" - - featured = "https://#{host}/users/#{user.nickname}/collections/featured" - - Tesla.Mock.mock(fn - %{ - method: :get, - url: ^actor - } -> - %Tesla.Env{ - status: 200, - body: user_data, - headers: [{"content-type", "application/activity+json"}] - } - - %{ - method: :get, - url: ^object_url - } -> - %Tesla.Env{ - status: 200, - body: object, - headers: [{"content-type", "application/activity+json"}] - } - - %{method: :get, url: ^featured} -> - %Tesla.Env{ - status: 200, - body: - "test/fixtures/users_mock/masto_featured.json" - |> File.read!() - |> String.replace("{{domain}}", "#{host}") - |> String.replace("{{nickname}}", user.nickname), - headers: [{"content-type", "application/activity+json"}] - } - end) - - message = %{ - "id" => "https://#{host}/objects/d61d6733-e256-4fe1-ab13-1e369789423f", - "actor" => actor, - "object" => object_url, - "target" => "https://#{host}/users/#{user.nickname}/collections/featured", - "type" => "Add", - "to" => [Pleroma.Constants.as_public()], - "cc" => ["https://#{host}/users/#{user.nickname}/followers"] - } - - assert {:ok, activity} = Transmogrifier.handle_incoming(message) - assert activity.data == message - user = User.get_cached_by_ap_id(actor) - assert user.pinned_objects[object_url] - end end describe "prepare outgoing" do From 8857242c952dcac0bc5363e1c80160efaf7a1638 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Tue, 9 Mar 2021 11:57:20 +0300 Subject: [PATCH 105/339] removeing corresponding add activity --- lib/pleroma/activity.ex | 9 +++++ lib/pleroma/web/activity_pub/side_effects.ex | 5 +++ test/pleroma/activity_test.exs | 22 ++++++++++ test/support/factory.ex | 42 ++++++++++++++++++++ 4 files changed, 78 insertions(+) diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index a4cfca4c5..53beca5e6 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -391,4 +391,13 @@ def get_by_object_ap_id_with_object(ap_id) when is_binary(ap_id) do end def get_by_object_ap_id_with_object(_), do: nil + + @spec add_by_params_query(String.t(), String.t(), String.t()) :: Ecto.Query.t() + def add_by_params_query(object_id, actor, target) do + object_id + |> Queries.by_object_id() + |> Queries.by_type("Add") + |> Queries.by_actor(actor) + |> where([a], fragment("?->>'target' = ?", a.data, ^target)) + end end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 9d22f9d3c..5fe143c2b 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -340,11 +340,16 @@ def handle(%{data: %{"type" => "Add"} = data} = object, meta) do # Tasks this handles: # - removes pin from user + # - removes corresponding Add activity # - if activity had expiration, recreates activity expiration job @impl true def handle(%{data: %{"type" => "Remove"} = data} = object, meta) do with %User{} = user <- User.get_cached_by_ap_id(data["actor"]), {:ok, _user} <- User.remove_pinned_object_id(user, data["object"]) do + data["object"] + |> Activity.add_by_params_query(user.ap_id, user.featured_address) + |> Repo.delete_all() + # if pinned activity was scheduled for deletion, we reschedule it for deletion if meta[:expires_at] do # MRF.ActivityExpirationPolicy used UTC timestamps for expires_at in original implementation diff --git a/test/pleroma/activity_test.exs b/test/pleroma/activity_test.exs index 390a06344..962bc7e45 100644 --- a/test/pleroma/activity_test.exs +++ b/test/pleroma/activity_test.exs @@ -254,4 +254,26 @@ test "get_by_object_ap_id_with_object/1" do assert %{id: ^id} = Activity.get_by_object_ap_id_with_object(obj_id) end + + test "add_by_params_query/3" do + user = insert(:user) + + note = insert(:note_activity, user: user) + + insert(:add_activity, user: user, note: note) + insert(:add_activity, user: user, note: note) + insert(:add_activity, user: user) + + assert Repo.aggregate(Activity, :count, :id) == 4 + + add_query = + Activity.add_by_params_query(note.data["object"], user.ap_id, user.featured_address) + + assert Repo.aggregate(add_query, :count, :id) == 2 + + Repo.delete_all(add_query) + assert Repo.aggregate(add_query, :count, :id) == 0 + + assert Repo.aggregate(Activity, :count, :id) == 2 + end end diff --git a/test/support/factory.ex b/test/support/factory.ex index 867076d6a..5c4e65c81 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -4,6 +4,9 @@ defmodule Pleroma.Factory do use ExMachina.Ecto, repo: Pleroma.Repo + + require Pleroma.Constants + alias Pleroma.Object alias Pleroma.User @@ -225,6 +228,45 @@ def direct_note_activity_factory do } end + def add_activity_factory(attrs \\ %{}) do + featured_collection_activity(attrs, "Add") + end + + def remove_activity_factor(attrs \\ %{}) do + featured_collection_activity(attrs, "Remove") + end + + defp featured_collection_activity(attrs, type) do + user = attrs[:user] || insert(:user) + note = attrs[:note] || insert(:note, user: user) + + data_attrs = + attrs + |> Map.get(:data_attrs, %{}) + |> Map.put(:type, type) + + attrs = Map.drop(attrs, [:user, :note, :data_attrs]) + + data = + %{ + "id" => Pleroma.Web.ActivityPub.Utils.generate_activity_id(), + "target" => user.featured_address, + "object" => note.data["object"], + "actor" => note.data["actor"], + "type" => "Add", + "to" => [Pleroma.Constants.as_public()], + "cc" => [user.follower_address] + } + |> Map.merge(data_attrs) + + %Pleroma.Activity{ + data: data, + actor: data["actor"], + recipients: data["to"] + } + |> Map.merge(attrs) + end + def note_activity_factory(attrs \\ %{}) do user = attrs[:user] || insert(:user) note = attrs[:note] || insert(:note, user: user) From 2a520ba008f432e7e1fa297954966e0181245f01 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Fri, 19 Mar 2021 17:25:12 +0300 Subject: [PATCH 106/339] expanding AddRemoveValidator --- .../web/activity_pub/object_validator.ex | 2 +- .../object_validators/add_remove_validator.ex | 26 ++++++++++++++----- .../web/activity_pub/transmogrifier.ex | 22 ++++------------ lib/pleroma/web/common_api.ex | 3 +-- 4 files changed, 27 insertions(+), 26 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 3ca9136aa..14c3e8531 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -238,7 +238,7 @@ def validate(%{"type" => "Announce"} = object, meta) do def validate(%{"type" => type} = object, meta) when type in ~w(Add Remove) do with {:ok, object} <- object - |> AddRemoveValidator.cast_and_validate(meta) + |> AddRemoveValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do object = stringify_keys(object) {:ok, object, meta} diff --git a/lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex b/lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex index 885282f32..c38f86a0e 100644 --- a/lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex @@ -8,6 +8,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AddRemoveValidator do import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + require Pleroma.Constants + alias Pleroma.EctoType.ActivityPub.ObjectValidators @primary_key false @@ -22,28 +24,40 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AddRemoveValidator do field(:cc, ObjectValidators.Recipients, default: []) end - def cast_and_validate(data, meta) do + def cast_and_validate(data) do data + |> maybe_fix_data_for_mastodon() |> cast_data() - |> validate_data(meta) + |> validate_data() + end + + defp maybe_fix_data_for_mastodon(data) do + {:ok, actor} = Pleroma.User.get_or_fetch_by_ap_id(data["actor"]) + # Mastodon sends pin/unpin objects without id, to, cc fields + data + |> Map.put_new("id", Pleroma.Web.ActivityPub.Utils.generate_activity_id()) + |> Map.put_new("to", [Pleroma.Constants.as_public()]) + |> Map.put_new("cc", [actor.follower_address]) end defp cast_data(data) do cast(%__MODULE__{}, data, __schema__(:fields)) end - defp validate_data(changeset, meta) do + defp validate_data(changeset) do changeset |> validate_required([:id, :target, :object, :actor, :type, :to, :cc]) |> validate_inclusion(:type, ~w(Add Remove)) |> validate_actor_presence() - |> validate_collection_belongs_to_actor(meta) + |> validate_collection_belongs_to_actor() |> validate_object_presence() end - defp validate_collection_belongs_to_actor(changeset, meta) do + defp validate_collection_belongs_to_actor(changeset) do + {:ok, actor} = Pleroma.User.get_or_fetch_by_ap_id(changeset.changes[:actor]) + validate_change(changeset, :target, fn :target, target -> - if target == meta[:featured_address] do + if target == actor.featured_address do [] else [target: "collection doesn't belong to actor"] diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index c4b11a655..2172e7736 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -557,24 +557,12 @@ def handle_incoming( end def handle_incoming(%{"type" => type} = data, _options) when type in ~w(Add Remove) do - with {:ok, %User{} = user} <- ObjectValidator.fetch_actor(data), + with :ok <- ObjectValidator.fetch_actor_and_object(data), + {:ok, actor} <- Pleroma.User.get_or_fetch_by_ap_id(data["actor"]), # maybe locally user doesn't have featured_address - {:ok, user} <- maybe_refetch_user(user), - %Object{} <- Object.normalize(data["object"], fetch: true) do - # Mastodon sends pin/unpin objects without id, to, cc fields - data = - data - |> Map.put_new("id", Utils.generate_activity_id()) - |> Map.put_new("to", [Pleroma.Constants.as_public()]) - |> Map.put_new("cc", [user.follower_address]) - - case Pipeline.common_pipeline(data, - local: false, - featured_address: user.featured_address - ) do - {:ok, activity, _meta} -> {:ok, activity} - error -> error - end + {:ok, _} <- maybe_refetch_user(actor), + {:ok, activity, _meta} <- Pipeline.common_pipeline(data, local: false) do + {:ok, activity} end end diff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex index 175d690cc..b36be4d2a 100644 --- a/lib/pleroma/web/common_api.ex +++ b/lib/pleroma/web/common_api.ex @@ -421,8 +421,7 @@ def pin(id, %User{} = user) do {:ok, _pin, _} <- Pipeline.common_pipeline(pin_data, local: true, - activity_id: id, - featured_address: user.featured_address + activity_id: id ) do {:ok, activity} else From 1885268c9c242aca2a51bd15ed839bd65d6a52dc Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Thu, 25 Mar 2021 13:26:54 +0300 Subject: [PATCH 107/339] expanding validator --- .../object_validators/add_remove_validator.ex | 28 +++++++++++++------ .../web/activity_pub/transmogrifier.ex | 18 +----------- 2 files changed, 20 insertions(+), 26 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex b/lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex index c38f86a0e..f885aabe4 100644 --- a/lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/add_remove_validator.ex @@ -11,6 +11,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AddRemoveValidator do require Pleroma.Constants alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.User @primary_key false @@ -25,14 +26,17 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AddRemoveValidator do end def cast_and_validate(data) do + {:ok, actor} = User.get_or_fetch_by_ap_id(data["actor"]) + + {:ok, actor} = maybe_refetch_user(actor) + data - |> maybe_fix_data_for_mastodon() + |> maybe_fix_data_for_mastodon(actor) |> cast_data() - |> validate_data() + |> validate_data(actor) end - defp maybe_fix_data_for_mastodon(data) do - {:ok, actor} = Pleroma.User.get_or_fetch_by_ap_id(data["actor"]) + defp maybe_fix_data_for_mastodon(data, actor) do # Mastodon sends pin/unpin objects without id, to, cc fields data |> Map.put_new("id", Pleroma.Web.ActivityPub.Utils.generate_activity_id()) @@ -44,18 +48,16 @@ defp cast_data(data) do cast(%__MODULE__{}, data, __schema__(:fields)) end - defp validate_data(changeset) do + defp validate_data(changeset, actor) do changeset |> validate_required([:id, :target, :object, :actor, :type, :to, :cc]) |> validate_inclusion(:type, ~w(Add Remove)) |> validate_actor_presence() - |> validate_collection_belongs_to_actor() + |> validate_collection_belongs_to_actor(actor) |> validate_object_presence() end - defp validate_collection_belongs_to_actor(changeset) do - {:ok, actor} = Pleroma.User.get_or_fetch_by_ap_id(changeset.changes[:actor]) - + defp validate_collection_belongs_to_actor(changeset, actor) do validate_change(changeset, :target, fn :target, target -> if target == actor.featured_address do [] @@ -64,4 +66,12 @@ defp validate_collection_belongs_to_actor(changeset) do end end) end + + defp maybe_refetch_user(%User{featured_address: address} = user) when is_binary(address) do + {:ok, user} + end + + defp maybe_refetch_user(%User{ap_id: ap_id}) do + Pleroma.Web.ActivityPub.Transmogrifier.upgrade_user_from_ap_id(ap_id) + end end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 2172e7736..c4caeff0a 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -534,7 +534,7 @@ def handle_incoming( end def handle_incoming(%{"type" => type} = data, _options) - when type in ~w{Like EmojiReact Announce} do + when type in ~w{Like EmojiReact Announce Add Remove} do with :ok <- ObjectValidator.fetch_actor_and_object(data), {:ok, activity, _meta} <- Pipeline.common_pipeline(data, local: false) do @@ -556,16 +556,6 @@ def handle_incoming( end end - def handle_incoming(%{"type" => type} = data, _options) when type in ~w(Add Remove) do - with :ok <- ObjectValidator.fetch_actor_and_object(data), - {:ok, actor} <- Pleroma.User.get_or_fetch_by_ap_id(data["actor"]), - # maybe locally user doesn't have featured_address - {:ok, _} <- maybe_refetch_user(actor), - {:ok, activity, _meta} <- Pipeline.common_pipeline(data, local: false) do - {:ok, activity} - end - end - def handle_incoming( %{"type" => "Delete"} = data, _options @@ -659,12 +649,6 @@ def handle_incoming( def handle_incoming(_, _), do: :error - defp maybe_refetch_user(%User{featured_address: address} = user) when is_binary(address) do - {:ok, user} - end - - defp maybe_refetch_user(%User{ap_id: ap_id}), do: upgrade_user_from_ap_id(ap_id) - @spec get_obj_helper(String.t(), Keyword.t()) :: {:ok, Object.t()} | nil def get_obj_helper(id, options \\ []) do options = Keyword.put(options, :fetch, true) From 6e108b8603de45d489d4aef7e3e271bc5e8c431d Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Fri, 26 Mar 2021 19:19:19 +0300 Subject: [PATCH 108/339] reading the file, instead of config keyword --- lib/pleroma/config/release_runtime_provider.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/config/release_runtime_provider.ex b/lib/pleroma/config/release_runtime_provider.ex index 8227195dc..70ef3bcc1 100644 --- a/lib/pleroma/config/release_runtime_provider.ex +++ b/lib/pleroma/config/release_runtime_provider.ex @@ -39,7 +39,7 @@ def load(config, _opts) do with_exported = if File.exists?(exported_config_path) do - exported_config = Config.Reader.read!(with_runtime_config) + exported_config = Config.Reader.read!(exported_config_path) Config.Reader.merge(with_runtime_config, exported_config) else with_runtime_config From 4d046afd2769cfdc16b2ee48e8c1d8f7f8e8ffa7 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Sat, 27 Mar 2021 09:05:33 +0300 Subject: [PATCH 109/339] tests for release config provider --- .../config/release_runtime_provider.ex | 17 +++---- mix.exs | 13 +++++- .../config/temp.exported_from_db.secret.exs | 5 ++ .../config/release_runtime_provider_test.exs | 46 +++++++++++++++++++ 4 files changed, 70 insertions(+), 11 deletions(-) create mode 100644 test/fixtures/config/temp.exported_from_db.secret.exs create mode 100644 test/pleroma/config/release_runtime_provider_test.exs diff --git a/lib/pleroma/config/release_runtime_provider.ex b/lib/pleroma/config/release_runtime_provider.ex index 70ef3bcc1..46fa35559 100644 --- a/lib/pleroma/config/release_runtime_provider.ex +++ b/lib/pleroma/config/release_runtime_provider.ex @@ -1,6 +1,6 @@ defmodule Pleroma.Config.ReleaseRuntimeProvider do @moduledoc """ - Imports `runtime.exs` and `{env}.exported_from_db.secret.exs` for elixir releases. + Imports runtime config and `{env}.exported_from_db.secret.exs` for releases. """ @behaviour Config.Provider @@ -8,13 +8,13 @@ defmodule Pleroma.Config.ReleaseRuntimeProvider do def init(opts), do: opts @impl true - def load(config, _opts) do + def load(config, opts) do with_defaults = Config.Reader.merge(config, Pleroma.Config.Holder.release_defaults()) - config_path = System.get_env("PLEROMA_CONFIG_PATH") || "/etc/pleroma/config.exs" + config_path = opts[:config_path] with_runtime_config = - if File.exists?(config_path) do + if config_path && File.exists?(config_path) do runtime_config = Config.Reader.read!(config_path) with_defaults @@ -24,7 +24,7 @@ def load(config, _opts) do warning = [ IO.ANSI.red(), IO.ANSI.bright(), - "!!! #{config_path} not found! Please ensure it exists and that PLEROMA_CONFIG_PATH is unset or points to an existing file", + "!!! Config path is not declared! Please ensure it exists and that PLEROMA_CONFIG_PATH is unset or points to an existing file", IO.ANSI.reset() ] @@ -32,13 +32,10 @@ def load(config, _opts) do with_defaults end - exported_config_path = - config_path - |> Path.dirname() - |> Path.join("prod.exported_from_db.secret.exs") + exported_config_path = opts[:exported_config_path] with_exported = - if File.exists?(exported_config_path) do + if exported_config_path && File.exists?(exported_config_path) do exported_config = Config.Reader.read!(exported_config_path) Config.Reader.merge(with_runtime_config, exported_config) else diff --git a/mix.exs b/mix.exs index ae74f50a3..7328b533b 100644 --- a/mix.exs +++ b/mix.exs @@ -38,7 +38,7 @@ def project do include_executables_for: [:unix], applications: [ex_syslogger: :load, syslog: :load, eldap: :transient], steps: [:assemble, &put_otp_version/1, ©_files/1, ©_nginx_config/1], - config_providers: [{Pleroma.Config.ReleaseRuntimeProvider, nil}] + config_providers: [{Pleroma.Config.ReleaseRuntimeProvider, release_config_paths()}] ] ] ] @@ -67,6 +67,17 @@ def copy_nginx_config(%{path: target_path} = release) do release end + defp release_config_paths do + config_path = System.get_env("PLEROMA_CONFIG_PATH") || "/etc/pleroma/config.exs" + + exported_config_path = + config_path + |> Path.dirname() + |> Path.join("#{Mix.env()}.exported_from_db.secret.exs") + + [config_path: config_path, exported_config_path: exported_config_path] + end + # Configuration for the OTP application. # # Type `mix help compile.app` for more information. diff --git a/test/fixtures/config/temp.exported_from_db.secret.exs b/test/fixtures/config/temp.exported_from_db.secret.exs new file mode 100644 index 000000000..64bee7f32 --- /dev/null +++ b/test/fixtures/config/temp.exported_from_db.secret.exs @@ -0,0 +1,5 @@ +use Mix.Config + +config :pleroma, exported_config_merged: true + +config :pleroma, :first_setting, key: "new value" diff --git a/test/pleroma/config/release_runtime_provider_test.exs b/test/pleroma/config/release_runtime_provider_test.exs new file mode 100644 index 000000000..1921698c5 --- /dev/null +++ b/test/pleroma/config/release_runtime_provider_test.exs @@ -0,0 +1,46 @@ +defmodule Pleroma.Config.ReleaseRuntimeProviderTest do + use ExUnit.Case, async: true + + alias Pleroma.Config.ReleaseRuntimeProvider + + describe "load/2" do + test "loads release defaults config and warns about non-existent runtime config" do + ExUnit.CaptureIO.capture_io(fn -> + merged = ReleaseRuntimeProvider.load([], []) + assert merged == Pleroma.Config.Holder.release_defaults() + IO.inspect(merged) + end) =~ + "!!! Config path is not declared! Please ensure it exists and that PLEROMA_CONFIG_PATH is unset or points to an existing file" + end + + test "merged runtime config" do + merged = + ReleaseRuntimeProvider.load([], config_path: "test/fixtures/config/temp.secret.exs") + + assert merged[:pleroma][:first_setting] == [key: "value", key2: [Pleroma.Repo]] + assert merged[:pleroma][:second_setting] == [key: "value2", key2: ["Activity"]] + end + + test "merged exported config" do + ExUnit.CaptureIO.capture_io(fn -> + merged = + ReleaseRuntimeProvider.load([], + exported_config_path: "test/fixtures/config/temp.exported_from_db.secret.exs" + ) + + assert merged[:pleroma][:exported_config_merged] + end) =~ + "!!! Config path is not declared! Please ensure it exists and that PLEROMA_CONFIG_PATH is unset or points to an existing file" + end + + test "runtime config is merged with exported config" do + merged = + ReleaseRuntimeProvider.load([], + config_path: "test/fixtures/config/temp.secret.exs", + exported_config_path: "test/fixtures/config/temp.exported_from_db.secret.exs" + ) + + assert merged[:pleroma][:first_setting] == [key2: [Pleroma.Repo], key: "new value"] + end + end +end From 8b81d6222773180c9632b7b53ebe7f5ee19f4f65 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Thu, 8 Oct 2020 11:55:35 -0500 Subject: [PATCH 110/339] Upstream original followbot implementation --- config/config.exs | 2 + .../web/activity_pub/mrf/follow_bot_policy.ex | 41 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex diff --git a/config/config.exs b/config/config.exs index 8d1e17b42..4381068ac 100644 --- a/config/config.exs +++ b/config/config.exs @@ -409,6 +409,8 @@ threshold: 604_800, actions: [:delist, :strip_followers] +config :pleroma, :mrf_follow_bot, follower_nickname: nil + config :pleroma, :rich_media, enabled: true, ignore_hosts: [], diff --git a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex new file mode 100644 index 000000000..fb123dbd3 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex @@ -0,0 +1,41 @@ +defmodule Pleroma.Web.ActivityPub.MRF.FollowBotPolicy do + @behaviour Pleroma.Web.ActivityPub.MRF + alias Pleroma.User + alias Pleroma.Web.CommonAPI + require Logger + + @impl true + def filter(message) do + Task.start(fn -> + follower_nickname = Pleroma.Config.get([:mrf_follow_bot, :follower_nickname]) + + with %User{} = follower <- User.get_cached_by_nickname(follower_nickname), + %{"type" => "Create", "object" => %{"type" => "Note"}} <- message do + to = Map.get(message, "to", []) + cc = Map.get(message, "cc", []) + actor = [message["actor"]] + + Enum.concat([to, cc, actor]) + |> List.flatten() + |> User.get_all_by_ap_id() + |> Enum.each(fn user -> + Logger.info("Checking if #{user.nickname} can be followed") + + with false <- User.following?(follower, user), + false <- user.locked, + false <- (user.bio || "") |> String.downcase() |> String.contains?("nobot") do + Logger.info("Following #{user.nickname}") + CommonAPI.follow(follower, user) + end + end) + end + end) + + {:ok, message} + end + + @impl true + def describe do + {:ok, %{}} + end +end From fba770b3ea861d0fdf7811b61a297278a617136b Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Thu, 8 Oct 2020 12:09:31 -0500 Subject: [PATCH 111/339] Try to handle misconfiguration scenarios gracefully --- .../web/activity_pub/mrf/follow_bot_policy.ex | 55 ++++++++++++------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex index fb123dbd3..52ac9aef7 100644 --- a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex @@ -1,34 +1,49 @@ defmodule Pleroma.Web.ActivityPub.MRF.FollowBotPolicy do @behaviour Pleroma.Web.ActivityPub.MRF + alias Pleroma.Config alias Pleroma.User alias Pleroma.Web.CommonAPI require Logger @impl true def filter(message) do + with follower_nickname <- Config.get([:mrf_follow_bot, :follower_nickname]), + %User{} = follower <- User.get_cached_by_nickname(follower_nickname), + %{"type" => "Create", "object" => %{"type" => "Note"}} <- message do + try_follow(follower, message) + else + nil -> + Logger.warn( + "#{__MODULE__} skipped because of missing :mrf_follow_bot, :follower_nickname configuration or the account + does not exist." + ) + + {:ok, message} + + _ -> + {:ok, message} + end + end + + defp try_follow(follower, message) do Task.start(fn -> - follower_nickname = Pleroma.Config.get([:mrf_follow_bot, :follower_nickname]) + to = Map.get(message, "to", []) + cc = Map.get(message, "cc", []) + actor = [message["actor"]] - with %User{} = follower <- User.get_cached_by_nickname(follower_nickname), - %{"type" => "Create", "object" => %{"type" => "Note"}} <- message do - to = Map.get(message, "to", []) - cc = Map.get(message, "cc", []) - actor = [message["actor"]] + Enum.concat([to, cc, actor]) + |> List.flatten() + |> User.get_all_by_ap_id() + |> Enum.each(fn user -> + Logger.info("Checking if #{user.nickname} can be followed") - Enum.concat([to, cc, actor]) - |> List.flatten() - |> User.get_all_by_ap_id() - |> Enum.each(fn user -> - Logger.info("Checking if #{user.nickname} can be followed") - - with false <- User.following?(follower, user), - false <- user.locked, - false <- (user.bio || "") |> String.downcase() |> String.contains?("nobot") do - Logger.info("Following #{user.nickname}") - CommonAPI.follow(follower, user) - end - end) - end + with false <- User.following?(follower, user), + false <- user.locked, + false <- (user.bio || "") |> String.downcase() |> String.contains?("nobot") do + Logger.info("Following #{user.nickname}") + CommonAPI.follow(follower, user) + end + end) end) {:ok, message} From 840dc4b44ba3ea2613b1a8dc110a9008ffc618c3 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 30 Mar 2021 11:10:34 -0500 Subject: [PATCH 112/339] Document :mrf_follow_bot --- docs/configuration/cheatsheet.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 8f2c4347e..6e52cd181 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -124,6 +124,7 @@ To add configuration to your config file, you can copy it from the base config. * `Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy`: Rejects or delists posts based on their age when received. (See [`:mrf_object_age`](#mrf_object_age)). * `Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy`: Sets a default expiration on all posts made by users of the local instance. Requires `Pleroma.Workers.PurgeExpiredActivity` to be enabled for processing the scheduled delections. * `Pleroma.Web.ActivityPub.MRF.ForceBotUnlistedPolicy`: Makes all bot posts to disappear from public timelines. + * `Pleroma.Web.ActivityPub.MRF.FollowBotPolicy`: Automatically follows newly discovered users from the specified bot account. * `transparency`: Make the content of your Message Rewrite Facility settings public (via nodeinfo). * `transparency_exclusions`: Exclude specific instance names from MRF transparency. The use of the exclusions feature will be disclosed in nodeinfo as a boolean value. @@ -220,6 +221,11 @@ Notes: - The hashtags in the configuration do not have a leading `#`. - This MRF Policy is always enabled, if you want to disable it you have to set empty lists +#### :mrf_follow_bot + +* `follower_nickname`: The name of the bot account to use for following newly discovered users. + + ### :activitypub * `unfollow_blocked`: Whether blocks result in people getting unfollowed * `outgoing_blocks`: Whether to federate blocks to other instances From e78738173aefd512bbce33c12b4ee3372bdc904b Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Thu, 8 Oct 2020 12:41:01 -0500 Subject: [PATCH 113/339] Enforce that the followbot must be marked as a bot. --- lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex index 52ac9aef7..d10b7b480 100644 --- a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex @@ -8,14 +8,15 @@ defmodule Pleroma.Web.ActivityPub.MRF.FollowBotPolicy do @impl true def filter(message) do with follower_nickname <- Config.get([:mrf_follow_bot, :follower_nickname]), - %User{} = follower <- User.get_cached_by_nickname(follower_nickname), + %User{actor_type: "Service"} = follower <- + User.get_cached_by_nickname(follower_nickname), %{"type" => "Create", "object" => %{"type" => "Note"}} <- message do try_follow(follower, message) else nil -> Logger.warn( - "#{__MODULE__} skipped because of missing :mrf_follow_bot, :follower_nickname configuration or the account - does not exist." + "#{__MODULE__} skipped because of missing `:mrf_follow_bot, :follower_nickname` configuration, the :follower_nickname + account does not exist, or the account is not correctly configured as a bot." ) {:ok, message} From 2557e805a3034f363f0dfaa38cb8b174d89e3d1b Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Thu, 8 Oct 2020 12:46:27 -0500 Subject: [PATCH 114/339] Support for configuration via AdminFE --- config/description.exs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/config/description.exs b/config/description.exs index 41e5e4056..bb1f43305 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2942,6 +2942,23 @@ } ] }, + %{ + group: :pleroma, + key: :mrf_follow_bot, + tab: :mrf, + related_policy: "Pleroma.Web.ActivityPub.MRF.FollowBotPolicy", + label: "MRF FollowBot Policy", + type: :group, + description: "Automatically follows newly discovered accounts.", + children: [ + %{ + key: :follower_nickname, + type: :string, + description: "The name of the bot account to use for following newly discovered users.", + suggestions: ["followbot"] + } + ] + }, %{ group: :pleroma, key: :modules, From 2689463c7e8e99f25964072360b4c6955b7fcea0 Mon Sep 17 00:00:00 2001 From: feld <feld@feld.me> Date: Thu, 8 Oct 2020 19:48:09 +0000 Subject: [PATCH 115/339] Apply 1 suggestion(s) to 1 file(s) --- docs/configuration/cheatsheet.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 6e52cd181..d30f4cbdd 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -223,7 +223,7 @@ Notes: #### :mrf_follow_bot -* `follower_nickname`: The name of the bot account to use for following newly discovered users. +* `follower_nickname`: The name of the bot account to use for following newly discovered users. Using `followbot` or similar is strongly suggested. ### :activitypub From 3949cfdc249bb508c1171851fa2ec076126003cc Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Fri, 19 Feb 2021 09:47:25 -0600 Subject: [PATCH 116/339] Make the followbot only dispatch follow requests once per 30 day period --- .../web/activity_pub/mrf/follow_bot_policy.ex | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex index d10b7b480..044febe0c 100644 --- a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex @@ -1,10 +1,14 @@ defmodule Pleroma.Web.ActivityPub.MRF.FollowBotPolicy do @behaviour Pleroma.Web.ActivityPub.MRF + alias Pleroma.Activity.Queries alias Pleroma.Config + alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.CommonAPI require Logger + import Ecto.Query + @impl true def filter(message) do with follower_nickname <- Config.get([:mrf_follow_bot, :follower_nickname]), @@ -36,12 +40,13 @@ defp try_follow(follower, message) do |> List.flatten() |> User.get_all_by_ap_id() |> Enum.each(fn user -> - Logger.info("Checking if #{user.nickname} can be followed") + since_thirty_days_ago = NaiveDateTime.utc_now() |> NaiveDateTime.add(-(86_400 * 30)) with false <- User.following?(follower, user), - false <- user.locked, - false <- (user.bio || "") |> String.downcase() |> String.contains?("nobot") do - Logger.info("Following #{user.nickname}") + false <- User.locked?(user), + false <- (user.bio || "") |> String.downcase() |> String.contains?("nobot"), + false <- outstanding_follow_request_since?(follower, user, since_thirty_days_ago) do + Logger.info("#{__MODULE__}: Follow request from #{follower.nickname} to #{user.nickname}") CommonAPI.follow(follower, user) end end) @@ -50,6 +55,20 @@ defp try_follow(follower, message) do {:ok, message} end + defp outstanding_follow_request_since?( + %User{ap_id: follower_id}, + %User{ap_id: followee_id}, + since_datetime + ) do + followee_id + |> Queries.by_object_id() + |> Queries.by_type("Follow") + |> where([a], a.inserted_at > ^since_datetime) + |> where([a], fragment("? ->> 'state' = 'pending'", a.data)) + |> where([a], a.actor == ^follower_id) + |> Repo.exists?() + end + @impl true def describe do {:ok, %{}} From 3989ec508c00a66d9093ead06deb8b1272b0b985 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Fri, 19 Feb 2021 09:59:30 -0600 Subject: [PATCH 117/339] Prevent duplicates from being processed --- lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex index 044febe0c..2fd5d5612 100644 --- a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex @@ -38,6 +38,7 @@ defp try_follow(follower, message) do Enum.concat([to, cc, actor]) |> List.flatten() + |> Enum.uniq() |> User.get_all_by_ap_id() |> Enum.each(fn user -> since_thirty_days_ago = NaiveDateTime.utc_now() |> NaiveDateTime.add(-(86_400 * 30)) From a176914c73456eea7926235eb48e342ac1ab112d Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Fri, 19 Feb 2021 14:42:20 -0600 Subject: [PATCH 118/339] Better checking of previous follow request attempts --- lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex index 2fd5d5612..c7aaa6386 100644 --- a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex @@ -65,7 +65,7 @@ defp outstanding_follow_request_since?( |> Queries.by_object_id() |> Queries.by_type("Follow") |> where([a], a.inserted_at > ^since_datetime) - |> where([a], fragment("? ->> 'state' = 'pending'", a.data)) + |> where([a], fragment("? ->> 'state' != 'accept'", a.data)) |> where([a], a.actor == ^follower_id) |> Repo.exists?() end From f0dcc1ca692fb5d6a5aca4f8a9ccb255baef9c1d Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Fri, 19 Feb 2021 14:55:05 -0600 Subject: [PATCH 119/339] Lint --- lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex index c7aaa6386..441ce553e 100644 --- a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex @@ -47,7 +47,10 @@ defp try_follow(follower, message) do false <- User.locked?(user), false <- (user.bio || "") |> String.downcase() |> String.contains?("nobot"), false <- outstanding_follow_request_since?(follower, user, since_thirty_days_ago) do - Logger.info("#{__MODULE__}: Follow request from #{follower.nickname} to #{user.nickname}") + Logger.info( + "#{__MODULE__}: Follow request from #{follower.nickname} to #{user.nickname}" + ) + CommonAPI.follow(follower, user) end end) From 1926d0804ba6ade106a509c027af6bf56e6a8791 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Fri, 19 Feb 2021 15:16:55 -0600 Subject: [PATCH 120/339] Add follow_requests_outstanding_since?/3 to Pleroma.Activity --- lib/pleroma/activity.ex | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index d59403884..b0f1a900d 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -345,6 +345,20 @@ def following_requests_for_actor(%User{ap_id: ap_id}) do |> Repo.all() end + def follow_requests_outstanding_since?( + %User{ap_id: follower_id}, + %User{ap_id: followee_id}, + since_datetime + ) do + followee_id + |> Queries.by_object_id() + |> Queries.by_type("Follow") + |> where([a], a.inserted_at > ^since_datetime) + |> where([a], fragment("? ->> 'state' != 'accept'", a.data)) + |> where([a], a.actor == ^follower_id) + |> Repo.exists?() + end + def restrict_deactivated_users(query) do deactivated_users = from(u in User.Query.build(%{deactivated: true}), select: u.ap_id) From 86182ef8e445ee8a89ce2e49f33cab3dac2d2b12 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Fri, 19 Feb 2021 15:17:33 -0600 Subject: [PATCH 121/339] Change module name to FollowbotPolicy --- ...llow_bot_policy.ex => followbot_policy.ex} | 25 ++++--------------- 1 file changed, 5 insertions(+), 20 deletions(-) rename lib/pleroma/web/activity_pub/mrf/{follow_bot_policy.ex => followbot_policy.ex} (72%) diff --git a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex b/lib/pleroma/web/activity_pub/mrf/followbot_policy.ex similarity index 72% rename from lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex rename to lib/pleroma/web/activity_pub/mrf/followbot_policy.ex index 441ce553e..838d39c88 100644 --- a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/followbot_policy.ex @@ -1,13 +1,11 @@ -defmodule Pleroma.Web.ActivityPub.MRF.FollowBotPolicy do +defmodule Pleroma.Web.ActivityPub.MRF.FollowbotPolicy do @behaviour Pleroma.Web.ActivityPub.MRF - alias Pleroma.Activity.Queries + alias Pleroma.Activity alias Pleroma.Config - alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.CommonAPI - require Logger - import Ecto.Query + require Logger @impl true def filter(message) do @@ -46,7 +44,8 @@ defp try_follow(follower, message) do with false <- User.following?(follower, user), false <- User.locked?(user), false <- (user.bio || "") |> String.downcase() |> String.contains?("nobot"), - false <- outstanding_follow_request_since?(follower, user, since_thirty_days_ago) do + false <- + Activity.follow_requests_outstanding_since?(follower, user, since_thirty_days_ago) do Logger.info( "#{__MODULE__}: Follow request from #{follower.nickname} to #{user.nickname}" ) @@ -59,20 +58,6 @@ defp try_follow(follower, message) do {:ok, message} end - defp outstanding_follow_request_since?( - %User{ap_id: follower_id}, - %User{ap_id: followee_id}, - since_datetime - ) do - followee_id - |> Queries.by_object_id() - |> Queries.by_type("Follow") - |> where([a], a.inserted_at > ^since_datetime) - |> where([a], fragment("? ->> 'state' != 'accept'", a.data)) - |> where([a], a.actor == ^follower_id) - |> Repo.exists?() - end - @impl true def describe do {:ok, %{}} From 778010ef8e1f4509bd554e65556336e5e8457ef6 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Fri, 19 Feb 2021 15:25:26 -0600 Subject: [PATCH 122/339] Do not try to follow local users. Their posts are already available locally on the instance. --- lib/pleroma/web/activity_pub/mrf/followbot_policy.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/mrf/followbot_policy.ex b/lib/pleroma/web/activity_pub/mrf/followbot_policy.ex index 838d39c88..5c8834536 100644 --- a/lib/pleroma/web/activity_pub/mrf/followbot_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/followbot_policy.ex @@ -41,7 +41,8 @@ defp try_follow(follower, message) do |> Enum.each(fn user -> since_thirty_days_ago = NaiveDateTime.utc_now() |> NaiveDateTime.add(-(86_400 * 30)) - with false <- User.following?(follower, user), + with false <- user.local, + false <- User.following?(follower, user), false <- User.locked?(user), false <- (user.bio || "") |> String.downcase() |> String.contains?("nobot"), false <- From c252ac71d4ea4f3b08bd3524f32ee3fe9308be06 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Fri, 19 Feb 2021 18:34:52 -0600 Subject: [PATCH 123/339] Revert --- lib/pleroma/activity.ex | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index b0f1a900d..d59403884 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -345,20 +345,6 @@ def following_requests_for_actor(%User{ap_id: ap_id}) do |> Repo.all() end - def follow_requests_outstanding_since?( - %User{ap_id: follower_id}, - %User{ap_id: followee_id}, - since_datetime - ) do - followee_id - |> Queries.by_object_id() - |> Queries.by_type("Follow") - |> where([a], a.inserted_at > ^since_datetime) - |> where([a], fragment("? ->> 'state' != 'accept'", a.data)) - |> where([a], a.actor == ^follower_id) - |> Repo.exists?() - end - def restrict_deactivated_users(query) do deactivated_users = from(u in User.Query.build(%{deactivated: true}), select: u.ap_id) From f73d1667854fc4c6c721bf49a7deeefde1f569e3 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Fri, 19 Feb 2021 18:36:21 -0600 Subject: [PATCH 124/339] Only need to validate a follow request is generated for now --- .../mrf/followbot_policy_test.exs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 test/pleroma/web/activity_pub/mrf/followbot_policy_test.exs diff --git a/test/pleroma/web/activity_pub/mrf/followbot_policy_test.exs b/test/pleroma/web/activity_pub/mrf/followbot_policy_test.exs new file mode 100644 index 000000000..283e9b12c --- /dev/null +++ b/test/pleroma/web/activity_pub/mrf/followbot_policy_test.exs @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.FollowbotPolicyTest do + use Pleroma.DataCase, async: true + + alias Pleroma.User + alias Pleroma.Web.ActivityPub.MRF.FollowbotPolicy + + import Pleroma.Factory + + describe "FollowBotPolicy" do + test "follows remote users" do + bot = insert(:user, actor_type: "Service") + remote_user = insert(:user, local: false) + clear_config([:mrf_follow_bot, :follower_nickname], bot.nickname) + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "to" => [remote_user.follower_address], + "cc" => ["https://www.w3.org/ns/activitystreams#Public"], + "type" => "Create", + "object" => %{ + "content" => "Test post", + "type" => "Note", + "attributedTo" => remote_user.ap_id, + "inReplyTo" => nil + }, + "actor" => remote_user.ap_id + } + + refute User.following?(bot, remote_user) + + assert User.get_follow_requests(remote_user) |> length == 0 + + FollowbotPolicy.filter(message) + + assert User.get_follow_requests(remote_user) |> length == 1 + end + end +end From 4796df0bc39a57b2581168cb8d8fde7779068f2d Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Fri, 19 Feb 2021 18:36:35 -0600 Subject: [PATCH 125/339] Remove Task.async as it is broken here and probably a premature optimization anyway --- .../web/activity_pub/mrf/followbot_policy.ex | 41 ++++++++----------- 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/followbot_policy.ex b/lib/pleroma/web/activity_pub/mrf/followbot_policy.ex index 5c8834536..ca99e429c 100644 --- a/lib/pleroma/web/activity_pub/mrf/followbot_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/followbot_policy.ex @@ -1,6 +1,5 @@ defmodule Pleroma.Web.ActivityPub.MRF.FollowbotPolicy do @behaviour Pleroma.Web.ActivityPub.MRF - alias Pleroma.Activity alias Pleroma.Config alias Pleroma.User alias Pleroma.Web.CommonAPI @@ -29,31 +28,25 @@ def filter(message) do end defp try_follow(follower, message) do - Task.start(fn -> - to = Map.get(message, "to", []) - cc = Map.get(message, "cc", []) - actor = [message["actor"]] + to = Map.get(message, "to", []) + cc = Map.get(message, "cc", []) + actor = [message["actor"]] - Enum.concat([to, cc, actor]) - |> List.flatten() - |> Enum.uniq() - |> User.get_all_by_ap_id() - |> Enum.each(fn user -> - since_thirty_days_ago = NaiveDateTime.utc_now() |> NaiveDateTime.add(-(86_400 * 30)) + Enum.concat([to, cc, actor]) + |> List.flatten() + |> Enum.uniq() + |> User.get_all_by_ap_id() + |> Enum.each(fn user -> + with false <- user.local, + false <- User.following?(follower, user), + false <- User.locked?(user), + false <- (user.bio || "") |> String.downcase() |> String.contains?("nobot") do + Logger.debug( + "#{__MODULE__}: Follow request from #{follower.nickname} to #{user.nickname}" + ) - with false <- user.local, - false <- User.following?(follower, user), - false <- User.locked?(user), - false <- (user.bio || "") |> String.downcase() |> String.contains?("nobot"), - false <- - Activity.follow_requests_outstanding_since?(follower, user, since_thirty_days_ago) do - Logger.info( - "#{__MODULE__}: Follow request from #{follower.nickname} to #{user.nickname}" - ) - - CommonAPI.follow(follower, user) - end - end) + CommonAPI.follow(follower, user) + end end) {:ok, message} From fef4f3772cf035cefb939bdfaaa4b12d6444b553 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Wed, 24 Feb 2021 11:52:03 -0600 Subject: [PATCH 126/339] More tests to validate Followbot is behaving --- .../mrf/followbot_policy_test.exs | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/test/pleroma/web/activity_pub/mrf/followbot_policy_test.exs b/test/pleroma/web/activity_pub/mrf/followbot_policy_test.exs index 283e9b12c..4c39e04e8 100644 --- a/test/pleroma/web/activity_pub/mrf/followbot_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/followbot_policy_test.exs @@ -38,5 +38,89 @@ test "follows remote users" do assert User.get_follow_requests(remote_user) |> length == 1 end + + test "does not follow users with #nobot in bio" do + bot = insert(:user, actor_type: "Service") + remote_user = insert(:user, %{local: false, bio: "go away bots! #nobot"}) + clear_config([:mrf_follow_bot, :follower_nickname], bot.nickname) + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "to" => [remote_user.follower_address], + "cc" => ["https://www.w3.org/ns/activitystreams#Public"], + "type" => "Create", + "object" => %{ + "content" => "I don't like follow bots", + "type" => "Note", + "attributedTo" => remote_user.ap_id, + "inReplyTo" => nil + }, + "actor" => remote_user.ap_id + } + + refute User.following?(bot, remote_user) + + assert User.get_follow_requests(remote_user) |> length == 0 + + FollowbotPolicy.filter(message) + + assert User.get_follow_requests(remote_user) |> length == 0 + end + + test "does not follow local users" do + bot = insert(:user, actor_type: "Service") + local_user = insert(:user, local: true) + clear_config([:mrf_follow_bot, :follower_nickname], bot.nickname) + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "to" => [local_user.follower_address], + "cc" => ["https://www.w3.org/ns/activitystreams#Public"], + "type" => "Create", + "object" => %{ + "content" => "Hi I'm a local user", + "type" => "Note", + "attributedTo" => local_user.ap_id, + "inReplyTo" => nil + }, + "actor" => local_user.ap_id + } + + refute User.following?(bot, local_user) + + assert User.get_follow_requests(local_user) |> length == 0 + + FollowbotPolicy.filter(message) + + assert User.get_follow_requests(local_user) |> length == 0 + end + + test "does not follow users requiring follower approval" do + bot = insert(:user, actor_type: "Service") + remote_user = insert(:user, %{local: false, is_locked: true}) + clear_config([:mrf_follow_bot, :follower_nickname], bot.nickname) + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "to" => [remote_user.follower_address], + "cc" => ["https://www.w3.org/ns/activitystreams#Public"], + "type" => "Create", + "object" => %{ + "content" => "I don't like randos following me", + "type" => "Note", + "attributedTo" => remote_user.ap_id, + "inReplyTo" => nil + }, + "actor" => remote_user.ap_id + } + + refute User.following?(bot, remote_user) + + assert User.get_follow_requests(remote_user) |> length == 0 + + FollowbotPolicy.filter(message) + + assert User.get_follow_requests(remote_user) |> length == 0 + end end end From 7eab98d5c856097c0cfe09a02adfd4c05fb5d240 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Wed, 24 Feb 2021 11:58:09 -0600 Subject: [PATCH 127/339] Document new FollowBot MRF --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb26c7a73..43f2bb638 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Ability to define custom HTTP headers per each frontend - MRF (`NoEmptyPolicy`): New MRF Policy which will deny empty statuses or statuses of only mentions from being created by local users - New users will receive a simple email confirming their registration if no other emails will be dispatched. (e.g., Welcome, Confirmation, or Approval Required) +- MRF (`FollowBotPolicy`): New MRF Policy which makes a designated local Bot account attempt to follow all users in public Notes received by your instance. Users who require approving follower requests or have #nobot in their profile are excluded. <details> <summary>API Changes</summary> From 03f38ac4ebd97e792b0ff2a6ac804adefed85a41 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Wed, 24 Feb 2021 11:59:11 -0600 Subject: [PATCH 128/339] Prefer FollowBot naming convention vs Followbot --- ...llowbot_policy.ex => follow_bot_policy.ex} | 2 +- .../mrf/follow_bot_policy_test.exs | 126 ++++++++++++++++++ ...y_test.exs => follow_bot_policy_test.exs~} | 0 3 files changed, 127 insertions(+), 1 deletion(-) rename lib/pleroma/web/activity_pub/mrf/{followbot_policy.ex => follow_bot_policy.ex} (96%) create mode 100644 test/pleroma/web/activity_pub/mrf/follow_bot_policy_test.exs rename test/pleroma/web/activity_pub/mrf/{followbot_policy_test.exs => follow_bot_policy_test.exs~} (100%) diff --git a/lib/pleroma/web/activity_pub/mrf/followbot_policy.ex b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex similarity index 96% rename from lib/pleroma/web/activity_pub/mrf/followbot_policy.ex rename to lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex index ca99e429c..7307c9c14 100644 --- a/lib/pleroma/web/activity_pub/mrf/followbot_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex @@ -1,4 +1,4 @@ -defmodule Pleroma.Web.ActivityPub.MRF.FollowbotPolicy do +defmodule Pleroma.Web.ActivityPub.MRF.FollowBotPolicy do @behaviour Pleroma.Web.ActivityPub.MRF alias Pleroma.Config alias Pleroma.User diff --git a/test/pleroma/web/activity_pub/mrf/follow_bot_policy_test.exs b/test/pleroma/web/activity_pub/mrf/follow_bot_policy_test.exs new file mode 100644 index 000000000..3f63f11ef --- /dev/null +++ b/test/pleroma/web/activity_pub/mrf/follow_bot_policy_test.exs @@ -0,0 +1,126 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.FollowBotPolicyTest do + use Pleroma.DataCase, async: true + + alias Pleroma.User + alias Pleroma.Web.ActivityPub.MRF.FollowBotPolicy + + import Pleroma.Factory + + describe "FollowBotPolicy" do + test "follows remote users" do + bot = insert(:user, actor_type: "Service") + remote_user = insert(:user, local: false) + clear_config([:mrf_follow_bot, :follower_nickname], bot.nickname) + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "to" => [remote_user.follower_address], + "cc" => ["https://www.w3.org/ns/activitystreams#Public"], + "type" => "Create", + "object" => %{ + "content" => "Test post", + "type" => "Note", + "attributedTo" => remote_user.ap_id, + "inReplyTo" => nil + }, + "actor" => remote_user.ap_id + } + + refute User.following?(bot, remote_user) + + assert User.get_follow_requests(remote_user) |> length == 0 + + FollowbotPolicy.filter(message) + + assert User.get_follow_requests(remote_user) |> length == 1 + end + + test "does not follow users with #nobot in bio" do + bot = insert(:user, actor_type: "Service") + remote_user = insert(:user, %{local: false, bio: "go away bots! #nobot"}) + clear_config([:mrf_follow_bot, :follower_nickname], bot.nickname) + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "to" => [remote_user.follower_address], + "cc" => ["https://www.w3.org/ns/activitystreams#Public"], + "type" => "Create", + "object" => %{ + "content" => "I don't like follow bots", + "type" => "Note", + "attributedTo" => remote_user.ap_id, + "inReplyTo" => nil + }, + "actor" => remote_user.ap_id + } + + refute User.following?(bot, remote_user) + + assert User.get_follow_requests(remote_user) |> length == 0 + + FollowbotPolicy.filter(message) + + assert User.get_follow_requests(remote_user) |> length == 0 + end + + test "does not follow local users" do + bot = insert(:user, actor_type: "Service") + local_user = insert(:user, local: true) + clear_config([:mrf_follow_bot, :follower_nickname], bot.nickname) + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "to" => [local_user.follower_address], + "cc" => ["https://www.w3.org/ns/activitystreams#Public"], + "type" => "Create", + "object" => %{ + "content" => "Hi I'm a local user", + "type" => "Note", + "attributedTo" => local_user.ap_id, + "inReplyTo" => nil + }, + "actor" => local_user.ap_id + } + + refute User.following?(bot, local_user) + + assert User.get_follow_requests(local_user) |> length == 0 + + FollowbotPolicy.filter(message) + + assert User.get_follow_requests(local_user) |> length == 0 + end + + test "does not follow users requiring follower approval" do + bot = insert(:user, actor_type: "Service") + remote_user = insert(:user, %{local: false, is_locked: true}) + clear_config([:mrf_follow_bot, :follower_nickname], bot.nickname) + + message = %{ + "@context" => "https://www.w3.org/ns/activitystreams", + "to" => [remote_user.follower_address], + "cc" => ["https://www.w3.org/ns/activitystreams#Public"], + "type" => "Create", + "object" => %{ + "content" => "I don't like randos following me", + "type" => "Note", + "attributedTo" => remote_user.ap_id, + "inReplyTo" => nil + }, + "actor" => remote_user.ap_id + } + + refute User.following?(bot, remote_user) + + assert User.get_follow_requests(remote_user) |> length == 0 + + FollowbotPolicy.filter(message) + + assert User.get_follow_requests(remote_user) |> length == 0 + end + end +end diff --git a/test/pleroma/web/activity_pub/mrf/followbot_policy_test.exs b/test/pleroma/web/activity_pub/mrf/follow_bot_policy_test.exs~ similarity index 100% rename from test/pleroma/web/activity_pub/mrf/followbot_policy_test.exs rename to test/pleroma/web/activity_pub/mrf/follow_bot_policy_test.exs~ From d29f6d6b6ef896d0fa47b4f5136fc6714e3425f3 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Wed, 24 Feb 2021 12:02:33 -0600 Subject: [PATCH 129/339] Add more details to the cheatsheat for FollowBot MRF --- docs/configuration/cheatsheet.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index d30f4cbdd..069421722 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -124,7 +124,7 @@ To add configuration to your config file, you can copy it from the base config. * `Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy`: Rejects or delists posts based on their age when received. (See [`:mrf_object_age`](#mrf_object_age)). * `Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy`: Sets a default expiration on all posts made by users of the local instance. Requires `Pleroma.Workers.PurgeExpiredActivity` to be enabled for processing the scheduled delections. * `Pleroma.Web.ActivityPub.MRF.ForceBotUnlistedPolicy`: Makes all bot posts to disappear from public timelines. - * `Pleroma.Web.ActivityPub.MRF.FollowBotPolicy`: Automatically follows newly discovered users from the specified bot account. + * `Pleroma.Web.ActivityPub.MRF.FollowBotPolicy`: Automatically follows newly discovered users from the specified bot account. Local accounts, locked accounts, and users with "#nobot" in their bio are respected and excluded from being followed. * `transparency`: Make the content of your Message Rewrite Facility settings public (via nodeinfo). * `transparency_exclusions`: Exclude specific instance names from MRF transparency. The use of the exclusions feature will be disclosed in nodeinfo as a boolean value. From bfcdcd4f6937bfc0b123a6ec0bbf1d3e6968f0fb Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Wed, 24 Feb 2021 12:07:40 -0600 Subject: [PATCH 130/339] Temp file leaked, oops --- .../mrf/follow_bot_policy_test.exs~ | 126 ------------------ 1 file changed, 126 deletions(-) delete mode 100644 test/pleroma/web/activity_pub/mrf/follow_bot_policy_test.exs~ diff --git a/test/pleroma/web/activity_pub/mrf/follow_bot_policy_test.exs~ b/test/pleroma/web/activity_pub/mrf/follow_bot_policy_test.exs~ deleted file mode 100644 index 4c39e04e8..000000000 --- a/test/pleroma/web/activity_pub/mrf/follow_bot_policy_test.exs~ +++ /dev/null @@ -1,126 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ActivityPub.MRF.FollowbotPolicyTest do - use Pleroma.DataCase, async: true - - alias Pleroma.User - alias Pleroma.Web.ActivityPub.MRF.FollowbotPolicy - - import Pleroma.Factory - - describe "FollowBotPolicy" do - test "follows remote users" do - bot = insert(:user, actor_type: "Service") - remote_user = insert(:user, local: false) - clear_config([:mrf_follow_bot, :follower_nickname], bot.nickname) - - message = %{ - "@context" => "https://www.w3.org/ns/activitystreams", - "to" => [remote_user.follower_address], - "cc" => ["https://www.w3.org/ns/activitystreams#Public"], - "type" => "Create", - "object" => %{ - "content" => "Test post", - "type" => "Note", - "attributedTo" => remote_user.ap_id, - "inReplyTo" => nil - }, - "actor" => remote_user.ap_id - } - - refute User.following?(bot, remote_user) - - assert User.get_follow_requests(remote_user) |> length == 0 - - FollowbotPolicy.filter(message) - - assert User.get_follow_requests(remote_user) |> length == 1 - end - - test "does not follow users with #nobot in bio" do - bot = insert(:user, actor_type: "Service") - remote_user = insert(:user, %{local: false, bio: "go away bots! #nobot"}) - clear_config([:mrf_follow_bot, :follower_nickname], bot.nickname) - - message = %{ - "@context" => "https://www.w3.org/ns/activitystreams", - "to" => [remote_user.follower_address], - "cc" => ["https://www.w3.org/ns/activitystreams#Public"], - "type" => "Create", - "object" => %{ - "content" => "I don't like follow bots", - "type" => "Note", - "attributedTo" => remote_user.ap_id, - "inReplyTo" => nil - }, - "actor" => remote_user.ap_id - } - - refute User.following?(bot, remote_user) - - assert User.get_follow_requests(remote_user) |> length == 0 - - FollowbotPolicy.filter(message) - - assert User.get_follow_requests(remote_user) |> length == 0 - end - - test "does not follow local users" do - bot = insert(:user, actor_type: "Service") - local_user = insert(:user, local: true) - clear_config([:mrf_follow_bot, :follower_nickname], bot.nickname) - - message = %{ - "@context" => "https://www.w3.org/ns/activitystreams", - "to" => [local_user.follower_address], - "cc" => ["https://www.w3.org/ns/activitystreams#Public"], - "type" => "Create", - "object" => %{ - "content" => "Hi I'm a local user", - "type" => "Note", - "attributedTo" => local_user.ap_id, - "inReplyTo" => nil - }, - "actor" => local_user.ap_id - } - - refute User.following?(bot, local_user) - - assert User.get_follow_requests(local_user) |> length == 0 - - FollowbotPolicy.filter(message) - - assert User.get_follow_requests(local_user) |> length == 0 - end - - test "does not follow users requiring follower approval" do - bot = insert(:user, actor_type: "Service") - remote_user = insert(:user, %{local: false, is_locked: true}) - clear_config([:mrf_follow_bot, :follower_nickname], bot.nickname) - - message = %{ - "@context" => "https://www.w3.org/ns/activitystreams", - "to" => [remote_user.follower_address], - "cc" => ["https://www.w3.org/ns/activitystreams#Public"], - "type" => "Create", - "object" => %{ - "content" => "I don't like randos following me", - "type" => "Note", - "attributedTo" => remote_user.ap_id, - "inReplyTo" => nil - }, - "actor" => remote_user.ap_id - } - - refute User.following?(bot, remote_user) - - assert User.get_follow_requests(remote_user) |> length == 0 - - FollowbotPolicy.filter(message) - - assert User.get_follow_requests(remote_user) |> length == 0 - end - end -end From 16a7ffb1ea9dc8e2c7a70d9243424b40d594bd63 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Fri, 26 Feb 2021 11:04:27 -0600 Subject: [PATCH 131/339] Fix function calls due to module name change --- .../web/activity_pub/mrf/follow_bot_policy_test.exs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/pleroma/web/activity_pub/mrf/follow_bot_policy_test.exs b/test/pleroma/web/activity_pub/mrf/follow_bot_policy_test.exs index 3f63f11ef..a61562558 100644 --- a/test/pleroma/web/activity_pub/mrf/follow_bot_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/follow_bot_policy_test.exs @@ -34,7 +34,7 @@ test "follows remote users" do assert User.get_follow_requests(remote_user) |> length == 0 - FollowbotPolicy.filter(message) + FollowBotPolicy.filter(message) assert User.get_follow_requests(remote_user) |> length == 1 end @@ -62,7 +62,7 @@ test "does not follow users with #nobot in bio" do assert User.get_follow_requests(remote_user) |> length == 0 - FollowbotPolicy.filter(message) + FollowBotPolicy.filter(message) assert User.get_follow_requests(remote_user) |> length == 0 end @@ -90,7 +90,7 @@ test "does not follow local users" do assert User.get_follow_requests(local_user) |> length == 0 - FollowbotPolicy.filter(message) + FollowBotPolicy.filter(message) assert User.get_follow_requests(local_user) |> length == 0 end @@ -118,7 +118,7 @@ test "does not follow users requiring follower approval" do assert User.get_follow_requests(remote_user) |> length == 0 - FollowbotPolicy.filter(message) + FollowBotPolicy.filter(message) assert User.get_follow_requests(remote_user) |> length == 0 end From 863010ea637d6670076dba3f6da54daa144cce67 Mon Sep 17 00:00:00 2001 From: Miss Pasture <atsmdq@gmail.com> Date: Wed, 31 Mar 2021 06:51:22 +0000 Subject: [PATCH 132/339] date-times are always strings --- lib/pleroma/web/api_spec/operations/account_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 54e5ebc76..08d68893a 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -482,7 +482,7 @@ defp create_response do access_token: %Schema{type: :string}, refresh_token: %Schema{type: :string}, scope: %Schema{type: :string}, - created_at: %Schema{type: :integer, format: :"date-time"}, + created_at: %Schema{type: :string, format: :"date-time"}, me: %Schema{type: :string}, expires_in: %Schema{type: :integer}, # From af1cd28f9b8005dff563c0df7f80db47df4e8488 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Thu, 1 Apr 2021 11:50:45 +0200 Subject: [PATCH 133/339] object_validator: Refactor most of validate/2 to a generic block --- .../web/activity_pub/object_validator.ex | 135 +++--------------- 1 file changed, 22 insertions(+), 113 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 297c19cc0..f75744203 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -37,37 +37,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do @impl true def validate(object, meta) - def validate(%{"type" => type} = object, meta) - when type in ~w[Accept Reject] do - with {:ok, object} <- - object - |> AcceptRejectValidator.cast_and_validate() - |> Ecto.Changeset.apply_action(:insert) do - object = stringify_keys(object) - {:ok, object, meta} - end - end - - def validate(%{"type" => "Event"} = object, meta) do - with {:ok, object} <- - object - |> EventValidator.cast_and_validate() - |> Ecto.Changeset.apply_action(:insert) do - object = stringify_keys(object) - {:ok, object, meta} - end - end - - def validate(%{"type" => "Follow"} = object, meta) do - with {:ok, object} <- - object - |> FollowValidator.cast_and_validate() - |> Ecto.Changeset.apply_action(:insert) do - object = stringify_keys(object) - {:ok, object, meta} - end - end - def validate(%{"type" => "Block"} = block_activity, meta) do with {:ok, block_activity} <- block_activity @@ -87,16 +56,6 @@ def validate(%{"type" => "Block"} = block_activity, meta) do end end - def validate(%{"type" => "Update"} = update_activity, meta) do - with {:ok, update_activity} <- - update_activity - |> UpdateValidator.cast_and_validate() - |> Ecto.Changeset.apply_action(:insert) do - update_activity = stringify_keys(update_activity) - {:ok, update_activity, meta} - end - end - def validate(%{"type" => "Undo"} = object, meta) do with {:ok, object} <- object @@ -123,76 +82,6 @@ def validate(%{"type" => "Delete"} = object, meta) do end end - def validate(%{"type" => "Like"} = object, meta) do - with {:ok, object} <- - object - |> LikeValidator.cast_and_validate() - |> Ecto.Changeset.apply_action(:insert) do - object = stringify_keys(object) - {:ok, object, meta} - end - end - - def validate(%{"type" => "ChatMessage"} = object, meta) do - with {:ok, object} <- - object - |> ChatMessageValidator.cast_and_validate() - |> Ecto.Changeset.apply_action(:insert) do - object = stringify_keys(object) - {:ok, object, meta} - end - end - - def validate(%{"type" => "Question"} = object, meta) do - with {:ok, object} <- - object - |> QuestionValidator.cast_and_validate() - |> Ecto.Changeset.apply_action(:insert) do - object = stringify_keys(object) - {:ok, object, meta} - end - end - - def validate(%{"type" => type} = object, meta) when type in ~w[Audio Video] do - with {:ok, object} <- - object - |> AudioVideoValidator.cast_and_validate() - |> Ecto.Changeset.apply_action(:insert) do - object = stringify_keys(object) - {:ok, object, meta} - end - end - - def validate(%{"type" => "Article"} = object, meta) do - with {:ok, object} <- - object - |> ArticleNoteValidator.cast_and_validate() - |> Ecto.Changeset.apply_action(:insert) do - object = stringify_keys(object) - {:ok, object, meta} - end - end - - def validate(%{"type" => "Answer"} = object, meta) do - with {:ok, object} <- - object - |> AnswerValidator.cast_and_validate() - |> Ecto.Changeset.apply_action(:insert) do - object = stringify_keys(object) - {:ok, object, meta} - end - end - - def validate(%{"type" => "EmojiReact"} = object, meta) do - with {:ok, object} <- - object - |> EmojiReactValidator.cast_and_validate() - |> Ecto.Changeset.apply_action(:insert) do - object = stringify_keys(object) - {:ok, object, meta} - end - end - def validate( %{"type" => "Create", "object" => %{"type" => "ChatMessage"} = object} = create_activity, meta @@ -224,10 +113,30 @@ def validate( end end - def validate(%{"type" => "Announce"} = object, meta) do + def validate(%{"type" => type} = object, meta) + when type in ~w[Accept Reject Follow Update Like EmojiReact Announce + Event ChatMessage Question Audio Video Article Answer] do + validator = + case type do + "Accept" -> AcceptRejectValidator + "Reject" -> AcceptRejectValidator + "Follow" -> FollowValidator + "Update" -> UpdateValidator + "Like" -> LikeValidator + "EmojiReact" -> EmojiReactValidator + "Announce" -> AnnounceValidator + "Event" -> EventValidator + "ChatMessage" -> ChatMessageValidator + "Question" -> QuestionValidator + "Audio" -> AudioVideoValidator + "Video" -> AudioVideoValidator + "Article" -> ArticleNoteValidator + "Answer" -> AnswerValidator + end + with {:ok, object} <- object - |> AnnounceValidator.cast_and_validate() + |> validator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do object = stringify_keys(object) {:ok, object, meta} From 1e3db075861198417bca36f4f2b0f29c2367c77e Mon Sep 17 00:00:00 2001 From: Haelwenn <contact+git.pleroma.social@hacktivis.me> Date: Thu, 1 Apr 2021 12:00:58 +0000 Subject: [PATCH 134/339] Revert "Merge branch 'patch-fix-open-api-spec' into 'develop'" This reverts merge request !3382 --- lib/pleroma/web/api_spec/operations/account_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 08d68893a..54e5ebc76 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -482,7 +482,7 @@ defp create_response do access_token: %Schema{type: :string}, refresh_token: %Schema{type: :string}, scope: %Schema{type: :string}, - created_at: %Schema{type: :string, format: :"date-time"}, + created_at: %Schema{type: :integer, format: :"date-time"}, me: %Schema{type: :string}, expires_in: %Schema{type: :integer}, # From 9015df22291ab60c0efad328557936fd14eab2e6 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Thu, 7 Jan 2021 18:23:01 +0100 Subject: [PATCH 135/339] TagValidator: New --- .../article_note_validator.ex | 7 +- .../object_validators/attachment_validator.ex | 1 - .../audio_video_validator.ex | 7 +- .../object_validators/event_validator.ex | 7 +- .../object_validators/question_validator.ex | 7 +- .../object_validators/tag_validator.ex | 77 +++++++++++++++++++ 6 files changed, 93 insertions(+), 13 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/object_validators/tag_validator.ex diff --git a/lib/pleroma/web/activity_pub/object_validators/article_note_validator.ex b/lib/pleroma/web/activity_pub/object_validators/article_note_validator.ex index b0388ef3b..5910f4060 100644 --- a/lib/pleroma/web/activity_pub/object_validators/article_note_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/article_note_validator.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNoteValidator do alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + alias Pleroma.Web.ActivityPub.ObjectValidators.TagValidator alias Pleroma.Web.ActivityPub.Transmogrifier import Ecto.Changeset @@ -22,8 +23,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNoteValidator do field(:cc, ObjectValidators.Recipients, default: []) field(:bto, ObjectValidators.Recipients, default: []) field(:bcc, ObjectValidators.Recipients, default: []) - # TODO: Write type - field(:tag, {:array, :map}, default: []) + embeds_many(:tag, TagValidator) field(:type, :string) field(:name, :string) @@ -90,8 +90,9 @@ def changeset(struct, data) do data = fix(data) struct - |> cast(data, __schema__(:fields) -- [:attachment]) + |> cast(data, __schema__(:fields) -- [:attachment, :tag]) |> cast_embed(:attachment) + |> cast_embed(:tag) end def validate_data(data_cng) do diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex index 3175427ad..e7b3a3922 100644 --- a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex @@ -6,7 +6,6 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do use Ecto.Schema alias Pleroma.EctoType.ActivityPub.ObjectValidators - alias Pleroma.Web.ActivityPub.ObjectValidators.UrlObjectValidator import Ecto.Changeset diff --git a/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex b/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex index 4a96fef52..a04c95f4b 100644 --- a/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AudioVideoValidator do alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + alias Pleroma.Web.ActivityPub.ObjectValidators.TagValidator alias Pleroma.Web.ActivityPub.Transmogrifier import Ecto.Changeset @@ -23,8 +24,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AudioVideoValidator do field(:cc, ObjectValidators.Recipients, default: []) field(:bto, ObjectValidators.Recipients, default: []) field(:bcc, ObjectValidators.Recipients, default: []) - # TODO: Write type - field(:tag, {:array, :map}, default: []) + embeds_many(:tag, TagValidator) field(:type, :string) field(:name, :string) @@ -132,8 +132,9 @@ def changeset(struct, data) do data = fix(data) struct - |> cast(data, __schema__(:fields) -- [:attachment]) + |> cast(data, __schema__(:fields) -- [:attachment, :tag]) |> cast_embed(:attachment) + |> cast_embed(:tag) end def validate_data(data_cng) do diff --git a/lib/pleroma/web/activity_pub/object_validators/event_validator.ex b/lib/pleroma/web/activity_pub/object_validators/event_validator.ex index 2e26726f8..0112a074d 100644 --- a/lib/pleroma/web/activity_pub/object_validators/event_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/event_validator.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.EventValidator do alias Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + alias Pleroma.Web.ActivityPub.ObjectValidators.TagValidator alias Pleroma.Web.ActivityPub.Transmogrifier import Ecto.Changeset @@ -23,8 +24,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.EventValidator do field(:cc, ObjectValidators.Recipients, default: []) field(:bto, ObjectValidators.Recipients, default: []) field(:bcc, ObjectValidators.Recipients, default: []) - # TODO: Write type - field(:tag, {:array, :map}, default: []) + embeds_many(:tag, TagValidator) field(:type, :string) field(:name, :string) @@ -81,8 +81,9 @@ def changeset(struct, data) do data = fix(data) struct - |> cast(data, __schema__(:fields) -- [:attachment]) + |> cast(data, __schema__(:fields) -- [:attachment, :tag]) |> cast_embed(:attachment) + |> cast_embed(:tag) end def validate_data(data_cng) do diff --git a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex index 6b746c997..7acb1e928 100644 --- a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex @@ -10,6 +10,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations alias Pleroma.Web.ActivityPub.ObjectValidators.QuestionOptionsValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.TagValidator alias Pleroma.Web.ActivityPub.Transmogrifier import Ecto.Changeset @@ -24,8 +25,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do field(:cc, ObjectValidators.Recipients, default: []) field(:bto, ObjectValidators.Recipients, default: []) field(:bcc, ObjectValidators.Recipients, default: []) - # TODO: Write type - field(:tag, {:array, :map}, default: []) + embeds_many(:tag, TagValidator) field(:type, :string) field(:content, :string) field(:context, :string) @@ -93,10 +93,11 @@ def changeset(struct, data) do data = fix(data) struct - |> cast(data, __schema__(:fields) -- [:anyOf, :oneOf, :attachment]) + |> cast(data, __schema__(:fields) -- [:anyOf, :oneOf, :attachment, :tag]) |> cast_embed(:attachment) |> cast_embed(:anyOf) |> cast_embed(:oneOf) + |> cast_embed(:tag) end def validate_data(data_cng) do diff --git a/lib/pleroma/web/activity_pub/object_validators/tag_validator.ex b/lib/pleroma/web/activity_pub/object_validators/tag_validator.ex new file mode 100644 index 000000000..751021585 --- /dev/null +++ b/lib/pleroma/web/activity_pub/object_validators/tag_validator.ex @@ -0,0 +1,77 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.ObjectValidators.TagValidator do + use Ecto.Schema + + alias Pleroma.EctoType.ActivityPub.ObjectValidators + + import Ecto.Changeset + + @primary_key false + embedded_schema do + # Common + field(:type, :string) + field(:name, :string) + + # Mention, Hashtag + field(:href, ObjectValidators.Uri) + + # Emoji + embeds_one :icon, IconObjectValidator, primary_key: false do + field(:type, :string) + field(:url, ObjectValidators.Uri) + end + + field(:updated, ObjectValidators.DateTime) + field(:id, ObjectValidators.Uri) + end + + def cast_and_validate(data) do + data + |> cast_data() + end + + def cast_data(data) do + %__MODULE__{} + |> changeset(data) + end + + def changeset(struct, %{"type" => "Mention"} = data) do + struct + |> cast(data, [:type, :name, :href]) + |> validate_required([:type, :href]) + end + + def changeset(struct, %{"type" => "Hashtag", "name" => name} = data) do + name = + cond do + "#" <> name -> name + name -> name + end + |> String.downcase() + + data = Map.put(data, "name", name) + + struct + |> cast(data, [:type, :name, :href]) + |> validate_required([:type, :name]) + end + + def changeset(struct, %{"type" => "Emoji"} = data) do + data = Map.put(data, "name", String.trim(data["name"], ":")) + + struct + |> cast(data, [:type, :name, :updated, :id]) + |> cast_embed(:icon, with: &icon_changeset/2) + |> validate_required([:type, :name, :icon]) + end + + def icon_changeset(struct, data) do + struct + |> cast(data, [:type, :url]) + |> validate_inclusion(:type, ~w[Image]) + |> validate_required([:type, :url]) + end +end From 5ae27c8451a7012b43ef9113713132158701364b Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Tue, 12 Jan 2021 14:11:29 +0100 Subject: [PATCH 136/339] pipeline_test: Fix usage of %Activity{} --- .../web/activity_pub/object_validator.ex | 2 +- lib/pleroma/web/activity_pub/pipeline.ex | 2 ++ .../web/activity_pub/pipeline_test.exs | 23 +++++++++++++------ 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index f75744203..15784b28c 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -169,7 +169,7 @@ def cast_and_apply(%{"type" => "Article"} = object) do def cast_and_apply(o), do: {:error, {:validator_not_set, o}} - # is_struct/1 isn't present in Elixir 1.8.x + # is_struct/1 appears in Elixir 1.11 def stringify_keys(%{__struct__: _} = object) do object |> Map.from_struct() diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 195596f94..0aa504e72 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -40,6 +40,8 @@ def common_pipeline(object, meta) do end end + def do_common_pipeline(%{__struct__: _}, _meta), do: {:error, :is_struct} + def do_common_pipeline(object, meta) do with {_, {:ok, validated_object, meta}} <- {:validate_object, @object_validator.validate(object, meta)}, diff --git a/test/pleroma/web/activity_pub/pipeline_test.exs b/test/pleroma/web/activity_pub/pipeline_test.exs index 52fa933ee..e606fa3d1 100644 --- a/test/pleroma/web/activity_pub/pipeline_test.exs +++ b/test/pleroma/web/activity_pub/pipeline_test.exs @@ -25,9 +25,6 @@ defmodule Pleroma.Web.ActivityPub.PipelineTest do MRFMock |> expect(:pipeline_filter, fn o, m -> {:ok, o, m} end) - ActivityPubMock - |> expect(:persist, fn o, m -> {:ok, o, m} end) - SideEffectsMock |> expect(:handle, fn o, m -> {:ok, o, m} end) |> expect(:handle_after_transaction, fn m -> m end) @@ -42,6 +39,9 @@ test "when given an `object_data` in meta, Federation will receive a the origina activity_with_object = %{activity | data: Map.put(activity.data, "object", object)} + ActivityPubMock + |> expect(:persist, fn _, m -> {:ok, activity, m} end) + FederatorMock |> expect(:publish, fn ^activity_with_object -> :ok end) @@ -50,7 +50,7 @@ test "when given an `object_data` in meta, Federation will receive a the origina assert {:ok, ^activity, ^meta} = Pleroma.Web.ActivityPub.Pipeline.common_pipeline( - activity, + activity.data, meta ) end @@ -59,6 +59,9 @@ test "it goes through validation, filtering, persisting, side effects and federa activity = insert(:note_activity) meta = [local: true] + ActivityPubMock + |> expect(:persist, fn _, m -> {:ok, activity, m} end) + FederatorMock |> expect(:publish, fn ^activity -> :ok end) @@ -66,29 +69,35 @@ test "it goes through validation, filtering, persisting, side effects and federa |> expect(:get, fn [:instance, :federating] -> true end) assert {:ok, ^activity, ^meta} = - Pleroma.Web.ActivityPub.Pipeline.common_pipeline(activity, meta) + Pleroma.Web.ActivityPub.Pipeline.common_pipeline(activity.data, meta) end test "it goes through validation, filtering, persisting, side effects without federation for remote activities" do activity = insert(:note_activity) meta = [local: false] + ActivityPubMock + |> expect(:persist, fn _, m -> {:ok, activity, m} end) + ConfigMock |> expect(:get, fn [:instance, :federating] -> true end) assert {:ok, ^activity, ^meta} = - Pleroma.Web.ActivityPub.Pipeline.common_pipeline(activity, meta) + Pleroma.Web.ActivityPub.Pipeline.common_pipeline(activity.data, meta) end test "it goes through validation, filtering, persisting, side effects without federation for local activities if federation is deactivated" do activity = insert(:note_activity) meta = [local: true] + ActivityPubMock + |> expect(:persist, fn _, m -> {:ok, activity, m} end) + ConfigMock |> expect(:get, fn [:instance, :federating] -> false end) assert {:ok, ^activity, ^meta} = - Pleroma.Web.ActivityPub.Pipeline.common_pipeline(activity, meta) + Pleroma.Web.ActivityPub.Pipeline.common_pipeline(activity.data, meta) end end end From 37a7f521fd4778cde48f1b003ad9695e6ea45d1f Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Tue, 12 Jan 2021 09:30:22 +0100 Subject: [PATCH 137/339] Insert string-hashtags in Pipeline Cannot be done in Ecto schemas because only one type is allowed in arrays, and needs to be done before the MRFs. --- lib/pleroma/web/activity_pub/pipeline.ex | 34 ++++++++++++------- .../web/activity_pub/transmogrifier.ex | 2 +- lib/pleroma/web/common_api.ex | 12 +------ 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 0aa504e72..e184a9376 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -42,23 +42,33 @@ def common_pipeline(object, meta) do def do_common_pipeline(%{__struct__: _}, _meta), do: {:error, :is_struct} - def do_common_pipeline(object, meta) do - with {_, {:ok, validated_object, meta}} <- - {:validate_object, @object_validator.validate(object, meta)}, - {_, {:ok, mrfd_object, meta}} <- - {:mrf_object, @mrf.pipeline_filter(validated_object, meta)}, - {_, {:ok, activity, meta}} <- - {:persist_object, @activity_pub.persist(mrfd_object, meta)}, - {_, {:ok, activity, meta}} <- - {:execute_side_effects, @side_effects.handle(activity, meta)}, - {_, {:ok, _}} <- {:federation, maybe_federate(activity, meta)} do - {:ok, activity, meta} + def do_common_pipeline(message, meta) do + with {_, {:ok, message, meta}} <- {:validate, @object_validator.validate(message, meta)}, + {_, {:ok, message, meta}} <- {:fixup, validation_fixups(message, meta)}, + {_, {:ok, message, meta}} <- {:mrf, @mrf.pipeline_filter(message, meta)}, + {_, {:ok, message, meta}} <- {:persist, @activity_pub.persist(message, meta)}, + {_, {:ok, message, meta}} <- {:side_effects, @side_effects.handle(message, meta)}, + {_, {:ok, _}} <- {:federation, maybe_federate(message, meta)} do + {:ok, message, meta} else - {:mrf_object, {:reject, message, _}} -> {:reject, message} + {:mrf, {:reject, message, _}} -> {:reject, message} e -> {:error, e} end end + defp validation_fixups(message, meta) do + # Insert copy of hashtags as strings for the non-hashtag table indexing + message = + if message["tag"] do + tag = Object.hashtags(%Object{data: message}) ++ (message["tag"] || []) + Map.put(message, "tag", tag) + else + message + end + + {:ok, message, meta} + end + defp maybe_federate(%Object{}, _), do: {:ok, :not_federated} defp maybe_federate(%Activity{} = activity, meta) do diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 8c7d6a747..4070ed14d 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -564,7 +564,7 @@ def handle_incoming( Pipeline.common_pipeline(data, local: false) do {:ok, activity} else - {:error, {:validate_object, _}} = e -> + {:error, {:validate, _}} = e -> # Check if we have a create activity for this with {:ok, object_id} <- ObjectValidators.ObjectID.cast(data["object"]), %Activity{data: %{"actor" => actor}} <- diff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex index b003e30c7..895baebc9 100644 --- a/lib/pleroma/web/common_api.ex +++ b/lib/pleroma/web/common_api.ex @@ -228,17 +228,7 @@ def favorite_helper(user, id) do {:find_object, _} -> {:error, :not_found} - {:common_pipeline, - { - :error, - { - :validate_object, - { - :error, - changeset - } - } - }} = e -> + {:common_pipeline, {:error, {:validate, {:error, changeset}}}} = e -> if {:object, {"already liked by this actor", []}} in changeset.errors do {:ok, :already_liked} else From 7ebfe899007002f5bbf8744a8f0b582e0e13342e Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Tue, 12 Jan 2021 11:14:09 +0100 Subject: [PATCH 138/339] object_validators: Mark validate_data as private --- .../activity_pub/object_validators/accept_reject_validator.ex | 2 +- .../web/activity_pub/object_validators/announce_validator.ex | 2 +- .../web/activity_pub/object_validators/answer_validator.ex | 2 +- .../activity_pub/object_validators/article_note_validator.ex | 2 +- .../web/activity_pub/object_validators/attachment_validator.ex | 2 +- .../web/activity_pub/object_validators/audio_video_validator.ex | 2 +- .../web/activity_pub/object_validators/block_validator.ex | 2 +- .../activity_pub/object_validators/chat_message_validator.ex | 2 +- .../object_validators/create_chat_message_validator.ex | 2 +- .../activity_pub/object_validators/create_generic_validator.ex | 2 +- .../web/activity_pub/object_validators/delete_validator.ex | 2 +- .../web/activity_pub/object_validators/emoji_react_validator.ex | 2 +- .../web/activity_pub/object_validators/event_validator.ex | 2 +- .../web/activity_pub/object_validators/follow_validator.ex | 2 +- .../web/activity_pub/object_validators/like_validator.ex | 2 +- .../web/activity_pub/object_validators/question_validator.ex | 2 +- .../web/activity_pub/object_validators/undo_validator.ex | 2 +- .../web/activity_pub/object_validators/update_validator.ex | 2 +- 18 files changed, 18 insertions(+), 18 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/accept_reject_validator.ex b/lib/pleroma/web/activity_pub/object_validators/accept_reject_validator.ex index d31e780c3..b577a1044 100644 --- a/lib/pleroma/web/activity_pub/object_validators/accept_reject_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/accept_reject_validator.ex @@ -27,7 +27,7 @@ def cast_data(data) do |> cast(data, __schema__(:fields)) end - def validate_data(cng) do + defp validate_data(cng) do cng |> validate_required([:id, :type, :actor, :to, :cc, :object]) |> validate_inclusion(:type, ["Accept", "Reject"]) diff --git a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex index b08a33e68..576341790 100644 --- a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex @@ -50,7 +50,7 @@ def fix_after_cast(cng) do cng end - def validate_data(data_cng) do + defp validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["Announce"]) |> validate_required([:id, :type, :object, :actor, :to, :cc]) diff --git a/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex b/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex index 15e4413cd..c9bd9e42d 100644 --- a/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex @@ -50,7 +50,7 @@ def changeset(struct, data) do |> cast(data, __schema__(:fields)) end - def validate_data(data_cng) do + defp validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["Answer"]) |> validate_required([:id, :inReplyTo, :name, :attributedTo, :actor]) diff --git a/lib/pleroma/web/activity_pub/object_validators/article_note_validator.ex b/lib/pleroma/web/activity_pub/object_validators/article_note_validator.ex index 5910f4060..39ef6dc29 100644 --- a/lib/pleroma/web/activity_pub/object_validators/article_note_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/article_note_validator.ex @@ -95,7 +95,7 @@ def changeset(struct, data) do |> cast_embed(:tag) end - def validate_data(data_cng) do + defp validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["Article", "Note"]) |> validate_required([:id, :actor, :attributedTo, :type, :context, :context_id]) diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex index e7b3a3922..4a0d1473d 100644 --- a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex @@ -89,7 +89,7 @@ defp fix_url(data) do end end - def validate_data(cng) do + defp validate_data(cng) do cng |> validate_inclusion(:type, ~w[Document Audio Image Video]) |> validate_required([:mediaType, :url, :type]) diff --git a/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex b/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex index a04c95f4b..8a5a60526 100644 --- a/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex @@ -137,7 +137,7 @@ def changeset(struct, data) do |> cast_embed(:tag) end - def validate_data(data_cng) do + defp validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["Audio", "Video"]) |> validate_required([:id, :actor, :attributedTo, :type, :context, :attachment]) diff --git a/lib/pleroma/web/activity_pub/object_validators/block_validator.ex b/lib/pleroma/web/activity_pub/object_validators/block_validator.ex index c5f77bb76..88948135f 100644 --- a/lib/pleroma/web/activity_pub/object_validators/block_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/block_validator.ex @@ -26,7 +26,7 @@ def cast_data(data) do |> cast(data, __schema__(:fields)) end - def validate_data(cng) do + defp validate_data(cng) do cng |> validate_required([:id, :type, :actor, :to, :cc, :object]) |> validate_inclusion(:type, ["Block"]) diff --git a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex index 1189778f2..b153156b0 100644 --- a/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/chat_message_validator.ex @@ -67,7 +67,7 @@ def changeset(struct, data) do |> cast_embed(:attachment) end - def validate_data(data_cng) do + defp validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["ChatMessage"]) |> validate_required([:id, :actor, :to, :type, :published]) diff --git a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex index 8384c16a7..7a31a99bf 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_chat_message_validator.ex @@ -39,7 +39,7 @@ def cast_and_validate(data, meta \\ []) do |> validate_data(meta) end - def validate_data(cng, meta \\ []) do + defp validate_data(cng, meta) do cng |> validate_required([:id, :actor, :to, :type, :object]) |> validate_inclusion(:type, ["Create"]) diff --git a/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex index bf56a918c..e06e442f4 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex @@ -79,7 +79,7 @@ defp fix(data, meta) do |> CommonFixes.fix_actor() end - def validate_data(cng, meta \\ []) do + defp validate_data(cng, meta) do cng |> validate_required([:actor, :type, :object]) |> validate_inclusion(:type, ["Create"]) diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex index fc1a79a72..7da67bf16 100644 --- a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex @@ -53,7 +53,7 @@ def add_deleted_activity_id(cng) do Tombstone Video } - def validate_data(cng) do + defp validate_data(cng) do cng |> validate_required([:id, :type, :actor, :to, :cc, :object]) |> validate_inclusion(:type, ["Delete"]) diff --git a/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex b/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex index 1906e597e..ec7566515 100644 --- a/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex @@ -70,7 +70,7 @@ def validate_emoji(cng) do end end - def validate_data(data_cng) do + defp validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["EmojiReact"]) |> validate_required([:id, :type, :object, :actor, :context, :to, :cc, :content]) diff --git a/lib/pleroma/web/activity_pub/object_validators/event_validator.ex b/lib/pleroma/web/activity_pub/object_validators/event_validator.ex index 0112a074d..d42458ef5 100644 --- a/lib/pleroma/web/activity_pub/object_validators/event_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/event_validator.ex @@ -86,7 +86,7 @@ def changeset(struct, data) do |> cast_embed(:tag) end - def validate_data(data_cng) do + defp validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["Event"]) |> validate_required([:id, :actor, :attributedTo, :type, :context, :context_id]) diff --git a/lib/pleroma/web/activity_pub/object_validators/follow_validator.ex b/lib/pleroma/web/activity_pub/object_validators/follow_validator.ex index 6e428bacc..239cee5e7 100644 --- a/lib/pleroma/web/activity_pub/object_validators/follow_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/follow_validator.ex @@ -27,7 +27,7 @@ def cast_data(data) do |> cast(data, __schema__(:fields)) end - def validate_data(cng) do + defp validate_data(cng) do cng |> validate_required([:id, :type, :actor, :to, :cc, :object]) |> validate_inclusion(:type, ["Follow"]) diff --git a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex index 30c40b238..509da507b 100644 --- a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex @@ -76,7 +76,7 @@ def fix_recipients(cng) do end end - def validate_data(data_cng) do + defp validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["Like"]) |> validate_required([:id, :type, :object, :actor, :context, :to, :cc]) diff --git a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex index 7acb1e928..7012e2e1d 100644 --- a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex @@ -100,7 +100,7 @@ def changeset(struct, data) do |> cast_embed(:tag) end - def validate_data(data_cng) do + defp validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["Question"]) |> validate_required([:id, :actor, :attributedTo, :type, :context, :context_id]) diff --git a/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex b/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex index 783a79ddb..e8af60ffa 100644 --- a/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex @@ -38,7 +38,7 @@ def changeset(struct, data) do |> cast(data, __schema__(:fields)) end - def validate_data(data_cng) do + defp validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["Undo"]) |> validate_required([:id, :type, :object, :actor, :to, :cc]) diff --git a/lib/pleroma/web/activity_pub/object_validators/update_validator.ex b/lib/pleroma/web/activity_pub/object_validators/update_validator.ex index a66d41400..6bb1dc7fa 100644 --- a/lib/pleroma/web/activity_pub/object_validators/update_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/update_validator.ex @@ -28,7 +28,7 @@ def cast_data(data) do |> cast(data, __schema__(:fields)) end - def validate_data(cng) do + defp validate_data(cng) do cng |> validate_required([:id, :type, :actor, :to, :cc, :object]) |> validate_inclusion(:type, ["Update"]) From 4ecf6ceea6062d68c382918010dc577151d0131c Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Thu, 1 Apr 2021 10:01:31 -0500 Subject: [PATCH 139/339] Enforce user.notification_settings is NOT NULL --- ...401143153_user_notification_settings_fix.exs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 priv/repo/migrations/20210401143153_user_notification_settings_fix.exs diff --git a/priv/repo/migrations/20210401143153_user_notification_settings_fix.exs b/priv/repo/migrations/20210401143153_user_notification_settings_fix.exs new file mode 100644 index 000000000..cf68f1be6 --- /dev/null +++ b/priv/repo/migrations/20210401143153_user_notification_settings_fix.exs @@ -0,0 +1,17 @@ +defmodule Pleroma.Repo.Migrations.UserNotificationSettingsFix do + use Ecto.Migration + + def up do + execute(~s(UPDATE users + SET + notification_settings = '{"followers": true, "follows": true, "non_follows": true, "non_followers": true}'::jsonb WHERE notification_settings IS NULL +)) + + execute("ALTER TABLE users + ALTER COLUMN notification_settings SET NOT NULL") + end + + def down do + :ok + end +end From 765f0907dfa9371038188ee35fc3b241be796d26 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Thu, 1 Apr 2021 10:07:57 -0500 Subject: [PATCH 140/339] Document user login failure fix for NULL notification_settings --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43f2bb638..31a22bb31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Try to save exported ConfigDB settings (migrate_from_db) in the system temp directory if default location is not writable. +- User login failures if their `notification_settings` were in a NULL state. ## [2.3.0] - 2020-03-01 From 31ce8a37304e24381b26d678dfbbc7b7a6b1ba35 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Thu, 1 Apr 2021 10:09:32 -0500 Subject: [PATCH 141/339] Fix CHANGELOG entry meant for next release --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43f2bb638..6c45cad85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +### Changed + - The `application` metadata returned with statuses is no longer hardcoded. Apps that want to display these details will now have valid data for new posts after this change. +### Added + +- MRF (`FollowBotPolicy`): New MRF Policy which makes a designated local Bot account attempt to follow all users in public Notes received by your instance. Users who require approving follower requests or have #nobot in their profile are excluded. + ## Unreleased (Patch) ### Fixed @@ -75,7 +81,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Ability to define custom HTTP headers per each frontend - MRF (`NoEmptyPolicy`): New MRF Policy which will deny empty statuses or statuses of only mentions from being created by local users - New users will receive a simple email confirming their registration if no other emails will be dispatched. (e.g., Welcome, Confirmation, or Approval Required) -- MRF (`FollowBotPolicy`): New MRF Policy which makes a designated local Bot account attempt to follow all users in public Notes received by your instance. Users who require approving follower requests or have #nobot in their profile are excluded. <details> <summary>API Changes</summary> From ef36f7fa5cff0a0d364aff192954556b0d2b0d2a Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Thu, 1 Apr 2021 13:49:04 +0200 Subject: [PATCH 142/339] Move tag fixup to object_validator --- .../web/activity_pub/object_validator.ex | 32 +++++++++++++++---- lib/pleroma/web/activity_pub/pipeline.ex | 14 -------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 15784b28c..70d9a35a9 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -113,9 +113,34 @@ def validate( end end + def validate(%{"type" => type} = object, meta) + when type in ~w[Event Question Audio Video Article] do + validator = + case type do + "Event" -> EventValidator + "Question" -> QuestionValidator + "Audio" -> AudioVideoValidator + "Video" -> AudioVideoValidator + "Article" -> ArticleNoteValidator + end + + with {:ok, object} <- + object + |> validator.cast_and_validate() + |> Ecto.Changeset.apply_action(:insert) do + object = stringify_keys(object) + + # Insert copy of hashtags as strings for the non-hashtag table indexing + tag = (object["tag"] || []) ++ Object.hashtags(%Object{data: object}) + object = Map.put(object, "tag", tag) + + {:ok, object, meta} + end + end + def validate(%{"type" => type} = object, meta) when type in ~w[Accept Reject Follow Update Like EmojiReact Announce - Event ChatMessage Question Audio Video Article Answer] do + ChatMessage Answer] do validator = case type do "Accept" -> AcceptRejectValidator @@ -125,12 +150,7 @@ def validate(%{"type" => type} = object, meta) "Like" -> LikeValidator "EmojiReact" -> EmojiReactValidator "Announce" -> AnnounceValidator - "Event" -> EventValidator "ChatMessage" -> ChatMessageValidator - "Question" -> QuestionValidator - "Audio" -> AudioVideoValidator - "Video" -> AudioVideoValidator - "Article" -> ArticleNoteValidator "Answer" -> AnswerValidator end diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index e184a9376..377eccb92 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -44,7 +44,6 @@ def do_common_pipeline(%{__struct__: _}, _meta), do: {:error, :is_struct} def do_common_pipeline(message, meta) do with {_, {:ok, message, meta}} <- {:validate, @object_validator.validate(message, meta)}, - {_, {:ok, message, meta}} <- {:fixup, validation_fixups(message, meta)}, {_, {:ok, message, meta}} <- {:mrf, @mrf.pipeline_filter(message, meta)}, {_, {:ok, message, meta}} <- {:persist, @activity_pub.persist(message, meta)}, {_, {:ok, message, meta}} <- {:side_effects, @side_effects.handle(message, meta)}, @@ -56,19 +55,6 @@ def do_common_pipeline(message, meta) do end end - defp validation_fixups(message, meta) do - # Insert copy of hashtags as strings for the non-hashtag table indexing - message = - if message["tag"] do - tag = Object.hashtags(%Object{data: message}) ++ (message["tag"] || []) - Map.put(message, "tag", tag) - else - message - end - - {:ok, message, meta} - end - defp maybe_federate(%Object{}, _), do: {:ok, :not_federated} defp maybe_federate(%Activity{} = activity, meta) do From e56779dd8d1668177afa199aaa836bea70e68420 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Thu, 10 Sep 2020 11:09:11 +0200 Subject: [PATCH 143/339] Transmogrifier: Simplify fix_explicit_addressing and fix_implicit_addressing --- .../web/activity_pub/transmogrifier.ex | 51 ++++++------------- .../web/activity_pub/transmogrifier_test.exs | 6 +-- 2 files changed, 19 insertions(+), 38 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 4070ed14d..047f23918 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -72,17 +72,21 @@ def fix_addressing_list(map, field) do end end - def fix_explicit_addressing( - %{"to" => to, "cc" => cc} = object, - explicit_mentions, - follower_collection - ) do - explicit_to = Enum.filter(to, fn x -> x in explicit_mentions end) + # if directMessage flag is set to true, leave the addressing alone + def fix_explicit_addressing(%{"directMessage" => true} = object, _follower_collection), + do: object + def fix_explicit_addressing(%{"to" => to, "cc" => cc} = object, follower_collection) do + explicit_mentions = + Utils.determine_explicit_mentions(object) ++ + [Pleroma.Constants.as_public(), follower_collection] + + explicit_to = Enum.filter(to, fn x -> x in explicit_mentions end) explicit_cc = Enum.filter(to, fn x -> x not in explicit_mentions end) final_cc = (cc ++ explicit_cc) + |> Enum.filter(& &1) |> Enum.reject(fn x -> String.ends_with?(x, "/followers") and x != follower_collection end) |> Enum.uniq() @@ -91,29 +95,6 @@ def fix_explicit_addressing( |> Map.put("cc", final_cc) end - def fix_explicit_addressing(object, _explicit_mentions, _followers_collection), do: object - - # if directMessage flag is set to true, leave the addressing alone - def fix_explicit_addressing(%{"directMessage" => true} = object), do: object - - def fix_explicit_addressing(object) do - explicit_mentions = Utils.determine_explicit_mentions(object) - - %User{follower_address: follower_collection} = - object - |> Containment.get_actor() - |> User.get_cached_by_ap_id() - - explicit_mentions = - explicit_mentions ++ - [ - Pleroma.Constants.as_public(), - follower_collection - ] - - fix_explicit_addressing(object, explicit_mentions, follower_collection) - end - # if as:Public is addressed, then make sure the followers collection is also addressed # so that the activities will be delivered to local users. def fix_implicit_addressing(%{"to" => to, "cc" => cc} = object, followers_collection) do @@ -137,19 +118,19 @@ def fix_implicit_addressing(%{"to" => to, "cc" => cc} = object, followers_collec end end - def fix_implicit_addressing(object, _), do: object - def fix_addressing(object) do - {:ok, %User{} = user} = User.get_or_fetch_by_ap_id(object["actor"]) - followers_collection = User.ap_followers(user) + {:ok, %User{follower_address: follower_collection}} = + object + |> Containment.get_actor() + |> User.get_or_fetch_by_ap_id() object |> fix_addressing_list("to") |> fix_addressing_list("cc") |> fix_addressing_list("bto") |> fix_addressing_list("bcc") - |> fix_explicit_addressing() - |> fix_implicit_addressing(followers_collection) + |> fix_explicit_addressing(follower_collection) + |> fix_implicit_addressing(follower_collection) end def fix_actor(%{"attributedTo" => actor} = object) do diff --git a/test/pleroma/web/activity_pub/transmogrifier_test.exs b/test/pleroma/web/activity_pub/transmogrifier_test.exs index 4c3fcb44a..bb0b58e4d 100644 --- a/test/pleroma/web/activity_pub/transmogrifier_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier_test.exs @@ -446,7 +446,7 @@ test "moves non-explicitly mentioned actors to cc", %{user: user} do end) } - fixed_object = Transmogrifier.fix_explicit_addressing(object) + fixed_object = Transmogrifier.fix_explicit_addressing(object, user.follower_address) assert Enum.all?(explicitly_mentioned_actors, &(&1 in fixed_object["to"])) refute "https://social.beepboop.ga/users/dirb" in fixed_object["to"] assert "https://social.beepboop.ga/users/dirb" in fixed_object["cc"] @@ -459,7 +459,7 @@ test "does not move actor's follower collection to cc", %{user: user} do "cc" => [] } - fixed_object = Transmogrifier.fix_explicit_addressing(object) + fixed_object = Transmogrifier.fix_explicit_addressing(object, user.follower_address) assert user.follower_address in fixed_object["to"] refute user.follower_address in fixed_object["cc"] end @@ -473,7 +473,7 @@ test "removes recipient's follower collection from cc", %{user: user} do "cc" => [user.follower_address, recipient.follower_address] } - fixed_object = Transmogrifier.fix_explicit_addressing(object) + fixed_object = Transmogrifier.fix_explicit_addressing(object, user.follower_address) assert user.follower_address in fixed_object["cc"] refute recipient.follower_address in fixed_object["cc"] From e2a3365b5ce86293a5fed28c06b2e7d9dd97c9d1 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Thu, 10 Sep 2020 11:08:05 +0200 Subject: [PATCH 144/339] ObjectValidator.CommonFixes: Introduce fix_objects_defaults and fix_activity_defaults --- .../object_validators/recipients.ex | 22 +++++++++------ .../article_note_validator.ex | 3 +- .../audio_video_validator.ex | 3 +- .../object_validators/common_fixes.ex | 28 +++++++++++++++---- .../create_generic_validator.ex | 12 +------- .../object_validators/event_validator.ex | 4 +-- .../object_validators/question_validator.ex | 4 +-- .../object_validators/recipients_test.exs | 2 +- .../transmogrifier/audio_handling_test.exs | 6 +++- .../transmogrifier/event_handling_test.exs | 2 +- 10 files changed, 50 insertions(+), 36 deletions(-) diff --git a/lib/pleroma/ecto_type/activity_pub/object_validators/recipients.ex b/lib/pleroma/ecto_type/activity_pub/object_validators/recipients.ex index af4b0e527..b76547e75 100644 --- a/lib/pleroma/ecto_type/activity_pub/object_validators/recipients.ex +++ b/lib/pleroma/ecto_type/activity_pub/object_validators/recipients.ex @@ -15,19 +15,23 @@ def cast(object) when is_binary(object) do def cast(data) when is_list(data) do data - |> Enum.reduce_while({:ok, []}, fn element, {:ok, list} -> - case ObjectID.cast(element) do - {:ok, id} -> - {:cont, {:ok, [id | list]}} + |> Enum.reduce_while({:ok, []}, fn + nil, {:ok, list} -> + {:cont, {:ok, list}} - _ -> - {:halt, :error} - end + element, {:ok, list} -> + case ObjectID.cast(element) do + {:ok, id} -> + {:cont, {:ok, [id | list]}} + + _ -> + {:halt, {:error, element}} + end end) end - def cast(_) do - :error + def cast(data) do + {:error, data} end def dump(data) do diff --git a/lib/pleroma/web/activity_pub/object_validators/article_note_validator.ex b/lib/pleroma/web/activity_pub/object_validators/article_note_validator.ex index 39ef6dc29..d2026b5ea 100644 --- a/lib/pleroma/web/activity_pub/object_validators/article_note_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/article_note_validator.ex @@ -79,9 +79,8 @@ defp fix_url(data), do: data defp fix(data) do data - |> CommonFixes.fix_defaults() - |> CommonFixes.fix_attribution() |> CommonFixes.fix_actor() + |> CommonFixes.fix_object_defaults() |> fix_url() |> Transmogrifier.fix_emoji() end diff --git a/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex b/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex index 8a5a60526..8ee432947 100644 --- a/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex @@ -120,9 +120,8 @@ defp fix_content(data), do: data defp fix(data) do data - |> CommonFixes.fix_defaults() - |> CommonFixes.fix_attribution() |> CommonFixes.fix_actor() + |> CommonFixes.fix_object_defaults() |> Transmogrifier.fix_emoji() |> fix_url() |> fix_content() diff --git a/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex b/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex index 5f2c633bc..950eb1494 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex @@ -3,26 +3,44 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes do + alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Object.Containment + alias Pleroma.User + alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.Utils - # based on Pleroma.Web.ActivityPub.Utils.lazy_put_objects_defaults - def fix_defaults(data) do + def fix_object_defaults(data) do %{data: %{"id" => context}, id: context_id} = Utils.create_context(data["context"] || data["conversation"]) + %User{follower_address: follower_collection} = User.get_cached_by_ap_id(data["attributedTo"]) + {:ok, to} = ObjectValidators.Recipients.cast(data["to"] || []) + {:ok, cc} = ObjectValidators.Recipients.cast(data["cc"] || []) + data |> Map.put("context", context) |> Map.put("context_id", context_id) + |> Map.put("to", to) + |> Map.put("cc", cc) + |> Transmogrifier.fix_explicit_addressing(follower_collection) + |> Transmogrifier.fix_implicit_addressing(follower_collection) end - def fix_attribution(data) do + def fix_activity_defaults(data, meta) do + object = meta[:object_data] || %{} + data - |> Map.put_new("actor", data["attributedTo"]) + |> Map.put_new("to", object["to"] || []) + |> Map.put_new("cc", object["cc"] || []) + |> Map.put_new("bto", object["bto"] || []) + |> Map.put_new("bcc", object["bcc"] || []) end def fix_actor(data) do - actor = Containment.get_actor(data) + actor = + data + |> Map.put_new("actor", data["attributedTo"]) + |> Containment.get_actor() data |> Map.put("actor", actor) diff --git a/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex index e06e442f4..99e8dc6c7 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex @@ -62,21 +62,11 @@ defp fix_context(data, meta) do end end - defp fix_addressing(data, meta) do - if object = meta[:object_data] do - data - |> Map.put_new("to", object["to"] || []) - |> Map.put_new("cc", object["cc"] || []) - else - data - end - end - defp fix(data, meta) do data |> fix_context(meta) - |> fix_addressing(meta) |> CommonFixes.fix_actor() + |> CommonFixes.fix_activity_defaults(meta) end defp validate_data(cng, meta) do diff --git a/lib/pleroma/web/activity_pub/object_validators/event_validator.ex b/lib/pleroma/web/activity_pub/object_validators/event_validator.ex index d42458ef5..fee2e997a 100644 --- a/lib/pleroma/web/activity_pub/object_validators/event_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/event_validator.ex @@ -72,8 +72,8 @@ def cast_data(data) do defp fix(data) do data - |> CommonFixes.fix_defaults() - |> CommonFixes.fix_attribution() + |> CommonFixes.fix_actor() + |> CommonFixes.fix_object_defaults() |> Transmogrifier.fix_emoji() end diff --git a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex index 7012e2e1d..083d08ec4 100644 --- a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex @@ -83,8 +83,8 @@ defp fix_closed(data) do defp fix(data) do data - |> CommonFixes.fix_defaults() - |> CommonFixes.fix_attribution() + |> CommonFixes.fix_actor() + |> CommonFixes.fix_object_defaults() |> Transmogrifier.fix_emoji() |> fix_closed() end diff --git a/test/pleroma/ecto_type/activity_pub/object_validators/recipients_test.exs b/test/pleroma/ecto_type/activity_pub/object_validators/recipients_test.exs index d3a2fd13f..ce8bef39f 100644 --- a/test/pleroma/ecto_type/activity_pub/object_validators/recipients_test.exs +++ b/test/pleroma/ecto_type/activity_pub/object_validators/recipients_test.exs @@ -9,7 +9,7 @@ defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.RecipientsTest do test "it asserts that all elements of the list are object ids" do list = ["https://lain.com/users/lain", "invalid"] - assert :error == Recipients.cast(list) + assert {:error, "invalid"} == Recipients.cast(list) end test "it works with a list" do diff --git a/test/pleroma/web/activity_pub/transmogrifier/audio_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/audio_handling_test.exs index e733f167d..032ad24b5 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/audio_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/audio_handling_test.exs @@ -24,6 +24,8 @@ test "it works for incoming listens" do "actor" => "http://mastodon.example.org/users/admin", "object" => %{ "type" => "Audio", + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [], "id" => "http://mastodon.example.org/users/admin/listens/1234", "attributedTo" => "http://mastodon.example.org/users/admin", "title" => "lain radio episode 1", @@ -61,7 +63,9 @@ test "Funkwhale Audio object" do assert object.data["to"] == ["https://www.w3.org/ns/activitystreams#Public"] - assert object.data["cc"] == [] + assert object.data["cc"] == [ + "https://channels.tests.funkwhale.audio/federation/actors/compositions/followers" + ] assert object.data["url"] == "https://channels.tests.funkwhale.audio/library/tracks/74" diff --git a/test/pleroma/web/activity_pub/transmogrifier/event_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/event_handling_test.exs index c4879fda1..14f5f704a 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/event_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/event_handling_test.exs @@ -31,7 +31,7 @@ test "Mobilizon Event object" do ) assert object.data["to"] == ["https://www.w3.org/ns/activitystreams#Public"] - assert object.data["cc"] == [] + assert object.data["cc"] == ["https://mobilizon.org/@tcit/followers"] assert object.data["url"] == "https://mobilizon.org/events/252d5816-00a3-4a89-a66f-15bf65c33e39" From c9449326747f8d33357f5179e69d3024b39089a0 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Thu, 10 Sep 2020 11:11:10 +0200 Subject: [PATCH 145/339] Pipeline Ingestion: Note --- .../object_validators/recipients.ex | 25 +-- lib/pleroma/web/activity_pub/activity_pub.ex | 2 +- .../web/activity_pub/object_validator.ex | 7 +- .../article_note_validator.ex | 29 +++- .../object_validators/common_fixes.ex | 18 +- .../object_validators/common_validations.ex | 1 + .../create_note_validator.ex | 29 ---- lib/pleroma/web/activity_pub/side_effects.ex | 15 +- .../web/activity_pub/transmogrifier.ex | 12 +- lib/pleroma/web/federator.ex | 5 + .../activitypub-client-post-activity.json | 1 + test/pleroma/activity_test.exs | 4 +- .../object_validators/recipients_test.exs | 4 +- test/pleroma/notification_test.exs | 6 + .../activity_pub_controller_test.exs | 45 ++--- .../transmogrifier/note_handling_test.exs | 155 ++++++++---------- .../web/activity_pub/transmogrifier_test.exs | 4 +- test/pleroma/web/federator_test.exs | 6 +- .../static_fe/static_fe_controller_test.exs | 13 +- 19 files changed, 202 insertions(+), 179 deletions(-) delete mode 100644 lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex diff --git a/lib/pleroma/ecto_type/activity_pub/object_validators/recipients.ex b/lib/pleroma/ecto_type/activity_pub/object_validators/recipients.ex index b76547e75..a03471462 100644 --- a/lib/pleroma/ecto_type/activity_pub/object_validators/recipients.ex +++ b/lib/pleroma/ecto_type/activity_pub/object_validators/recipients.ex @@ -13,20 +13,23 @@ def cast(object) when is_binary(object) do cast([object]) end + def cast(object) when is_map(object) do + case ObjectID.cast(object) do + {:ok, data} -> {:ok, data} + _ -> :error + end + end + def cast(data) when is_list(data) do data - |> Enum.reduce_while({:ok, []}, fn - nil, {:ok, list} -> - {:cont, {:ok, list}} + |> Enum.reduce_while({:ok, []}, fn element, {:ok, list} -> + case ObjectID.cast(element) do + {:ok, id} -> + {:cont, {:ok, [id | list]}} - element, {:ok, list} -> - case ObjectID.cast(element) do - {:ok, id} -> - {:cont, {:ok, [id | list]}} - - _ -> - {:halt, {:error, element}} - end + _ -> + {:cont, {:ok, list}} + end end) end diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index efbf92c70..b74af3f3b 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -88,7 +88,7 @@ defp increase_replies_count_if_reply(%{ defp increase_replies_count_if_reply(_create_data), do: :noop - @object_types ~w[ChatMessage Question Answer Audio Video Event Article] + @object_types ~w[ChatMessage Question Answer Audio Video Event Article Note] @impl true def persist(%{"type" => type} = object, meta) when type in @object_types do with {:ok, object} <- Object.create(object) do diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 70d9a35a9..e5b35cdd4 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -101,7 +101,7 @@ def validate( %{"type" => "Create", "object" => %{"type" => objtype} = object} = create_activity, meta ) - when objtype in ~w[Question Answer Audio Video Event Article] do + when objtype in ~w[Question Answer Audio Video Event Article Note] do with {:ok, object_data} <- cast_and_apply(object), meta = Keyword.put(meta, :object_data, object_data |> stringify_keys), {:ok, create_activity} <- @@ -114,7 +114,7 @@ def validate( end def validate(%{"type" => type} = object, meta) - when type in ~w[Event Question Audio Video Article] do + when type in ~w[Event Question Audio Video Article Note] do validator = case type do "Event" -> EventValidator @@ -122,6 +122,7 @@ def validate(%{"type" => type} = object, meta) "Audio" -> AudioVideoValidator "Video" -> AudioVideoValidator "Article" -> ArticleNoteValidator + "Note" -> ArticleNoteValidator end with {:ok, object} <- @@ -183,7 +184,7 @@ def cast_and_apply(%{"type" => "Event"} = object) do EventValidator.cast_and_apply(object) end - def cast_and_apply(%{"type" => "Article"} = object) do + def cast_and_apply(%{"type" => type} = object) when type in ~w[Article Note] do ArticleNoteValidator.cast_and_apply(object) end diff --git a/lib/pleroma/web/activity_pub/object_validators/article_note_validator.ex b/lib/pleroma/web/activity_pub/object_validators/article_note_validator.ex index d2026b5ea..193f85f49 100644 --- a/lib/pleroma/web/activity_pub/object_validators/article_note_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/article_note_validator.ex @@ -50,6 +50,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNoteValidator do field(:likes, {:array, ObjectValidators.ObjectID}, default: []) field(:announcements, {:array, ObjectValidators.ObjectID}, default: []) + + field(:replies, {:array, ObjectValidators.ObjectID}, default: []) end def cast_and_apply(data) do @@ -65,24 +67,39 @@ def cast_and_validate(data) do end def cast_data(data) do - data = fix(data) - %__MODULE__{} |> changeset(data) end - defp fix_url(%{"url" => url} = data) when is_map(url) do - Map.put(data, "url", url["href"]) - end - + defp fix_url(%{"url" => url} = data) when is_bitstring(url), do: data + defp fix_url(%{"url" => url} = data) when is_map(url), do: Map.put(data, "url", url["href"]) defp fix_url(data), do: data + defp fix_tag(%{"tag" => tag} = data) when is_list(tag), do: data + defp fix_tag(%{"tag" => tag} = data) when is_map(tag), do: Map.put(data, "tag", [tag]) + defp fix_tag(data), do: Map.drop(data, ["tag"]) + + defp fix_replies(%{"replies" => %{"first" => %{"items" => replies}}} = data) + when is_list(replies), + do: Map.put(data, "replies", replies) + + defp fix_replies(%{"replies" => %{"items" => replies}} = data) when is_list(replies), + do: Map.put(data, "replies", replies) + + defp fix_replies(%{"replies" => replies} = data) when is_bitstring(replies), + do: Map.drop(data, ["replies"]) + + defp fix_replies(data), do: data + defp fix(data) do data |> CommonFixes.fix_actor() |> CommonFixes.fix_object_defaults() |> fix_url() + |> fix_tag() + |> fix_replies() |> Transmogrifier.fix_emoji() + |> Transmogrifier.fix_content_map() end def changeset(struct, data) do diff --git a/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex b/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex index 950eb1494..7309f6af2 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex @@ -26,14 +26,20 @@ def fix_object_defaults(data) do |> Transmogrifier.fix_implicit_addressing(follower_collection) end - def fix_activity_defaults(data, meta) do + defp fix_activity_recipients(activity, field, object) do + {:ok, data} = ObjectValidators.Recipients.cast(activity[field] || object[field]) + + Map.put(activity, field, data) + end + + def fix_activity_defaults(activity, meta) do object = meta[:object_data] || %{} - data - |> Map.put_new("to", object["to"] || []) - |> Map.put_new("cc", object["cc"] || []) - |> Map.put_new("bto", object["bto"] || []) - |> Map.put_new("bcc", object["bcc"] || []) + activity + |> fix_activity_recipients("to", object) + |> fix_activity_recipients("cc", object) + |> fix_activity_recipients("bto", object) + |> fix_activity_recipients("bcc", object) end def fix_actor(data) do diff --git a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex index 093549a45..85ac07044 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_validations.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_validations.ex @@ -14,6 +14,7 @@ def validate_any_presence(cng, fields) do fields |> Enum.map(fn field -> get_field(cng, field) end) |> Enum.any?(fn + nil -> false [] -> false _ -> true end) diff --git a/lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex deleted file mode 100644 index a85a0298c..000000000 --- a/lib/pleroma/web/activity_pub/object_validators/create_note_validator.ex +++ /dev/null @@ -1,29 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only - -defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateNoteValidator do - use Ecto.Schema - - alias Pleroma.EctoType.ActivityPub.ObjectValidators - alias Pleroma.Web.ActivityPub.ObjectValidators.NoteValidator - - import Ecto.Changeset - - @primary_key false - - embedded_schema do - field(:id, ObjectValidators.ObjectID, primary_key: true) - field(:actor, ObjectValidators.ObjectID) - field(:type, :string) - field(:to, ObjectValidators.Recipients, default: []) - field(:cc, ObjectValidators.Recipients, default: []) - field(:bto, ObjectValidators.Recipients, default: []) - field(:bcc, ObjectValidators.Recipients, default: []) - embeds_one(:object, NoteValidator) - end - - def cast_data(data) do - cast(%__MODULE__{}, data, __schema__(:fields)) - end -end diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 0b9a9f0c5..3234b9e43 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -203,6 +203,19 @@ def handle(%{data: %{"type" => "Create"}} = activity, meta) do Object.increase_replies_count(in_reply_to) end + reply_depth = (meta[:depth] || 0) + 1 + + # FIXME: Force inReplyTo to replies + if Pleroma.Web.Federator.allowed_thread_distance?(reply_depth) and + object.data["replies"] != nil do + for reply_id <- object.data["replies"] do + Pleroma.Workers.RemoteFetcherWorker.enqueue("fetch_remote", %{ + "id" => reply_id, + "depth" => reply_depth + }) + end + end + ConcurrentLimiter.limit(Pleroma.Web.RichMedia.Helpers, fn -> Task.start(fn -> Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity) end) end) @@ -366,7 +379,7 @@ def handle_object_creation(%{"type" => "Answer"} = object_map, meta) do end def handle_object_creation(%{"type" => objtype} = object, meta) - when objtype in ~w[Audio Video Question Event Article] do + when objtype in ~w[Audio Video Question Event Article Note] do with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do {:ok, object, meta} end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 047f23918..28bc25363 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -404,10 +404,9 @@ def handle_incoming(%{"id" => id}, _options) when is_binary(id) and byte_size(id # - tags # - emoji def handle_incoming( - %{"type" => "Create", "object" => %{"type" => objtype} = object} = data, + %{"type" => "Create", "object" => %{"type" => "Page"} = object} = data, options - ) - when objtype in ~w{Note Page} do + ) do actor = Containment.get_actor(data) with nil <- Activity.get_create_by_object_ap_id(object["id"]), @@ -499,14 +498,15 @@ def handle_incoming( def handle_incoming( %{"type" => "Create", "object" => %{"type" => objtype, "id" => obj_id}} = data, - _options + options ) - when objtype in ~w{Question Answer ChatMessage Audio Video Event Article} do + when objtype in ~w{Question Answer ChatMessage Audio Video Event Article Note} do data = Map.put(data, "object", strip_internal_fields(data["object"])) + options = Keyword.put(options, :local, false) with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), nil <- Activity.get_create_by_object_ap_id(obj_id), - {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do + {:ok, activity, _} <- Pipeline.common_pipeline(data, options) do {:ok, activity} else %Activity{} = activity -> {:ok, activity} diff --git a/lib/pleroma/web/federator.ex b/lib/pleroma/web/federator.ex index f5ef76d32..69cfc2d52 100644 --- a/lib/pleroma/web/federator.ex +++ b/lib/pleroma/web/federator.ex @@ -96,6 +96,11 @@ def perform(:incoming_ap_doc, params) do Logger.debug("Unhandled actor #{actor}, #{inspect(e)}") {:error, e} + {:error, {:validate_object, _}} = e -> + Logger.error("Incoming AP doc validation error: #{inspect(e)}") + Logger.debug(Jason.encode!(params, pretty: true)) + e + e -> # Just drop those for now Logger.debug(fn -> "Unhandled activity\n" <> Jason.encode!(params, pretty: true) end) diff --git a/test/fixtures/activitypub-client-post-activity.json b/test/fixtures/activitypub-client-post-activity.json index c985e072b..e592081bc 100644 --- a/test/fixtures/activitypub-client-post-activity.json +++ b/test/fixtures/activitypub-client-post-activity.json @@ -3,6 +3,7 @@ "type": "Create", "object": { "type": "Note", + "to": ["https://www.w3.org/ns/activitystreams#Public"], "content": "It's a note" }, "to": ["https://www.w3.org/ns/activitystreams#Public"] diff --git a/test/pleroma/activity_test.exs b/test/pleroma/activity_test.exs index 390a06344..9911aa45c 100644 --- a/test/pleroma/activity_test.exs +++ b/test/pleroma/activity_test.exs @@ -123,7 +123,8 @@ test "when association is not loaded" do "type" => "Note", "content" => "find me!", "id" => "http://mastodon.example.org/users/admin/objects/1", - "attributedTo" => "http://mastodon.example.org/users/admin" + "attributedTo" => "http://mastodon.example.org/users/admin", + "to" => ["https://www.w3.org/ns/activitystreams#Public"] }, "to" => ["https://www.w3.org/ns/activitystreams#Public"] } @@ -132,6 +133,7 @@ test "when association is not loaded" do {:ok, japanese_activity} = Pleroma.Web.CommonAPI.post(user, %{status: "更新情報"}) {:ok, job} = Pleroma.Web.Federator.incoming_ap_doc(params) {:ok, remote_activity} = ObanHelpers.perform(job) + remote_activity = Activity.get_by_id_with_object(remote_activity.id) %{ japanese_activity: japanese_activity, diff --git a/test/pleroma/ecto_type/activity_pub/object_validators/recipients_test.exs b/test/pleroma/ecto_type/activity_pub/object_validators/recipients_test.exs index ce8bef39f..4cdafa898 100644 --- a/test/pleroma/ecto_type/activity_pub/object_validators/recipients_test.exs +++ b/test/pleroma/ecto_type/activity_pub/object_validators/recipients_test.exs @@ -6,10 +6,10 @@ defmodule Pleroma.EctoType.ActivityPub.ObjectValidators.RecipientsTest do alias Pleroma.EctoType.ActivityPub.ObjectValidators.Recipients use Pleroma.DataCase, async: true - test "it asserts that all elements of the list are object ids" do + test "it only keeps elements that are valid object ids" do list = ["https://lain.com/users/lain", "invalid"] - assert {:error, "invalid"} == Recipients.cast(list) + assert {:ok, ["https://lain.com/users/lain"]} == Recipients.cast(list) end test "it works with a list" do diff --git a/test/pleroma/notification_test.exs b/test/pleroma/notification_test.exs index abf1b0410..85f895f0f 100644 --- a/test/pleroma/notification_test.exs +++ b/test/pleroma/notification_test.exs @@ -624,6 +624,8 @@ test "it sends notifications to mentioned users in new messages" do "actor" => user.ap_id, "object" => %{ "type" => "Note", + "id" => Pleroma.Web.ActivityPub.Utils.generate_object_id(), + "to" => ["https://www.w3.org/ns/activitystreams#Public"], "content" => "message with a Mention tag, but no explicit tagging", "tag" => [ %{ @@ -655,6 +657,9 @@ test "it does not send notifications to users who are only cc in new messages" d "actor" => user.ap_id, "object" => %{ "type" => "Note", + "id" => Pleroma.Web.ActivityPub.Utils.generate_object_id(), + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "cc" => [other_user.ap_id], "content" => "hi everyone", "attributedTo" => user.ap_id } @@ -951,6 +956,7 @@ test "notifications are deleted if a remote user is deleted" do "cc" => [], "object" => %{ "type" => "Note", + "id" => remote_user.ap_id <> "/objects/test", "content" => "Hello!", "tag" => [ %{ diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index 19e04d472..2de52323e 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -539,7 +539,7 @@ test "it inserts an incoming activity into the database" <> File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!() |> Map.put("actor", user.ap_id) - |> put_in(["object", "attridbutedTo"], user.ap_id) + |> put_in(["object", "attributedTo"], user.ap_id) conn = conn @@ -820,29 +820,34 @@ test "it clears `unreachable` federation status of the sender", %{conn: conn, da assert Instances.reachable?(sender_host) end + @tag capture_log: true test "it removes all follower collections but actor's", %{conn: conn} do [actor, recipient] = insert_pair(:user) - data = - File.read!("test/fixtures/activitypub-client-post-activity.json") - |> Jason.decode!() + to = [ + recipient.ap_id, + recipient.follower_address, + "https://www.w3.org/ns/activitystreams#Public" + ] - object = Map.put(data["object"], "attributedTo", actor.ap_id) + cc = [recipient.follower_address, actor.follower_address] - data = - data - |> Map.put("id", Utils.generate_object_id()) - |> Map.put("actor", actor.ap_id) - |> Map.put("object", object) - |> Map.put("cc", [ - recipient.follower_address, - actor.follower_address - ]) - |> Map.put("to", [ - recipient.ap_id, - recipient.follower_address, - "https://www.w3.org/ns/activitystreams#Public" - ]) + data = %{ + "@context" => ["https://www.w3.org/ns/activitystreams"], + "type" => "Create", + "id" => Utils.generate_activity_id(), + "to" => to, + "cc" => cc, + "actor" => actor.ap_id, + "object" => %{ + "type" => "Note", + "to" => to, + "cc" => cc, + "content" => "It's a note", + "attributedTo" => actor.ap_id, + "id" => Utils.generate_object_id() + } + } conn |> assign(:valid_signature, true) @@ -852,7 +857,7 @@ test "it removes all follower collections but actor's", %{conn: conn} do ObanHelpers.perform(all_enqueued(worker: ReceiverWorker)) - activity = Activity.get_by_ap_id(data["id"]) + assert activity = Activity.get_by_ap_id(data["id"]) assert activity.id assert actor.follower_address in activity.recipients diff --git a/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs index deb956410..3eeae4004 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs @@ -14,7 +14,6 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.NoteHandlingTest do import Mock import Pleroma.Factory - import ExUnit.CaptureLog setup_all do Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) @@ -147,9 +146,7 @@ test "it does not crash if the object in inReplyTo can't be fetched" do data |> Map.put("object", object) - assert capture_log(fn -> - {:ok, _returned_activity} = Transmogrifier.handle_incoming(data) - end) =~ "[warn] Couldn't fetch \"https://404.site/whatever\", error: nil" + assert {:ok, _returned_activity} = Transmogrifier.handle_incoming(data) end test "it does not work for deactivated users" do @@ -221,8 +218,25 @@ test "it works for incoming notices with hashtags" do {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) object = Object.normalize(data["object"], fetch: false) - assert Enum.at(Object.tags(object), 2) == "moo" - assert Object.hashtags(object) == ["moo"] + assert match?( + %{ + "href" => "http://localtesting.pleroma.lol/users/lain", + "name" => "@lain@localtesting.pleroma.lol", + "type" => "Mention" + }, + Enum.at(object.data["tag"], 0) + ) + + assert match?( + %{ + "href" => "http://mastodon.example.org/tags/moo", + "name" => "#moo", + "type" => "Hashtag" + }, + Enum.at(object.data["tag"], 1) + ) + + assert "moo" == Enum.at(object.data["tag"], 2) end test "it works for incoming notices with contentMap" do @@ -276,13 +290,11 @@ test "it ensures that address fields become lists" do File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!() |> Map.put("actor", user.ap_id) - |> Map.put("to", nil) |> Map.put("cc", nil) object = data["object"] |> Map.put("attributedTo", user.ap_id) - |> Map.put("to", nil) |> Map.put("cc", nil) |> Map.put("id", user.ap_id <> "/activities/12345678") @@ -290,8 +302,7 @@ test "it ensures that address fields become lists" do {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - assert !is_nil(data["to"]) - assert !is_nil(data["cc"]) + refute is_nil(data["cc"]) end test "it strips internal likes" do @@ -330,70 +341,46 @@ test "it strips internal reactions" do end test "it correctly processes messages with non-array to field" do - user = insert(:user) + data = + File.read!("test/fixtures/mastodon-post-activity.json") + |> Poison.decode!() + |> Map.put("to", "https://www.w3.org/ns/activitystreams#Public") + |> put_in(["object", "to"], "https://www.w3.org/ns/activitystreams#Public") - message = %{ - "@context" => "https://www.w3.org/ns/activitystreams", - "to" => "https://www.w3.org/ns/activitystreams#Public", - "type" => "Create", - "object" => %{ - "content" => "blah blah blah", - "type" => "Note", - "attributedTo" => user.ap_id, - "inReplyTo" => nil - }, - "actor" => user.ap_id - } + assert {:ok, activity} = Transmogrifier.handle_incoming(data) - assert {:ok, activity} = Transmogrifier.handle_incoming(message) + assert [ + "http://mastodon.example.org/users/admin/followers", + "http://localtesting.pleroma.lol/users/lain" + ] == activity.data["cc"] assert ["https://www.w3.org/ns/activitystreams#Public"] == activity.data["to"] end test "it correctly processes messages with non-array cc field" do - user = insert(:user) + data = + File.read!("test/fixtures/mastodon-post-activity.json") + |> Poison.decode!() + |> Map.put("cc", "http://mastodon.example.org/users/admin/followers") + |> put_in(["object", "cc"], "http://mastodon.example.org/users/admin/followers") - message = %{ - "@context" => "https://www.w3.org/ns/activitystreams", - "to" => user.follower_address, - "cc" => "https://www.w3.org/ns/activitystreams#Public", - "type" => "Create", - "object" => %{ - "content" => "blah blah blah", - "type" => "Note", - "attributedTo" => user.ap_id, - "inReplyTo" => nil - }, - "actor" => user.ap_id - } + assert {:ok, activity} = Transmogrifier.handle_incoming(data) - assert {:ok, activity} = Transmogrifier.handle_incoming(message) - - assert ["https://www.w3.org/ns/activitystreams#Public"] == activity.data["cc"] - assert [user.follower_address] == activity.data["to"] + assert ["http://mastodon.example.org/users/admin/followers"] == activity.data["cc"] + assert ["https://www.w3.org/ns/activitystreams#Public"] == activity.data["to"] end test "it correctly processes messages with weirdness in address fields" do - user = insert(:user) + data = + File.read!("test/fixtures/mastodon-post-activity.json") + |> Poison.decode!() + |> Map.put("cc", ["http://mastodon.example.org/users/admin/followers", ["¿"]]) + |> put_in(["object", "cc"], ["http://mastodon.example.org/users/admin/followers", ["¿"]]) - message = %{ - "@context" => "https://www.w3.org/ns/activitystreams", - "to" => [nil, user.follower_address], - "cc" => ["https://www.w3.org/ns/activitystreams#Public", ["¿"]], - "type" => "Create", - "object" => %{ - "content" => "…", - "type" => "Note", - "attributedTo" => user.ap_id, - "inReplyTo" => nil - }, - "actor" => user.ap_id - } + assert {:ok, activity} = Transmogrifier.handle_incoming(data) - assert {:ok, activity} = Transmogrifier.handle_incoming(message) - - assert ["https://www.w3.org/ns/activitystreams#Public"] == activity.data["cc"] - assert [user.follower_address] == activity.data["to"] + assert ["http://mastodon.example.org/users/admin/followers"] == activity.data["cc"] + assert ["https://www.w3.org/ns/activitystreams#Public"] == activity.data["to"] end end @@ -419,7 +406,11 @@ test "schedules background fetching of `replies` items if max thread depth limit } do clear_config([:instance, :federation_incoming_replies_max_depth], 10) - {:ok, _activity} = Transmogrifier.handle_incoming(data) + {:ok, activity} = Transmogrifier.handle_incoming(data) + + object = Object.normalize(activity.data["object"]) + + assert object.data["replies"] == items for id <- items do job_args = %{"op" => "fetch_remote", "id" => id, "depth" => 1} @@ -442,45 +433,41 @@ test "does NOT schedule background fetching of `replies` beyond max thread depth setup do: clear_config([:instance, :federation_incoming_replies_max_depth]) setup do - user = insert(:user) + replies = %{ + "type" => "Collection", + "items" => [ + Pleroma.Web.ActivityPub.Utils.generate_object_id(), + Pleroma.Web.ActivityPub.Utils.generate_object_id() + ] + } - {:ok, activity} = CommonAPI.post(user, %{status: "post1"}) + activity = + File.read!("test/fixtures/mastodon-post-activity.json") + |> Poison.decode!() + |> Kernel.put_in(["object", "replies"], replies) - {:ok, reply1} = - CommonAPI.post(user, %{status: "reply1", in_reply_to_status_id: activity.id}) - - {:ok, reply2} = - CommonAPI.post(user, %{status: "reply2", in_reply_to_status_id: activity.id}) - - replies_uris = Enum.map([reply1, reply2], fn a -> a.object.data["id"] end) - - {:ok, federation_output} = Transmogrifier.prepare_outgoing(activity.data) - - Repo.delete(activity.object) - Repo.delete(activity) - - %{federation_output: federation_output, replies_uris: replies_uris} + %{activity: activity} end test "schedules background fetching of `replies` items if max thread depth limit allows", %{ - federation_output: federation_output, - replies_uris: replies_uris + activity: activity } do clear_config([:instance, :federation_incoming_replies_max_depth], 1) - {:ok, _activity} = Transmogrifier.handle_incoming(federation_output) + assert {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(activity) + object = Object.normalize(data["object"]) - for id <- replies_uris do + for id <- object.data["replies"] do job_args = %{"op" => "fetch_remote", "id" => id, "depth" => 1} assert_enqueued(worker: Pleroma.Workers.RemoteFetcherWorker, args: job_args) end end test "does NOT schedule background fetching of `replies` beyond max thread depth limit allows", - %{federation_output: federation_output} do + %{activity: activity} do clear_config([:instance, :federation_incoming_replies_max_depth], 0) - {:ok, _activity} = Transmogrifier.handle_incoming(federation_output) + {:ok, _activity} = Transmogrifier.handle_incoming(activity) assert all_enqueued(worker: Pleroma.Workers.RemoteFetcherWorker) == [] end @@ -498,6 +485,7 @@ test "successfully reserializes a message with inReplyTo == nil" do "object" => %{ "to" => ["https://www.w3.org/ns/activitystreams#Public"], "cc" => [], + "id" => Utils.generate_object_id(), "type" => "Note", "content" => "Hi", "inReplyTo" => nil, @@ -522,6 +510,7 @@ test "successfully reserializes a message with AS2 objects in IR" do "object" => %{ "to" => ["https://www.w3.org/ns/activitystreams#Public"], "cc" => [], + "id" => Utils.generate_object_id(), "type" => "Note", "content" => "Hi", "inReplyTo" => nil, diff --git a/test/pleroma/web/activity_pub/transmogrifier_test.exs b/test/pleroma/web/activity_pub/transmogrifier_test.exs index bb0b58e4d..5a3b57acb 100644 --- a/test/pleroma/web/activity_pub/transmogrifier_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier_test.exs @@ -11,6 +11,7 @@ defmodule Pleroma.Web.ActivityPub.TransmogrifierTest do alias Pleroma.Tests.ObanHelpers alias Pleroma.User alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.AdminAPI.AccountView alias Pleroma.Web.CommonAPI @@ -159,8 +160,7 @@ test "it adds the json-ld context and the conversation property" do {:ok, activity} = CommonAPI.post(user, %{status: "hey"}) {:ok, modified} = Transmogrifier.prepare_outgoing(activity.data) - assert modified["@context"] == - Pleroma.Web.ActivityPub.Utils.make_json_ld_header()["@context"] + assert modified["@context"] == Utils.make_json_ld_header()["@context"] assert modified["object"]["conversation"] == modified["context"] end diff --git a/test/pleroma/web/federator_test.exs b/test/pleroma/web/federator_test.exs index 532ee6d30..372b6a73a 100644 --- a/test/pleroma/web/federator_test.exs +++ b/test/pleroma/web/federator_test.exs @@ -123,7 +123,8 @@ test "successfully processes incoming AP docs with correct origin" do "type" => "Note", "content" => "hi world!", "id" => "http://mastodon.example.org/users/admin/objects/1", - "attributedTo" => "http://mastodon.example.org/users/admin" + "attributedTo" => "http://mastodon.example.org/users/admin", + "to" => ["https://www.w3.org/ns/activitystreams#Public"] }, "to" => ["https://www.w3.org/ns/activitystreams#Public"] } @@ -145,7 +146,8 @@ test "rejects incoming AP docs with incorrect origin" do "type" => "Note", "content" => "hi world!", "id" => "http://mastodon.example.org/users/admin/objects/1", - "attributedTo" => "http://mastodon.example.org/users/admin" + "attributedTo" => "http://mastodon.example.org/users/admin", + "to" => ["https://www.w3.org/ns/activitystreams#Public"] }, "to" => ["https://www.w3.org/ns/activitystreams#Public"] } diff --git a/test/pleroma/web/static_fe/static_fe_controller_test.exs b/test/pleroma/web/static_fe/static_fe_controller_test.exs index 2af14dfeb..5752cffda 100644 --- a/test/pleroma/web/static_fe/static_fe_controller_test.exs +++ b/test/pleroma/web/static_fe/static_fe_controller_test.exs @@ -7,6 +7,7 @@ defmodule Pleroma.Web.StaticFE.StaticFEControllerTest do alias Pleroma.Activity alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI import Pleroma.Factory @@ -185,16 +186,16 @@ test "404 for private status", %{conn: conn, user: user} do test "302 for remote cached status", %{conn: conn, user: user} do message = %{ "@context" => "https://www.w3.org/ns/activitystreams", - "to" => user.follower_address, - "cc" => "https://www.w3.org/ns/activitystreams#Public", "type" => "Create", + "actor" => user.ap_id, "object" => %{ + "to" => user.follower_address, + "cc" => "https://www.w3.org/ns/activitystreams#Public", + "id" => Utils.generate_object_id(), "content" => "blah blah blah", "type" => "Note", - "attributedTo" => user.ap_id, - "inReplyTo" => nil - }, - "actor" => user.ap_id + "attributedTo" => user.ap_id + } } assert {:ok, activity} = Transmogrifier.handle_incoming(message) From 641184fc7aff694e4e7e802b9204a1d313c0877c Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Thu, 10 Sep 2020 19:45:42 +0200 Subject: [PATCH 146/339] recipients fixes/hardening for CreateGenericValidator --- .../object_validators/recipients.ex | 25 ++++---- .../object_validators/common_fixes.ex | 34 ++++++----- .../create_generic_validator.ex | 60 +++++++++++++------ .../transmogrifier/note_handling_test.exs | 12 ++-- 4 files changed, 82 insertions(+), 49 deletions(-) diff --git a/lib/pleroma/ecto_type/activity_pub/object_validators/recipients.ex b/lib/pleroma/ecto_type/activity_pub/object_validators/recipients.ex index a03471462..06fed8fb3 100644 --- a/lib/pleroma/ecto_type/activity_pub/object_validators/recipients.ex +++ b/lib/pleroma/ecto_type/activity_pub/object_validators/recipients.ex @@ -15,22 +15,27 @@ def cast(object) when is_binary(object) do def cast(object) when is_map(object) do case ObjectID.cast(object) do - {:ok, data} -> {:ok, data} + {:ok, data} -> {:ok, [data]} _ -> :error end end def cast(data) when is_list(data) do - data - |> Enum.reduce_while({:ok, []}, fn element, {:ok, list} -> - case ObjectID.cast(element) do - {:ok, id} -> - {:cont, {:ok, [id | list]}} + data = + data + |> Enum.reduce_while([], fn element, list -> + case ObjectID.cast(element) do + {:ok, id} -> + {:cont, [id | list]} - _ -> - {:cont, {:ok, list}} - end - end) + _ -> + {:cont, list} + end + end) + |> Enum.sort() + |> Enum.uniq() + + {:ok, data} end def cast(data) do diff --git a/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex b/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex index 7309f6af2..009cd51b0 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex @@ -9,37 +9,39 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes do alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.Utils + def cast_recipients(message, field, field_fallback \\ []) do + {:ok, data} = ObjectValidators.Recipients.cast(message[field] || field_fallback) + + Map.put(message, field, data) + end + def fix_object_defaults(data) do %{data: %{"id" => context}, id: context_id} = Utils.create_context(data["context"] || data["conversation"]) %User{follower_address: follower_collection} = User.get_cached_by_ap_id(data["attributedTo"]) - {:ok, to} = ObjectValidators.Recipients.cast(data["to"] || []) - {:ok, cc} = ObjectValidators.Recipients.cast(data["cc"] || []) data |> Map.put("context", context) |> Map.put("context_id", context_id) - |> Map.put("to", to) - |> Map.put("cc", cc) + |> cast_recipients("to") + |> cast_recipients("cc") + |> cast_recipients("bto") + |> cast_recipients("bcc") |> Transmogrifier.fix_explicit_addressing(follower_collection) |> Transmogrifier.fix_implicit_addressing(follower_collection) end - defp fix_activity_recipients(activity, field, object) do - {:ok, data} = ObjectValidators.Recipients.cast(activity[field] || object[field]) - - Map.put(activity, field, data) - end - - def fix_activity_defaults(activity, meta) do - object = meta[:object_data] || %{} + def fix_activity_addressing(activity, _meta) do + %User{follower_address: follower_collection} = User.get_cached_by_ap_id(activity["actor"]) activity - |> fix_activity_recipients("to", object) - |> fix_activity_recipients("cc", object) - |> fix_activity_recipients("bto", object) - |> fix_activity_recipients("bcc", object) + |> cast_recipients("to") + |> cast_recipients("cc") + |> cast_recipients("bto") + |> cast_recipients("bcc") + |> Transmogrifier.fix_explicit_addressing(follower_collection) + |> Transmogrifier.fix_implicit_addressing(follower_collection) end def fix_actor(data) do diff --git a/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex index 99e8dc6c7..51d43e8d0 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex @@ -10,8 +10,10 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateGenericValidator do alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Object + alias Pleroma.User alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations + alias Pleroma.Web.ActivityPub.Transmogrifier import Ecto.Changeset @@ -23,6 +25,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CreateGenericValidator do field(:type, :string) field(:to, ObjectValidators.Recipients, default: []) field(:cc, ObjectValidators.Recipients, default: []) + field(:bto, ObjectValidators.Recipients, default: []) + field(:bcc, ObjectValidators.Recipients, default: []) field(:object, ObjectValidators.ObjectID) field(:expires_at, ObjectValidators.DateTime) @@ -54,29 +58,38 @@ def changeset(struct, data) do |> cast(data, __schema__(:fields)) end - defp fix_context(data, meta) do - if object = meta[:object_data] do - Map.put_new(data, "context", object["context"]) - else - data - end + # CommonFixes.fix_activity_addressing adapted for Create specific behavior + defp fix_addressing(data, object) do + %User{follower_address: follower_collection} = User.get_cached_by_ap_id(data["actor"]) + + data + |> CommonFixes.cast_recipients("to", object["to"]) + |> CommonFixes.cast_recipients("cc", object["cc"]) + |> CommonFixes.cast_recipients("bto", object["bto"]) + |> CommonFixes.cast_recipients("bcc", object["bcc"]) + |> Transmogrifier.fix_explicit_addressing(follower_collection) + |> Transmogrifier.fix_implicit_addressing(follower_collection) end - defp fix(data, meta) do + def fix(data, meta) do + object = meta[:object_data] + data - |> fix_context(meta) |> CommonFixes.fix_actor() - |> CommonFixes.fix_activity_defaults(meta) + |> Map.put_new("context", object["context"]) + |> fix_addressing(object) end defp validate_data(cng, meta) do + object = meta[:object_data] + cng - |> validate_required([:actor, :type, :object]) + |> validate_required([:actor, :type, :object, :to, :cc]) |> validate_inclusion(:type, ["Create"]) |> CommonValidations.validate_actor_presence() - |> CommonValidations.validate_any_presence([:to, :cc]) - |> validate_actors_match(meta) - |> validate_context_match(meta) + |> validate_actors_match(object) + |> validate_context_match(object) + |> validate_addressing_match(object) |> validate_object_nonexistence() |> validate_object_containment() end @@ -108,8 +121,8 @@ def validate_object_nonexistence(cng) do end) end - def validate_actors_match(cng, meta) do - attributed_to = meta[:object_data]["attributedTo"] || meta[:object_data]["actor"] + def validate_actors_match(cng, object) do + attributed_to = object["attributedTo"] || object["actor"] cng |> validate_change(:actor, fn :actor, actor -> @@ -121,7 +134,7 @@ def validate_actors_match(cng, meta) do end) end - def validate_context_match(cng, %{object_data: %{"context" => object_context}}) do + def validate_context_match(cng, %{"context" => object_context}) do cng |> validate_change(:context, fn :context, context -> if context == object_context do @@ -132,5 +145,18 @@ def validate_context_match(cng, %{object_data: %{"context" => object_context}}) end) end - def validate_context_match(cng, _), do: cng + def validate_addressing_match(cng, object) do + [:to, :cc, :bcc, :bto] + |> Enum.reduce(cng, fn field, cng -> + object_data = object[to_string(field)] + + validate_change(cng, field, fn field, data -> + if data == object_data do + [] + else + [{field, "field doesn't match with object (#{inspect(object_data)})"}] + end + end) + end) + end end diff --git a/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs index 3eeae4004..b79f2c94c 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs @@ -171,8 +171,8 @@ test "it works for incoming notices" do assert data["to"] == ["https://www.w3.org/ns/activitystreams#Public"] assert data["cc"] == [ - "http://mastodon.example.org/users/admin/followers", - "http://localtesting.pleroma.lol/users/lain" + "http://localtesting.pleroma.lol/users/lain", + "http://mastodon.example.org/users/admin/followers" ] assert data["actor"] == "http://mastodon.example.org/users/admin" @@ -185,8 +185,8 @@ test "it works for incoming notices" do assert object_data["to"] == ["https://www.w3.org/ns/activitystreams#Public"] assert object_data["cc"] == [ - "http://mastodon.example.org/users/admin/followers", - "http://localtesting.pleroma.lol/users/lain" + "http://localtesting.pleroma.lol/users/lain", + "http://mastodon.example.org/users/admin/followers" ] assert object_data["actor"] == "http://mastodon.example.org/users/admin" @@ -350,8 +350,8 @@ test "it correctly processes messages with non-array to field" do assert {:ok, activity} = Transmogrifier.handle_incoming(data) assert [ - "http://mastodon.example.org/users/admin/followers", - "http://localtesting.pleroma.lol/users/lain" + "http://localtesting.pleroma.lol/users/lain", + "http://mastodon.example.org/users/admin/followers" ] == activity.data["cc"] assert ["https://www.w3.org/ns/activitystreams#Public"] == activity.data["to"] From 96212b2e32e2542964c665f091158fb1ff1d987d Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Tue, 15 Sep 2020 17:22:08 +0200 Subject: [PATCH 147/339] Fix addressing --- lib/pleroma/object/fetcher.ex | 7 ++++-- .../object_validators/common_fixes.ex | 25 +++++++++++-------- .../create_generic_validator.ex | 9 +++---- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index bcccf1c4c..82d2c8bcb 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -4,6 +4,7 @@ defmodule Pleroma.Object.Fetcher do alias Pleroma.HTTP + alias Pleroma.Maps alias Pleroma.Object alias Pleroma.Object.Containment alias Pleroma.Repo @@ -124,12 +125,14 @@ def fetch_object_from_id(id, options \\ []) do defp prepare_activity_params(data) do %{ "type" => "Create", - "to" => data["to"] || [], - "cc" => data["cc"] || [], # Should we seriously keep this attributedTo thing? "actor" => data["actor"] || data["attributedTo"], "object" => data } + |> Maps.put_if_present("to", data["to"]) + |> Maps.put_if_present("cc", data["cc"]) + |> Maps.put_if_present("bto", data["bto"]) + |> Maps.put_if_present("bcc", data["bcc"]) end def fetch_object_from_id!(id, options \\ []) do diff --git a/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex b/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex index 009cd51b0..c958fcc5d 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex @@ -9,9 +9,14 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes do alias Pleroma.Web.ActivityPub.Transmogrifier alias Pleroma.Web.ActivityPub.Utils - def cast_recipients(message, field, field_fallback \\ []) do + def cast_and_filter_recipients(message, field, follower_collection, field_fallback \\ []) do {:ok, data} = ObjectValidators.Recipients.cast(message[field] || field_fallback) + data = + Enum.reject(data, fn x -> + String.ends_with?(x, "/followers") and x != follower_collection + end) + Map.put(message, field, data) end @@ -24,11 +29,10 @@ def fix_object_defaults(data) do data |> Map.put("context", context) |> Map.put("context_id", context_id) - |> cast_recipients("to") - |> cast_recipients("cc") - |> cast_recipients("bto") - |> cast_recipients("bcc") - |> Transmogrifier.fix_explicit_addressing(follower_collection) + |> cast_and_filter_recipients("to", follower_collection) + |> cast_and_filter_recipients("cc", follower_collection) + |> cast_and_filter_recipients("bto", follower_collection) + |> cast_and_filter_recipients("bcc", follower_collection) |> Transmogrifier.fix_implicit_addressing(follower_collection) end @@ -36,11 +40,10 @@ def fix_activity_addressing(activity, _meta) do %User{follower_address: follower_collection} = User.get_cached_by_ap_id(activity["actor"]) activity - |> cast_recipients("to") - |> cast_recipients("cc") - |> cast_recipients("bto") - |> cast_recipients("bcc") - |> Transmogrifier.fix_explicit_addressing(follower_collection) + |> cast_and_filter_recipients("to", follower_collection) + |> cast_and_filter_recipients("cc", follower_collection) + |> cast_and_filter_recipients("bto", follower_collection) + |> cast_and_filter_recipients("bcc", follower_collection) |> Transmogrifier.fix_implicit_addressing(follower_collection) end diff --git a/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex index 51d43e8d0..d2de53049 100644 --- a/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/create_generic_validator.ex @@ -63,11 +63,10 @@ defp fix_addressing(data, object) do %User{follower_address: follower_collection} = User.get_cached_by_ap_id(data["actor"]) data - |> CommonFixes.cast_recipients("to", object["to"]) - |> CommonFixes.cast_recipients("cc", object["cc"]) - |> CommonFixes.cast_recipients("bto", object["bto"]) - |> CommonFixes.cast_recipients("bcc", object["bcc"]) - |> Transmogrifier.fix_explicit_addressing(follower_collection) + |> CommonFixes.cast_and_filter_recipients("to", follower_collection, object["to"]) + |> CommonFixes.cast_and_filter_recipients("cc", follower_collection, object["cc"]) + |> CommonFixes.cast_and_filter_recipients("bto", follower_collection, object["bto"]) + |> CommonFixes.cast_and_filter_recipients("bcc", follower_collection, object["bcc"]) |> Transmogrifier.fix_implicit_addressing(follower_collection) end From d1205406d9237c72d10df937dd8d2d4da2786cc5 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Tue, 15 Sep 2020 18:18:57 +0200 Subject: [PATCH 148/339] ActivityPubControllerTest: Apply same addr changes to object --- lib/pleroma/web/activity_pub/utils.ex | 5 +++- .../activity_pub_controller_test.exs | 30 ++++++++++++++----- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index a4dc469dc..e81623d83 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -97,7 +97,10 @@ def maybe_splice_recipient(ap_id, params) do if need_splice? do cc_list = extract_list(params["cc"]) - Map.put(params, "cc", [ap_id | cc_list]) + + params + |> Map.put("cc", [ap_id | cc_list]) + |> Kernel.put_in(["object", "cc"], [ap_id | cc_list]) else params end diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index 2de52323e..f6ea9e2ca 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -649,7 +649,11 @@ test "without valid signature, " <> test "it inserts an incoming activity into the database", %{conn: conn, data: data} do user = insert(:user) - data = Map.put(data, "bcc", [user.ap_id]) + + data = + data + |> Map.put("bcc", [user.ap_id]) + |> Kernel.put_in(["object", "bcc"], [user.ap_id]) conn = conn @@ -666,8 +670,11 @@ test "it accepts messages with to as string instead of array", %{conn: conn, dat user = insert(:user) data = - Map.put(data, "to", user.ap_id) - |> Map.delete("cc") + data + |> Map.put("to", user.ap_id) + |> Map.put("cc", []) + |> Kernel.put_in(["object", "to"], user.ap_id) + |> Kernel.put_in(["object", "cc"], []) conn = conn @@ -684,8 +691,11 @@ test "it accepts messages with cc as string instead of array", %{conn: conn, dat user = insert(:user) data = - Map.put(data, "cc", user.ap_id) - |> Map.delete("to") + data + |> Map.put("to", []) + |> Map.put("cc", user.ap_id) + |> Kernel.put_in(["object", "to"], []) + |> Kernel.put_in(["object", "cc"], user.ap_id) conn = conn @@ -703,9 +713,13 @@ test "it accepts messages with bcc as string instead of array", %{conn: conn, da user = insert(:user) data = - Map.put(data, "bcc", user.ap_id) - |> Map.delete("to") - |> Map.delete("cc") + data + |> Map.put("to", []) + |> Map.put("cc", []) + |> Map.put("bcc", user.ap_id) + |> Kernel.put_in(["object", "to"], []) + |> Kernel.put_in(["object", "cc"], []) + |> Kernel.put_in(["object", "bcc"], user.ap_id) conn = conn From b0c778fde77f5ec2320b0bd0327e8a13b0f39a63 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Tue, 15 Sep 2020 18:19:38 +0200 Subject: [PATCH 149/339] NoteHandlingTest: remove fix_explicit_addressing-related test --- .../transmogrifier/note_handling_test.exs | 42 +++---------------- 1 file changed, 6 insertions(+), 36 deletions(-) diff --git a/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs index b79f2c94c..1846b2291 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs @@ -10,6 +10,7 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.NoteHandlingTest do alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.Transmogrifier + alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI import Mock @@ -42,36 +43,6 @@ test "it works for incoming notices with tag not being an array (kroeg)" do assert Object.hashtags(object) == ["test"] end - test "it cleans up incoming notices which are not really DMs" do - user = insert(:user) - other_user = insert(:user) - - to = [user.ap_id, other_user.ap_id] - - data = - File.read!("test/fixtures/mastodon-post-activity.json") - |> Jason.decode!() - |> Map.put("to", to) - |> Map.put("cc", []) - - object = - data["object"] - |> Map.put("to", to) - |> Map.put("cc", []) - - data = Map.put(data, "object", object) - - {:ok, %Activity{data: data, local: false} = activity} = Transmogrifier.handle_incoming(data) - - assert data["to"] == [] - assert data["cc"] == to - - object_data = Object.normalize(activity, fetch: false).data - - assert object_data["to"] == [] - assert object_data["cc"] == to - end - test "it ignores an incoming notice if we already have it" do activity = insert(:note_activity) @@ -321,9 +292,11 @@ test "it strips internal likes" do object = Map.put(data["object"], "likes", likes) data = Map.put(data, "object", object) - {:ok, %Activity{object: object}} = Transmogrifier.handle_incoming(data) + {:ok, %Activity{} = activity} = Transmogrifier.handle_incoming(data) - refute Map.has_key?(object.data, "likes") + object = Object.normalize(activity) + + assert object.data["likes"] == [] end test "it strips internal reactions" do @@ -435,10 +408,7 @@ test "does NOT schedule background fetching of `replies` beyond max thread depth setup do replies = %{ "type" => "Collection", - "items" => [ - Pleroma.Web.ActivityPub.Utils.generate_object_id(), - Pleroma.Web.ActivityPub.Utils.generate_object_id() - ] + "items" => [Utils.generate_object_id(), Utils.generate_object_id()] } activity = From 461123110b7cf47f4d2c01d1dd6992a2b63337fe Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Thu, 17 Sep 2020 16:17:16 +0200 Subject: [PATCH 150/339] Object.Fetcher: Fix getting transmogrifier reject reason --- lib/pleroma/object/fetcher.ex | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index 82d2c8bcb..4ca67f0fd 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -102,6 +102,9 @@ def fetch_object_from_id(id, options \\ []) do {:transmogrifier, {:error, {:reject, e}}} -> {:reject, e} + {:transmogrifier, {:reject, e}} -> + {:reject, e} + {:transmogrifier, _} = e -> {:error, e} From 6c9f6e62c8453f023c6ec9106d1a7c3e66ab95b7 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Mon, 28 Sep 2020 19:34:27 +0200 Subject: [PATCH 151/339] transmogrifier: Fixing votes from Note to Answer --- .../object_validators/answer_validator.ex | 7 ++++++ .../web/activity_pub/transmogrifier.ex | 22 ++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex b/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex index c9bd9e42d..3451e1ff8 100644 --- a/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/answer_validator.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnswerValidator do use Ecto.Schema alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes alias Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations import Ecto.Changeset @@ -23,6 +24,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnswerValidator do field(:name, :string) field(:inReplyTo, ObjectValidators.ObjectID) field(:attributedTo, ObjectValidators.ObjectID) + field(:context, :string) # TODO: Remove actor on objects field(:actor, ObjectValidators.ObjectID) @@ -46,6 +48,11 @@ def cast_data(data) do end def changeset(struct, data) do + data = + data + |> CommonFixes.fix_actor() + |> CommonFixes.fix_object_defaults() + struct |> cast(data, __schema__(:fields)) end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 28bc25363..454bbce9d 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -43,7 +43,6 @@ def fix_object(object, options \\ []) do |> fix_content_map() |> fix_addressing() |> fix_summary() - |> fix_type(options) end def fix_summary(%{"summary" => nil} = object) do @@ -321,19 +320,18 @@ def fix_content_map(%{"contentMap" => content_map} = object) do def fix_content_map(object), do: object - def fix_type(object, options \\ []) + defp fix_type(%{"type" => "Note", "inReplyTo" => reply_id, "name" => _} = object, options) + when is_binary(reply_id) do + options = Keyword.put(options, :fetch, true) - def fix_type(%{"inReplyTo" => reply_id, "name" => _} = object, options) - when is_binary(reply_id) do - with true <- Federator.allowed_thread_distance?(options[:depth]), - {:ok, %{data: %{"type" => "Question"} = _} = _} <- get_obj_helper(reply_id, options) do + with %Object{data: %{"type" => "Question"}} <- Object.normalize(reply_id, options) do Map.put(object, "type", "Answer") else _ -> object end end - def fix_type(object, _), do: object + defp fix_type(object, _options), do: object # Reduce the object list to find the reported user. defp get_reported(objects) do @@ -501,7 +499,15 @@ def handle_incoming( options ) when objtype in ~w{Question Answer ChatMessage Audio Video Event Article Note} do - data = Map.put(data, "object", strip_internal_fields(data["object"])) + fetch_options = Keyword.put(options, :depth, (options[:depth] || 0) + 1) + + object = + data["object"] + |> strip_internal_fields() + |> fix_type(fetch_options) + |> fix_in_reply_to(fetch_options) + + data = Map.put(data, "object", object) options = Keyword.put(options, :local, false) with {:ok, %User{}} <- ObjectValidator.fetch_actor(data), From 0b88accae632e371becacb16be4e8798aa80c705 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Wed, 21 Oct 2020 01:20:06 +0200 Subject: [PATCH 152/339] fetcher_test: Fix missing mock function --- test/pleroma/object/fetcher_test.exs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/pleroma/object/fetcher_test.exs b/test/pleroma/object/fetcher_test.exs index a7ac90348..8d9c6c3cb 100644 --- a/test/pleroma/object/fetcher_test.exs +++ b/test/pleroma/object/fetcher_test.exs @@ -66,6 +66,14 @@ defmodule Pleroma.Object.FetcherTest do %Tesla.Env{ status: 500 } + + %{ + method: :get, + url: "https://stereophonic.space/objects/02997b83-3ea7-4b63-94af-ef3aa2d4ed17" + } -> + %Tesla.Env{ + status: 500 + } end) :ok From 53193b84b1d07c9fd3c6b80c04e3eada4fb4cd59 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Fri, 27 Nov 2020 00:25:24 +0100 Subject: [PATCH 153/339] =?UTF-8?q?utils:=20Fix=20maybe=5Fsplice=5Frecipie?= =?UTF-8?q?nt=20when=20"object"=20isn=E2=80=99t=20a=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pleroma/maps.ex | 6 ++++++ lib/pleroma/web/activity_pub/utils.ex | 6 +++--- .../web/activity_pub/activity_pub_controller_test.exs | 1 - 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/maps.ex b/lib/pleroma/maps.ex index 0d2e94248..b08b83305 100644 --- a/lib/pleroma/maps.ex +++ b/lib/pleroma/maps.ex @@ -12,4 +12,10 @@ def put_if_present(map, key, value, value_function \\ &{:ok, &1}) when is_map(ma _ -> map end end + + def safe_put_in(data, keys, value) when is_map(data) and is_list(keys) do + Kernel.put_in(data, keys, value) + rescue + _ -> data + end end diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index e81623d83..0d1a6d0f1 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -96,11 +96,11 @@ def maybe_splice_recipient(ap_id, params) do !label_in_collection?(ap_id, params["cc"]) if need_splice? do - cc_list = extract_list(params["cc"]) + cc = [ap_id | extract_list(params["cc"])] params - |> Map.put("cc", [ap_id | cc_list]) - |> Kernel.put_in(["object", "cc"], [ap_id | cc_list]) + |> Map.put("cc", cc) + |> Maps.safe_put_in(["object", "cc"], cc) else params end diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index f6ea9e2ca..f3ce703e2 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -1003,7 +1003,6 @@ test "forwarded report from mastodon", %{conn: conn} do "actor" => remote_actor, "content" => "test report", "id" => "https://#{remote_domain}/e3b12fd1-948c-446e-b93b-a5e67edbe1d8", - "nickname" => reported_user.nickname, "object" => [ reported_user.ap_id, note.data["object"] From 6d6bef64bf3b37457b71cf7025e84aa9017a3b86 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Thu, 25 Mar 2021 10:17:26 +0100 Subject: [PATCH 154/339] fetcher_test: Remove assert on fake Create having an ap_id --- test/pleroma/object/fetcher_test.exs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/pleroma/object/fetcher_test.exs b/test/pleroma/object/fetcher_test.exs index 8d9c6c3cb..bd0a6e497 100644 --- a/test/pleroma/object/fetcher_test.exs +++ b/test/pleroma/object/fetcher_test.exs @@ -132,8 +132,7 @@ test "it fetches an object" do {:ok, object} = Fetcher.fetch_object_from_id("http://mastodon.example.org/@admin/99541947525187367") - assert activity = Activity.get_create_by_object_ap_id(object.data["id"]) - assert activity.data["id"] + assert _activity = Activity.get_create_by_object_ap_id(object.data["id"]) {:ok, object_again} = Fetcher.fetch_object_from_id("http://mastodon.example.org/@admin/99541947525187367") From 5ef4659b373ae1106090952ff3e963b419fa1d72 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Mon, 5 Apr 2021 18:57:14 +0200 Subject: [PATCH 155/339] test/pleroma/web/common_api_test.exs: Strip : around emoji key-name --- test/pleroma/web/common_api_test.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/pleroma/web/common_api_test.exs b/test/pleroma/web/common_api_test.exs index 6619f8fc8..86c12f0b2 100644 --- a/test/pleroma/web/common_api_test.exs +++ b/test/pleroma/web/common_api_test.exs @@ -539,8 +539,8 @@ test "it copies emoji from the subject of the parent post" do spoiler_text: ":joker_smile:" }) - assert Object.normalize(reply_activity).data["emoji"][":joker_smile:"] - refute Object.normalize(reply_activity).data["emoji"][":joker_disapprove:"] + assert Object.normalize(reply_activity).data["emoji"]["joker_smile"] + refute Object.normalize(reply_activity).data["emoji"]["joker_disapprove"] end test "deactivated users can't post" do From 681a42c359b4fbae74285363c670dff18aac5918 Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov <alex.strizhakov@gmail.com> Date: Thu, 8 Apr 2021 15:45:31 +0300 Subject: [PATCH 156/339] release runtime provider fix for paths --- lib/pleroma/config/release_runtime_provider.ex | 13 +++++++++---- mix.exs | 13 +------------ .../config/release_runtime_provider_test.exs | 1 - 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/lib/pleroma/config/release_runtime_provider.ex b/lib/pleroma/config/release_runtime_provider.ex index 46fa35559..e5e9d3dcd 100644 --- a/lib/pleroma/config/release_runtime_provider.ex +++ b/lib/pleroma/config/release_runtime_provider.ex @@ -11,10 +11,11 @@ def init(opts), do: opts def load(config, opts) do with_defaults = Config.Reader.merge(config, Pleroma.Config.Holder.release_defaults()) - config_path = opts[:config_path] + config_path = + opts[:config_path] || System.get_env("PLEROMA_CONFIG_PATH") || "/etc/pleroma/config.exs" with_runtime_config = - if config_path && File.exists?(config_path) do + if File.exists?(config_path) do runtime_config = Config.Reader.read!(config_path) with_defaults @@ -32,10 +33,14 @@ def load(config, opts) do with_defaults end - exported_config_path = opts[:exported_config_path] + exported_config_path = + opts[:exported_config_path] || + config_path + |> Path.dirname() + |> Path.join("#{Pleroma.Config.get(:env)}.exported_from_db.secret.exs") with_exported = - if exported_config_path && File.exists?(exported_config_path) do + if File.exists?(exported_config_path) do exported_config = Config.Reader.read!(exported_config_path) Config.Reader.merge(with_runtime_config, exported_config) else diff --git a/mix.exs b/mix.exs index 7328b533b..fe5d9d963 100644 --- a/mix.exs +++ b/mix.exs @@ -38,7 +38,7 @@ def project do include_executables_for: [:unix], applications: [ex_syslogger: :load, syslog: :load, eldap: :transient], steps: [:assemble, &put_otp_version/1, ©_files/1, ©_nginx_config/1], - config_providers: [{Pleroma.Config.ReleaseRuntimeProvider, release_config_paths()}] + config_providers: [{Pleroma.Config.ReleaseRuntimeProvider, []}] ] ] ] @@ -67,17 +67,6 @@ def copy_nginx_config(%{path: target_path} = release) do release end - defp release_config_paths do - config_path = System.get_env("PLEROMA_CONFIG_PATH") || "/etc/pleroma/config.exs" - - exported_config_path = - config_path - |> Path.dirname() - |> Path.join("#{Mix.env()}.exported_from_db.secret.exs") - - [config_path: config_path, exported_config_path: exported_config_path] - end - # Configuration for the OTP application. # # Type `mix help compile.app` for more information. diff --git a/test/pleroma/config/release_runtime_provider_test.exs b/test/pleroma/config/release_runtime_provider_test.exs index 1921698c5..6578d3268 100644 --- a/test/pleroma/config/release_runtime_provider_test.exs +++ b/test/pleroma/config/release_runtime_provider_test.exs @@ -8,7 +8,6 @@ test "loads release defaults config and warns about non-existent runtime config" ExUnit.CaptureIO.capture_io(fn -> merged = ReleaseRuntimeProvider.load([], []) assert merged == Pleroma.Config.Holder.release_defaults() - IO.inspect(merged) end) =~ "!!! Config path is not declared! Please ensure it exists and that PLEROMA_CONFIG_PATH is unset or points to an existing file" end From 0feafcc20cec168258f592b9d509c1e6ccc8efba Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Fri, 9 Apr 2021 10:30:27 -0500 Subject: [PATCH 157/339] Use URI.merge to prevent concatenating two canonical URLs when a custom instance thumbnail was uploaded via AdminFE --- lib/pleroma/web/mastodon_api/views/instance_view.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 73205fb6d..dac68d8e6 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -23,7 +23,8 @@ def render("show.json", _) do streaming_api: Pleroma.Web.Endpoint.websocket_url() }, stats: Pleroma.Stats.get_stats(), - thumbnail: Pleroma.Web.base_url() <> Keyword.get(instance, :instance_thumbnail), + thumbnail: + URI.merge(Pleroma.Web.base_url(), Keyword.get(instance, :instance_thumbnail)) |> to_string, languages: ["en"], registrations: Keyword.get(instance, :registrations_open), approval_required: Keyword.get(instance, :account_approval_required), From 9fbcdc15b11dedf27bc5c78d09048ba354906c16 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 13 Apr 2021 10:52:53 -0500 Subject: [PATCH 158/339] Validate custom instance thumbnail set via AdminAPI produces correct URL --- CHANGELOG.md | 1 + .../controllers/config_controller_test.exs | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c45cad85..1553245e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Try to save exported ConfigDB settings (migrate_from_db) in the system temp directory if default location is not writable. +- Uploading custom instance thumbnail via AdminAPI/AdminFE generated invalid URL to the image ## [2.3.0] - 2020-03-01 diff --git a/test/pleroma/web/admin_api/controllers/config_controller_test.exs b/test/pleroma/web/admin_api/controllers/config_controller_test.exs index 578a4c914..71151712e 100644 --- a/test/pleroma/web/admin_api/controllers/config_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/config_controller_test.exs @@ -1410,6 +1410,48 @@ test "enables the welcome messages", %{conn: conn} do "need_reboot" => false } end + + test "custom instance thumbnail", %{conn: conn} do + clear_config([:instance]) + + params = %{ + "group" => ":pleroma", + "key" => ":instance", + "value" => [ + %{ + "tuple" => [ + ":instance_thumbnail", + "https://example.com/media/new_thumbnail.jpg" + ] + } + ] + } + + res = + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{"configs" => [params]}) + |> json_response_and_validate_schema(200) + + assert res == %{ + "configs" => [ + %{ + "db" => [":instance_thumbnail"], + "group" => ":pleroma", + "key" => ":instance", + "value" => params["value"] + } + ], + "need_reboot" => false + } + + assert res = + conn + |> get("/api/v1/instance") + |> json_response_and_validate_schema(200) + + assert res = %{"thumbnail" => "https://example.com/media/new_thumbnail.jpg"} + end end describe "GET /api/pleroma/admin/config/descriptions" do From cdd271b0655799e65bb9a13016dc82441ec34f87 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 13 Apr 2021 10:55:54 -0500 Subject: [PATCH 159/339] Fix assignment / assertion --- .../web/admin_api/controllers/config_controller_test.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/pleroma/web/admin_api/controllers/config_controller_test.exs b/test/pleroma/web/admin_api/controllers/config_controller_test.exs index 71151712e..c4d07d61c 100644 --- a/test/pleroma/web/admin_api/controllers/config_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/config_controller_test.exs @@ -1445,8 +1445,8 @@ test "custom instance thumbnail", %{conn: conn} do "need_reboot" => false } - assert res = - conn + _res = + assert conn |> get("/api/v1/instance") |> json_response_and_validate_schema(200) From 905efc57e9f2a96519bf1ac84b56f88d1818cca3 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 13 Apr 2021 11:15:52 -0500 Subject: [PATCH 160/339] Initial test validating the AdminAPI issue --- .../controllers/config_controller_test.exs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/test/pleroma/web/admin_api/controllers/config_controller_test.exs b/test/pleroma/web/admin_api/controllers/config_controller_test.exs index c4d07d61c..d26fd3150 100644 --- a/test/pleroma/web/admin_api/controllers/config_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/config_controller_test.exs @@ -1452,6 +1452,41 @@ test "custom instance thumbnail", %{conn: conn} do assert res = %{"thumbnail" => "https://example.com/media/new_thumbnail.jpg"} end + + test "Concurrent Limiter", %{conn: conn} do + clear_config([ConcurrentLimiter]) + + params = %{ + "group" => ":pleroma", + "key" => "ConcurrentLimiter", + "value" => [ + %{ + "tuple" => [ + "Pleroma.Web.RichMedia.Helpers", + [ + %{"tuple" => [":max_running", 6]}, + %{"tuple" => [":max_waiting", 6]} + ] + ] + }, + %{ + "tuple" => [ + "Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy", + [ + %{"tuple" => [":max_running", 7]}, + %{"tuple" => [":max_waiting", 7]} + ] + ] + } + ] + } + + _res = + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{"configs" => [params]}) + |> json_response_and_validate_schema(200) + end end describe "GET /api/pleroma/admin/config/descriptions" do From ee53ad4d7705328a5a583680c6f551c4c3bf2302 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 13 Apr 2021 12:09:18 -0500 Subject: [PATCH 161/339] Add ConcurrentLimiter to module_name?/1 and apply string_to_elixir_types/1 to search_opts keys during update_or_create/1 --- lib/pleroma/config_db.ex | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/config_db.ex b/lib/pleroma/config_db.ex index b874e0e37..03905c06b 100644 --- a/lib/pleroma/config_db.ex +++ b/lib/pleroma/config_db.ex @@ -141,7 +141,9 @@ defp deep_merge(_key, value1, value2) do @spec update_or_create(map()) :: {:ok, ConfigDB.t()} | {:error, Changeset.t()} def update_or_create(params) do params = Map.put(params, :value, to_elixir_types(params[:value])) - search_opts = Map.take(params, [:group, :key]) + + search_opts = + Map.take(params, [:group, :key]) |> Map.update!(:key, &string_to_elixir_types(&1)) with %ConfigDB{} = config <- ConfigDB.get_by_params(search_opts), {_, true, config} <- {:partial_update, can_be_partially_updated?(config), config}, @@ -387,6 +389,6 @@ defp find_valid_delimiter([delimiter | others], pattern, regex_delimiter) do @spec module_name?(String.t()) :: boolean() def module_name?(string) do Regex.match?(~r/^(Pleroma|Phoenix|Tesla|Quack|Ueberauth|Swoosh)\./, string) or - string in ["Oban", "Ueberauth", "ExSyslogger"] + string in ["Oban", "Ueberauth", "ExSyslogger", "ConcurrentLimiter"] end end From 861f1928526930eeb78f79c4840c69cee5c2f215 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 13 Apr 2021 14:39:44 -0500 Subject: [PATCH 162/339] Document fixed ability to save ConcurrentLimiter settings in ConfigDB --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1553245e5..6e13b3875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Try to save exported ConfigDB settings (migrate_from_db) in the system temp directory if default location is not writable. - Uploading custom instance thumbnail via AdminAPI/AdminFE generated invalid URL to the image +- Applying ConcurrentLimiter settings via AdminAPI ## [2.3.0] - 2020-03-01 From c3b8c77967b0c42f93286f864236b7d6f1471c13 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 13 Apr 2021 14:25:15 -0500 Subject: [PATCH 163/339] Improve string_to_elixir_types/1 with guards --- lib/pleroma/config_db.ex | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/config_db.ex b/lib/pleroma/config_db.ex index 03905c06b..eeeb026c1 100644 --- a/lib/pleroma/config_db.ex +++ b/lib/pleroma/config_db.ex @@ -327,7 +327,7 @@ def to_elixir_types(entity), do: entity @spec string_to_elixir_types(String.t()) :: atom() | Regex.t() | module() | String.t() | no_return() - def string_to_elixir_types("~r" <> _pattern = regex) do + def string_to_elixir_types("~r" <> _pattern = regex) when is_binary(regex) do pattern = ~r/^~r(?'delimiter'[\/|"'([{<]{1})(?'pattern'.+)[\/|"')\]}>]{1}(?'modifier'[uismxfU]*)/u @@ -341,9 +341,9 @@ def string_to_elixir_types("~r" <> _pattern = regex) do end end - def string_to_elixir_types(":" <> atom), do: String.to_atom(atom) + def string_to_elixir_types(":" <> atom) when is_binary(atom), do: String.to_atom(atom) - def string_to_elixir_types(value) do + def string_to_elixir_types(value) when is_binary(value) do if module_name?(value) do String.to_existing_atom("Elixir." <> value) else @@ -351,6 +351,8 @@ def string_to_elixir_types(value) do end end + def string_to_elixir_types(value) when is_atom(value), do: value + defp parse_host("localhost"), do: :localhost defp parse_host(host) do From f95b52255b2d7373a3e0bf4adff81f83c080b2ef Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Wed, 14 Apr 2021 09:39:57 -0500 Subject: [PATCH 164/339] Revert guards on string_to_elixir_types/1, remove unnecessary assignment in test --- lib/pleroma/config_db.ex | 12 ++++-------- .../admin_api/controllers/config_controller_test.exs | 9 ++++----- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/config_db.ex b/lib/pleroma/config_db.ex index eeeb026c1..cb57673e3 100644 --- a/lib/pleroma/config_db.ex +++ b/lib/pleroma/config_db.ex @@ -141,9 +141,7 @@ defp deep_merge(_key, value1, value2) do @spec update_or_create(map()) :: {:ok, ConfigDB.t()} | {:error, Changeset.t()} def update_or_create(params) do params = Map.put(params, :value, to_elixir_types(params[:value])) - - search_opts = - Map.take(params, [:group, :key]) |> Map.update!(:key, &string_to_elixir_types(&1)) + search_opts = Map.take(params, [:group, :key]) with %ConfigDB{} = config <- ConfigDB.get_by_params(search_opts), {_, true, config} <- {:partial_update, can_be_partially_updated?(config), config}, @@ -327,7 +325,7 @@ def to_elixir_types(entity), do: entity @spec string_to_elixir_types(String.t()) :: atom() | Regex.t() | module() | String.t() | no_return() - def string_to_elixir_types("~r" <> _pattern = regex) when is_binary(regex) do + def string_to_elixir_types("~r" <> _pattern = regex) do pattern = ~r/^~r(?'delimiter'[\/|"'([{<]{1})(?'pattern'.+)[\/|"')\]}>]{1}(?'modifier'[uismxfU]*)/u @@ -341,9 +339,9 @@ def string_to_elixir_types("~r" <> _pattern = regex) when is_binary(regex) do end end - def string_to_elixir_types(":" <> atom) when is_binary(atom), do: String.to_atom(atom) + def string_to_elixir_types(":" <> atom), do: String.to_atom(atom) - def string_to_elixir_types(value) when is_binary(value) do + def string_to_elixir_types(value) do if module_name?(value) do String.to_existing_atom("Elixir." <> value) else @@ -351,8 +349,6 @@ def string_to_elixir_types(value) when is_binary(value) do end end - def string_to_elixir_types(value) when is_atom(value), do: value - defp parse_host("localhost"), do: :localhost defp parse_host(host) do diff --git a/test/pleroma/web/admin_api/controllers/config_controller_test.exs b/test/pleroma/web/admin_api/controllers/config_controller_test.exs index d26fd3150..c39c1b1e1 100644 --- a/test/pleroma/web/admin_api/controllers/config_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/config_controller_test.exs @@ -1481,11 +1481,10 @@ test "Concurrent Limiter", %{conn: conn} do ] } - _res = - assert conn - |> put_req_header("content-type", "application/json") - |> post("/api/pleroma/admin/config", %{"configs" => [params]}) - |> json_response_and_validate_schema(200) + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{"configs" => [params]}) + |> json_response_and_validate_schema(200) end end From d9fce0133ef3444ef7d09ae7e2760583540d1cd2 Mon Sep 17 00:00:00 2001 From: Sean King <seanking2919@protonmail.com> Date: Wed, 14 Apr 2021 14:01:33 -0600 Subject: [PATCH 165/339] Fix Mastodon interface link --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 1a90d0a8d..80c5d2631 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,7 +20,7 @@ The default front-end used by Pleroma is Pleroma-FE. You can find more informati ### Mastodon interface If the Pleroma interface isn't your thing, or you're just trying something new but you want to keep using the familiar Mastodon interface, we got that too! -Just add a "/web" after your instance url (e.g. <https://pleroma.soycaf.com/web>) and you'll end on the Mastodon web interface, but with a Pleroma backend! MAGIC! +Just add a "/web" after your instance url (e.g. <https://pleroma.soykaf.com/web>) and you'll end on the Mastodon web interface, but with a Pleroma backend! MAGIC! The Mastodon interface is from the Glitch-soc fork. For more information on the Mastodon interface you can check the [Mastodon](https://docs.joinmastodon.org/) and [Glitch-soc](https://glitch-soc.github.io/docs/) documentation. Remember, what you see is only the frontend part of Mastodon, the backend is still Pleroma. From c6dcd863e28531e0d21ee64a8387bd27c2c0ed31 Mon Sep 17 00:00:00 2001 From: rinpatch <rin@patch.cx> Date: Fri, 16 Apr 2021 09:59:50 +0000 Subject: [PATCH 166/339] Apply rinpatch's suggestion(s) to 1 file(s) --- lib/pleroma/web/api_spec/operations/twitter_util_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex b/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex index 62c9826f6..decb6572a 100644 --- a/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex +++ b/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex @@ -59,7 +59,7 @@ def frontend_configurations_operation do def change_password_operation do %Operation{ - tags: ["Accounts"], + tags: ["Account credentials"], summary: "Change account password", security: [%{"oAuth" => ["write:accounts"]}], operationId: "UtilController.change_password", From 2b4f958b2ad653ee8e294ade18aa4482e4d372e1 Mon Sep 17 00:00:00 2001 From: Sean King <seanking2919@protonmail.com> Date: Sun, 18 Apr 2021 14:00:18 -0600 Subject: [PATCH 167/339] Add opting out of Google FLoC to HTTPSecurityPlug headers --- lib/pleroma/web/plugs/http_security_plug.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/plugs/http_security_plug.ex b/lib/pleroma/web/plugs/http_security_plug.ex index 0025b042a..d1e6cc9d3 100644 --- a/lib/pleroma/web/plugs/http_security_plug.ex +++ b/lib/pleroma/web/plugs/http_security_plug.ex @@ -48,7 +48,8 @@ def headers do {"x-content-type-options", "nosniff"}, {"referrer-policy", referrer_policy}, {"x-download-options", "noopen"}, - {"content-security-policy", csp_string()} + {"content-security-policy", csp_string()}, + {"permissions-policy", "interest-cohort=()"} ] headers = From efed94a23e30260bcf1b297910906b11d6e4d895 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Mon, 19 Apr 2021 16:23:57 -0500 Subject: [PATCH 168/339] Fix error response which was breaking tests related to pinned posts --- lib/pleroma/web/common_api.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex index 3970c19a8..1b5f8491e 100644 --- a/lib/pleroma/web/common_api.ex +++ b/lib/pleroma/web/common_api.ex @@ -415,7 +415,7 @@ def pin(id, %User{} = user) do ) do {:ok, activity} else - {:error, {:execute_side_effects, error}} -> error + {:error, {:side_effects, error}} -> error error -> error end end From 2780cdd4e7acde0f4bf4719b7c82bc7e2d1bf3b5 Mon Sep 17 00:00:00 2001 From: Sean King <seanking2919@protonmail.com> Date: Mon, 19 Apr 2021 16:06:19 -0600 Subject: [PATCH 169/339] Add CHANGELOG entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b0678023..bfa76a89a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed - The `application` metadata returned with statuses is no longer hardcoded. Apps that want to display these details will now have valid data for new posts after this change. +- HTTPSecurityPlug now sends a response header to opt out of Google's FLoC (Federated Learning of Cohorts) targeted advertising. ### Added From 7eded7218922b46c5cc085e715b6031ffff9b6ce Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 20 Apr 2021 12:31:14 -0500 Subject: [PATCH 170/339] Fix incorrect shell command Can't be in /opt/pleroma/bin and then call ./bin/pleroma_ctl :) --- docs/installation/otp_en.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/otp_en.md b/docs/installation/otp_en.md index 42e264e65..13f9636f3 100644 --- a/docs/installation/otp_en.md +++ b/docs/installation/otp_en.md @@ -290,7 +290,7 @@ nginx -t ## Create your first user and set as admin ```sh -cd /opt/pleroma/bin +cd /opt/pleroma su pleroma -s $SHELL -lc "./bin/pleroma_ctl user new joeuser joeuser@sld.tld --admin" ``` This will create an account withe the username of 'joeuser' with the email address of joeuser@sld.tld, and set that user's account as an admin. This will result in a link that you can paste into the browser, which logs you in and enables you to set the password. From 30b1d5093808974310a52917e6ab85d528683fae Mon Sep 17 00:00:00 2001 From: Haelwenn <contact+git.pleroma.social@hacktivis.me> Date: Tue, 20 Apr 2021 21:06:32 +0000 Subject: [PATCH 171/339] Apply lanodan's suggestion(s) to 1 file(s) --- lib/pleroma/web/api_spec/operations/twitter_util_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex b/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex index decb6572a..6ddc93a92 100644 --- a/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex +++ b/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex @@ -88,7 +88,7 @@ def change_password_operation do def change_email_operation do %Operation{ - tags: ["Accounts"], + tags: ["Account credentials"], summary: "Change account email", security: [%{"oAuth" => ["write:accounts"]}], operationId: "UtilController.change_email", From e104829c2f5b3eae9133ea1a6a81d138c3a8e314 Mon Sep 17 00:00:00 2001 From: Haelwenn <contact+git.pleroma.social@hacktivis.me> Date: Tue, 20 Apr 2021 21:06:39 +0000 Subject: [PATCH 172/339] Apply lanodan's suggestion(s) to 1 file(s) --- lib/pleroma/web/api_spec/operations/twitter_util_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex b/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex index 6ddc93a92..dbed1b518 100644 --- a/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex +++ b/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex @@ -143,7 +143,7 @@ def update_notificaton_settings_operation do def disable_account_operation do %Operation{ - tags: ["Accounts"], + tags: ["Account credentials"], summary: "Disable Account", security: [%{"oAuth" => ["write:accounts"]}], operationId: "UtilController.disable_account", From 42185d87504ea595138e8e3f5bf9ce6840edd2f1 Mon Sep 17 00:00:00 2001 From: Haelwenn <contact+git.pleroma.social@hacktivis.me> Date: Tue, 20 Apr 2021 21:06:45 +0000 Subject: [PATCH 173/339] Apply lanodan's suggestion(s) to 1 file(s) --- lib/pleroma/web/api_spec/operations/twitter_util_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex b/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex index dbed1b518..6e6d330de 100644 --- a/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex +++ b/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex @@ -163,7 +163,7 @@ def disable_account_operation do def delete_account_operation do %Operation{ - tags: ["Accounts"], + tags: ["Account credentials"], summary: "Delete Account", security: [%{"oAuth" => ["write:accounts"]}], operationId: "UtilController.delete_account", From f9bedf5597dd00ce4f429a4077e7bb4473c97410 Mon Sep 17 00:00:00 2001 From: Haelwenn <contact+git.pleroma.social@hacktivis.me> Date: Tue, 20 Apr 2021 21:08:31 +0000 Subject: [PATCH 174/339] Apply lanodan's suggestion(s) to 1 file(s) --- lib/pleroma/web/api_spec/operations/twitter_util_operation.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex b/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex index 6e6d330de..0cafbc719 100644 --- a/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex +++ b/lib/pleroma/web/api_spec/operations/twitter_util_operation.ex @@ -195,7 +195,7 @@ def captcha_operation do def healthcheck_operation do %Operation{ tags: ["Accounts"], - summary: "Disable Account", + summary: "Quick status check on the instance", security: [%{"oAuth" => ["write:accounts"]}], operationId: "UtilController.healthcheck", parameters: [], From 0effcd2cfed36baec1d960b64c901da7e56710a8 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Mon, 19 Apr 2021 15:43:17 -0500 Subject: [PATCH 175/339] Set Repo.transaction/2 timeout to infinity. Fixes pleroma.user delete_activities mix task. --- lib/pleroma/web/activity_pub/pipeline.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 377eccb92..400823094 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -24,7 +24,7 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do @spec common_pipeline(map(), keyword()) :: {:ok, Activity.t() | Object.t(), keyword()} | {:error, any()} def common_pipeline(object, meta) do - case Repo.transaction(fn -> do_common_pipeline(object, meta) end) do + case Repo.transaction(fn -> do_common_pipeline(object, meta) end, timeout: :infinity) do {:ok, {:ok, activity, meta}} -> @side_effects.handle_after_transaction(meta) {:ok, activity, meta} From 9bc69196d5dfbd3fb37c0e62da19ce08fb9bf28d Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 20 Apr 2021 11:10:39 -0500 Subject: [PATCH 176/339] Add utility function to return infinite timeout for SQL transactions if we detect it was called from a Mix Task --- lib/pleroma/utils.ex | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/pleroma/utils.ex b/lib/pleroma/utils.ex index bc0c95332..5e2fa8bf7 100644 --- a/lib/pleroma/utils.ex +++ b/lib/pleroma/utils.ex @@ -63,4 +63,13 @@ def posix_error_message(code) when code in @posix_error_codes do end def posix_error_message(_), do: "" + + def query_timeout do + {parent, _, _, _} = Process.info(self(), :current_stacktrace) |> elem(1) |> Enum.fetch!(2) + + cond do + parent |> to_string |> String.starts_with?("Elixir.Mix.Task") -> [timeout: :infinity] + true -> [timeout: 15_000] + end + end end From 9f711ddcf84bdb5a5680e1b55afa83768014906d Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 20 Apr 2021 11:16:24 -0500 Subject: [PATCH 177/339] Try to set query timeout intelligently --- lib/pleroma/web/activity_pub/pipeline.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 400823094..a0f2e0312 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do alias Pleroma.Config alias Pleroma.Object alias Pleroma.Repo + alias Pleroma.Utils alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.MRF alias Pleroma.Web.ActivityPub.ObjectValidator @@ -24,7 +25,7 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do @spec common_pipeline(map(), keyword()) :: {:ok, Activity.t() | Object.t(), keyword()} | {:error, any()} def common_pipeline(object, meta) do - case Repo.transaction(fn -> do_common_pipeline(object, meta) end, timeout: :infinity) do + case Repo.transaction(fn -> do_common_pipeline(object, meta) end, Utils.query_timeout()) do {:ok, {:ok, activity, meta}} -> @side_effects.handle_after_transaction(meta) {:ok, activity, meta} From 99fd9c5e38ad08973f435f1a67d6af60d004c578 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 20 Apr 2021 12:00:02 -0500 Subject: [PATCH 178/339] OTP releases executing commands via pleroma_ctl show the parent of the process is :erl_eval --- lib/pleroma/utils.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pleroma/utils.ex b/lib/pleroma/utils.ex index 5e2fa8bf7..55aecc509 100644 --- a/lib/pleroma/utils.ex +++ b/lib/pleroma/utils.ex @@ -69,6 +69,7 @@ def query_timeout do cond do parent |> to_string |> String.starts_with?("Elixir.Mix.Task") -> [timeout: :infinity] + parent == :erl_eval -> [timeout: :infinity] true -> [timeout: 15_000] end end From 959dc6e6fc95b33700fb7e08689afb701b2877f2 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Thu, 22 Apr 2021 10:11:08 -0500 Subject: [PATCH 179/339] Cleanup and ensure we obey custom Repo timeout --- lib/pleroma/utils.ex | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/utils.ex b/lib/pleroma/utils.ex index 55aecc509..a446d3ae6 100644 --- a/lib/pleroma/utils.ex +++ b/lib/pleroma/utils.ex @@ -11,6 +11,8 @@ defmodule Pleroma.Utils do eperm epipe erange erofs espipe esrch estale etxtbsy exdev )a + @repo_timeout Pleroma.Config.get([Pleroma.Repo, :timeout], 15_000) + def compile_dir(dir) when is_binary(dir) do dir |> File.ls!() @@ -64,13 +66,20 @@ def posix_error_message(code) when code in @posix_error_codes do def posix_error_message(_), do: "" + @doc """ + Returns [timeout: integer] suitable for passing as an option to Repo functions. + + This function detects if the execution was triggered from IEx shell, Mix task, or + ./bin/pleroma_ctl and sets the timeout to :infinity, else returns the default timeout value. + """ + @spec query_timeout() :: [timeout: integer] def query_timeout do {parent, _, _, _} = Process.info(self(), :current_stacktrace) |> elem(1) |> Enum.fetch!(2) cond do parent |> to_string |> String.starts_with?("Elixir.Mix.Task") -> [timeout: :infinity] parent == :erl_eval -> [timeout: :infinity] - true -> [timeout: 15_000] + true -> [timeout: @repo_timeout] end end end From d7a71a275abea6286ee116d092ddc9440a9419a5 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Thu, 22 Apr 2021 10:15:05 -0500 Subject: [PATCH 180/339] Fixed pleroma.user delete_activities mix task. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfa76a89a..a1173414d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Uploading custom instance thumbnail via AdminAPI/AdminFE generated invalid URL to the image - Applying ConcurrentLimiter settings via AdminAPI - User login failures if their `notification_settings` were in a NULL state. +- Mix task `pleroma.user delete_activities` query transaction timeout is now :infinity ## [2.3.0] - 2020-03-01 From b9a99ac0d4b245ff3df6a9aa1b4db46ee75e9d22 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Tue, 27 Apr 2021 11:54:28 -0500 Subject: [PATCH 181/339] Cache gitlab-ci based on mix.lock --- .gitlab-ci.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2bc571971..2651ff9e6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -8,7 +8,9 @@ variables: &global_variables MIX_ENV: test cache: &global_cache_policy - key: ${CI_COMMIT_REF_SLUG} + key: + files: + - mix.lock paths: - deps - _build @@ -171,8 +173,8 @@ spec-deploy: - apk add curl script: - curl -X POST -F"token=$API_DOCS_PIPELINE_TRIGGER" -F'ref=master' -F"variables[BRANCH]=$CI_COMMIT_REF_NAME" -F"variables[JOB_REF]=$CI_JOB_ID" https://git.pleroma.social/api/v4/projects/1130/trigger/pipeline - - + + stop_review_app: image: alpine:3.9 stage: deploy @@ -231,7 +233,7 @@ amd64-musl: stage: release artifacts: *release-artifacts only: *release-only - image: elixir:1.10.3-alpine + image: elixir:1.10.3-alpine cache: *release-cache variables: *release-variables before_script: &before-release-musl From 8c1d6e88395e1d7ada9d86236a7fa2339d9097e9 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Thu, 29 Apr 2021 12:20:46 -0500 Subject: [PATCH 182/339] CHANGELOG: Return OAuth token `id` --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1173414d..9a0171763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - MRF (`FollowBotPolicy`): New MRF Policy which makes a designated local Bot account attempt to follow all users in public Notes received by your instance. Users who require approving follower requests or have #nobot in their profile are excluded. +- Return OAuth token `id` (primary key) in POST `/oauth/token`. ## Unreleased (Patch) From b5ae8268982524a0a4fd295ddef64e4983832489 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Thu, 29 Apr 2021 13:03:41 -0500 Subject: [PATCH 183/339] CI: Purge pleroma build directory between runs --- .gitlab-ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2651ff9e6..78e715d47 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -24,6 +24,7 @@ stages: - docker before_script: + - rm -rf _build/*/lib/pleroma - apt-get update && apt-get install -y cmake - mix local.hex --force - mix local.rebar --force @@ -31,6 +32,9 @@ before_script: - apt-get -qq update - apt-get install -y libmagic-dev +after_script: + - rm -rf _build/*/lib/pleroma + build: stage: build script: From 004bcedb074d50bc42803e4c0a884239bd504b3d Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Fri, 30 Apr 2021 12:23:11 -0500 Subject: [PATCH 184/339] Upgrade Earmark 1.4.15 --- mix.exs | 2 +- mix.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mix.exs b/mix.exs index 06d77edb7..8ba2d8fbc 100644 --- a/mix.exs +++ b/mix.exs @@ -144,7 +144,7 @@ defp deps do {:ex_aws, "~> 2.1.6"}, {:ex_aws_s3, "~> 2.0"}, {:sweet_xml, "~> 0.6.6"}, - {:earmark, "1.4.13"}, + {:earmark, "1.4.15"}, {:bbcode_pleroma, "~> 0.2.0"}, {:crypt, git: "https://github.com/msantos/crypt.git", diff --git a/mix.lock b/mix.lock index e4dd32c83..06542f18d 100644 --- a/mix.lock +++ b/mix.lock @@ -27,8 +27,8 @@ "db_connection": {:hex, :db_connection, "2.2.2", "3bbca41b199e1598245b716248964926303b5d4609ff065125ce98bcd368939e", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "642af240d8a8affb93b4ba5a6fcd2bbcbdc327e1a524b825d383711536f8070c"}, "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, - "earmark": {:hex, :earmark, "1.4.13", "2c6ce9768fc9fdbf4046f457e207df6360ee6c91ee1ecb8e9a139f96a4289d91", [:mix], [{:earmark_parser, ">= 1.4.12", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "a0cf3ed88ef2b1964df408889b5ecb886d1a048edde53497fc935ccd15af3403"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.12", "b245e875ec0a311a342320da0551da407d9d2b65d98f7a9597ae078615af3449", [:mix], [], "hexpm", "711e2cc4d64abb7d566d43f54b78f7dc129308a63bc103fbd88550d2174b3160"}, + "earmark": {:hex, :earmark, "1.4.15", "2c7f924bf495ec1f65bd144b355d0949a05a254d0ec561740308a54946a67888", [:mix], [{:earmark_parser, ">= 1.4.13", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "3b1209b85bc9f3586f370f7c363f6533788fb4e51db23aa79565875e7f9999ee"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.13", "0c98163e7d04a15feb62000e1a891489feb29f3d10cb57d4f845c405852bbef8", [:mix], [], "hexpm", "d602c26af3a0af43d2f2645613f65841657ad6efc9f0e361c3b6c06b578214ba"}, "ecto": {:hex, :ecto, "3.4.6", "08f7afad3257d6eb8613309af31037e16c36808dfda5a3cd0cb4e9738db030e4", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6f13a9e2a62e75c2dcfc7207bfc65645ab387af8360db4c89fee8b5a4bf3f70b"}, "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, "ecto_sql": {:hex, :ecto_sql, "3.4.5", "30161f81b167d561a9a2df4329c10ae05ff36eca7ccc84628f2c8b9fa1e43323", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.0", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "31990c6a3579b36a3c0841d34a94c275e727de8b84f58509da5f1b2032c98ac2"}, From 6727a3659f60c0e09fa6375b6c0843c01f5be3dc Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Fri, 30 Apr 2021 12:27:06 -0500 Subject: [PATCH 185/339] Remove Pleroma.Formatter.minify/2 --- lib/pleroma/formatter.ex | 11 ----------- .../object_validators/audio_video_validator.ex | 1 - lib/pleroma/web/common_api/utils.ex | 1 - test/pleroma/formatter_test.exs | 7 ------- 4 files changed, 20 deletions(-) diff --git a/lib/pleroma/formatter.ex b/lib/pleroma/formatter.ex index 2aa236ca9..baf652a5a 100644 --- a/lib/pleroma/formatter.ex +++ b/lib/pleroma/formatter.ex @@ -142,17 +142,6 @@ def html_escape(text, "text/plain") do |> Enum.join("") end - def minify({text, mentions, hashtags}, type) do - {minify(text, type), mentions, hashtags} - end - - def minify(text, "text/html") do - text - |> String.replace(">\n", ">") - |> String.replace("> ", ">") - |> String.replace(" <", "<") - end - def truncate(text, max_length \\ 200, omission \\ "...") do # Remove trailing whitespace text = Regex.replace(~r/([^ \t\r\n])([ \t]+$)/u, text, "\\g{1}") diff --git a/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex b/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex index fa3e2c026..9b38aa4c2 100644 --- a/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/audio_video_validator.ex @@ -96,7 +96,6 @@ defp fix_content(%{"mediaType" => "text/markdown", "content" => content} = data) content = content |> Pleroma.Formatter.markdown_to_html() - |> Pleroma.Formatter.minify("text/html") |> Pleroma.HTML.filter_tags() Map.put(data, "content", content) diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index be86009af..4731e79be 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -296,7 +296,6 @@ def format_input(text, "text/markdown", options) do |> Formatter.mentions_escape(options) |> Formatter.markdown_to_html() |> Formatter.linkify(options) - |> Formatter.minify("text/html") |> Formatter.html_escape("text/html") end diff --git a/test/pleroma/formatter_test.exs b/test/pleroma/formatter_test.exs index ceedd1b6d..5781a3f01 100644 --- a/test/pleroma/formatter_test.exs +++ b/test/pleroma/formatter_test.exs @@ -307,11 +307,4 @@ test "it escapes HTML in plain text" do assert Formatter.html_escape(text, "text/plain") == expected end - - test "it minifies html" do - text = "<p>\nhello</p>\n<p>\nworld</p>\n" - expected = "<p>hello</p><p>world</p>" - - assert Formatter.minify(text, "text/html") == expected - end end From 53760d2cda9b9f241355365b3fff9852bcb1a8a2 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Fri, 30 Apr 2021 12:51:18 -0500 Subject: [PATCH 186/339] Delete obsolete EarmarkRendereTests (moved to UtilsTest) --- test/pleroma/earmark_renderer_test.exs | 79 -------------------------- 1 file changed, 79 deletions(-) delete mode 100644 test/pleroma/earmark_renderer_test.exs diff --git a/test/pleroma/earmark_renderer_test.exs b/test/pleroma/earmark_renderer_test.exs deleted file mode 100644 index 3adbefc1e..000000000 --- a/test/pleroma/earmark_renderer_test.exs +++ /dev/null @@ -1,79 +0,0 @@ -# Pleroma: A lightweight social networking server -# Copyright © 2020 Pleroma Authors <https://pleroma.social/> -# SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.EarmarkRendererTest do - use ExUnit.Case - - test "Paragraph" do - code = ~s[Hello\n\nWorld!] - result = Pleroma.Formatter.markdown_to_html(code) - assert result == "<p>Hello</p><p>World!</p>" - end - - test "raw HTML" do - code = ~s[<a href="http://example.org/">OwO</a><!-- what's this?-->] - result = Pleroma.Formatter.markdown_to_html(code) - assert result == "<p>#{code}</p>" - end - - test "rulers" do - code = ~s[before\n\n-----\n\nafter] - result = Pleroma.Formatter.markdown_to_html(code) - assert result == "<p>before</p><hr /><p>after</p>" - end - - test "headings" do - code = ~s[# h1\n## h2\n### h3\n] - result = Pleroma.Formatter.markdown_to_html(code) - assert result == ~s[<h1>h1</h1><h2>h2</h2><h3>h3</h3>] - end - - test "blockquote" do - code = ~s[> whoms't are you quoting?] - result = Pleroma.Formatter.markdown_to_html(code) - assert result == "<blockquote><p>whoms’t are you quoting?</p></blockquote>" - end - - test "code" do - code = ~s[`mix`] - result = Pleroma.Formatter.markdown_to_html(code) - assert result == ~s[<p><code class="inline">mix</code></p>] - - code = ~s[``mix``] - result = Pleroma.Formatter.markdown_to_html(code) - assert result == ~s[<p><code class="inline">mix</code></p>] - - code = ~s[```\nputs "Hello World"\n```] - result = Pleroma.Formatter.markdown_to_html(code) - assert result == ~s[<pre><code class="">puts "Hello World"</code></pre>] - end - - test "lists" do - code = ~s[- one\n- two\n- three\n- four] - result = Pleroma.Formatter.markdown_to_html(code) - assert result == "<ul><li>one</li><li>two</li><li>three</li><li>four</li></ul>" - - code = ~s[1. one\n2. two\n3. three\n4. four\n] - result = Pleroma.Formatter.markdown_to_html(code) - assert result == "<ol><li>one</li><li>two</li><li>three</li><li>four</li></ol>" - end - - test "delegated renderers" do - code = ~s[a<br/>b] - result = Pleroma.Formatter.markdown_to_html(code) - assert result == "<p>#{code}</p>" - - code = ~s[*aaaa~*] - result = Pleroma.Formatter.markdown_to_html(code) - assert result == ~s[<p><em>aaaa~</em></p>] - - code = ~s[**aaaa~**] - result = Pleroma.Formatter.markdown_to_html(code) - assert result == ~s[<p><strong>aaaa~</strong></p>] - - # strikethrought - code = ~s[<del>aaaa~</del>] - result = Pleroma.Formatter.markdown_to_html(code) - assert result == ~s[<p><del>aaaa~</del></p>] - end -end From a8fa00ef666f574aec8048626aed78a7d62e6915 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Fri, 30 Apr 2021 12:55:43 -0500 Subject: [PATCH 187/339] Fix failing remote mentions test, valid TLDs --- test/pleroma/web/common_api/utils_test.exs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/pleroma/web/common_api/utils_test.exs b/test/pleroma/web/common_api/utils_test.exs index 28b05ed91..8c79a9a83 100644 --- a/test/pleroma/web/common_api/utils_test.exs +++ b/test/pleroma/web/common_api/utils_test.exs @@ -209,10 +209,10 @@ test "local mentions" do end test "remote mentions" do - mario = insert(:user, %{nickname: "mario@mushroom.kingdom", local: false}) - luigi = insert(:user, %{nickname: "luigi@mushroom.kingdom", local: false}) + mario = insert(:user, %{nickname: "mario@mushroom.world", local: false}) + luigi = insert(:user, %{nickname: "luigi@mushroom.world", local: false}) - code = "@mario@mushroom.kingdom @luigi@mushroom.kingdom yo what's up?" + code = "@mario@mushroom.world @luigi@mushroom.world yo what's up?" {result, _, []} = Utils.format_input(code, "text/markdown") assert result == From 3d742c3c1af69a9526c12a171663630b3439b5cc Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Thu, 18 Mar 2021 15:31:50 -0500 Subject: [PATCH 188/339] SimplePolicy: filter nested objects --- lib/pleroma/web/activity_pub/mrf/simple_policy.ex | 11 ++++++++++- .../web/activity_pub/mrf/simple_policy_test.exs | 12 ++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index bb3838d2c..b3e5d814d 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -181,6 +181,14 @@ defp check_banner_removal(%{host: actor_host} = _actor_info, %{"image" => _image defp check_banner_removal(_actor_info, object), do: {:ok, object} + defp check_object(%{"object" => object} = activity) when is_map(object) do + with {:ok, _object} <- filter(object) do + {:ok, activity} + end + end + + defp check_object(object), do: {:ok, object} + @impl true def filter(%{"type" => "Delete", "actor" => actor} = object) do %{host: actor_host} = URI.parse(actor) @@ -206,7 +214,8 @@ def filter(%{"actor" => actor} = object) do {:ok, object} <- check_media_nsfw(actor_info, object), {:ok, object} <- check_ftl_removal(actor_info, object), {:ok, object} <- check_followers_only(actor_info, object), - {:ok, object} <- check_report_removal(actor_info, object) do + {:ok, object} <- check_report_removal(actor_info, object), + {:ok, object} <- check_object(object) do {:ok, object} else {:reject, nil} -> {:reject, "[SimplePolicy]"} diff --git a/test/pleroma/web/activity_pub/mrf/simple_policy_test.exs b/test/pleroma/web/activity_pub/mrf/simple_policy_test.exs index f48e5b39b..b6d9f2ded 100644 --- a/test/pleroma/web/activity_pub/mrf/simple_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/simple_policy_test.exs @@ -260,6 +260,18 @@ test "actor has a matching host" do assert {:reject, _} = SimplePolicy.filter(remote_user) end + + test "reject Announce when object would be rejected" do + clear_config([:mrf_simple, :reject], ["blocked.tld"]) + + announce = %{ + "type" => "Announce", + "actor" => "https://okay.tld/users/alice", + "object" => %{"type" => "Note", "actor" => "https://blocked.tld/users/bob"} + } + + assert {:reject, _} = SimplePolicy.filter(announce) + end end describe "when :followers_only" do From c16c7fdb8794df8558cf8fbe4231d8f9ec01bb6d Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Thu, 29 Apr 2021 11:51:49 -0500 Subject: [PATCH 189/339] SimplePolicy: filter string Objects --- lib/pleroma/web/activity_pub/mrf/simple_policy.ex | 15 ++++++++++++++- .../web/activity_pub/mrf/simple_policy_test.exs | 12 ++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index b3e5d814d..b07d70401 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -181,7 +181,7 @@ defp check_banner_removal(%{host: actor_host} = _actor_info, %{"image" => _image defp check_banner_removal(_actor_info, object), do: {:ok, object} - defp check_object(%{"object" => object} = activity) when is_map(object) do + defp check_object(%{"object" => object} = activity) do with {:ok, _object} <- filter(object) do {:ok, activity} end @@ -240,6 +240,19 @@ def filter(%{"id" => actor, "type" => obj_type} = object) end end + def filter(object) when is_binary(object) do + uri = URI.parse(object) + + with {:ok, object} <- check_accept(uri, object), + {:ok, object} <- check_reject(uri, object) do + {:ok, object} + else + {:reject, nil} -> {:reject, "[SimplePolicy]"} + {:reject, _} = e -> e + _ -> {:reject, "[SimplePolicy]"} + end + end + def filter(object), do: {:ok, object} @impl true diff --git a/test/pleroma/web/activity_pub/mrf/simple_policy_test.exs b/test/pleroma/web/activity_pub/mrf/simple_policy_test.exs index b6d9f2ded..8024a2459 100644 --- a/test/pleroma/web/activity_pub/mrf/simple_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/simple_policy_test.exs @@ -272,6 +272,18 @@ test "reject Announce when object would be rejected" do assert {:reject, _} = SimplePolicy.filter(announce) end + + test "reject by URI object" do + clear_config([:mrf_simple, :reject], ["blocked.tld"]) + + announce = %{ + "type" => "Announce", + "actor" => "https://okay.tld/users/alice", + "object" => "https://blocked.tld/activities/1" + } + + assert {:reject, _} = SimplePolicy.filter(announce) + end end describe "when :followers_only" do From 20878c7f9913e1501821356f24e97c2c42b00a41 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Fri, 2 Apr 2021 12:18:35 -0500 Subject: [PATCH 190/339] CHANGELOG: SimplePolicy embedded objects are now checked --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a0171763..150cd4147 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Applying ConcurrentLimiter settings via AdminAPI - User login failures if their `notification_settings` were in a NULL state. - Mix task `pleroma.user delete_activities` query transaction timeout is now :infinity +- MRF (`SimplePolicy`): Embedded objects are now checked. If any embedded object would be rejected, its parent is rejected. This fixes Announces leaking posts from blocked domains. ## [2.3.0] - 2020-03-01 From dca87c5e7b4b12918cf59a83a77be389a7e0df01 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Sat, 1 May 2021 11:28:06 -0500 Subject: [PATCH 191/339] CHANGELOG: markdown --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a0171763..ed6e548dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Applying ConcurrentLimiter settings via AdminAPI - User login failures if their `notification_settings` were in a NULL state. - Mix task `pleroma.user delete_activities` query transaction timeout is now :infinity +- Fixed some Markdown issues, including trailing slash in links. ## [2.3.0] - 2020-03-01 From c80b1aaf514dec6b538a9833d48df027708b6b4d Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Mon, 3 May 2021 14:27:03 -0500 Subject: [PATCH 192/339] Don't crash when email settings are invalid Fixes: https://git.pleroma.social/pleroma/pleroma/-/issues/2606 Fixes: https://gitlab.com/soapbox-pub/soapbox/-/issues/4 --- lib/pleroma/application_requirements.ex | 38 ++++++++++--------- .../pleroma/application_requirements_test.exs | 18 ++++----- test/pleroma/user_test.exs | 18 +++++++++ 3 files changed, 47 insertions(+), 27 deletions(-) diff --git a/lib/pleroma/application_requirements.ex b/lib/pleroma/application_requirements.ex index 6ef65b263..c412dec5e 100644 --- a/lib/pleroma/application_requirements.ex +++ b/lib/pleroma/application_requirements.ex @@ -34,15 +34,16 @@ defp handle_result({:error, message}), do: raise(VerifyError, message: message) defp check_welcome_message_config!(:ok) do if Pleroma.Config.get([:welcome, :email, :enabled], false) and not Pleroma.Emails.Mailer.enabled?() do - Logger.error(""" - To send welcome email do you need to enable mail. - \nconfig :pleroma, Pleroma.Emails.Mailer, enabled: true - """) + Logger.warn(""" + To send welcome emails, you need to enable the mailer. + Welcome emails will NOT be sent with the current config. - {:error, "The mail disabled."} - else - :ok + Enable the mailer: + config :pleroma, Pleroma.Emails.Mailer, enabled: true + """) end + + :ok end defp check_welcome_message_config!(result), do: result @@ -51,18 +52,21 @@ defp check_welcome_message_config!(result), do: result # def check_confirmation_accounts!(:ok) do if Pleroma.Config.get([:instance, :account_activation_required]) && - not Pleroma.Config.get([Pleroma.Emails.Mailer, :enabled]) do - Logger.error( - "Account activation enabled, but no Mailer settings enabled.\n" <> - "Please set config :pleroma, :instance, account_activation_required: false\n" <> - "Otherwise setup and enable Mailer." - ) + not Pleroma.Emails.Mailer.enabled?() do + Logger.warn(""" + Account activation is required, but the mailer is disabled. + Users will NOT be able to confirm their accounts with this config. + Either disable account activation or enable the mailer. - {:error, - "Account activation enabled, but Mailer is disabled. Cannot send confirmation emails."} - else - :ok + Disable account activation: + config :pleroma, :instance, account_activation_required: false + + Enable the mailer: + config :pleroma, Pleroma.Emails.Mailer, enabled: true + """) end + + :ok end def check_confirmation_accounts!(result), do: result diff --git a/test/pleroma/application_requirements_test.exs b/test/pleroma/application_requirements_test.exs index 683ac8c96..a54c37968 100644 --- a/test/pleroma/application_requirements_test.exs +++ b/test/pleroma/application_requirements_test.exs @@ -35,13 +35,13 @@ test "doesn't raise if the pool size is unexpected but the respective flag is se setup do: clear_config([:welcome]) setup do: clear_config([Pleroma.Emails.Mailer]) - test "raises if welcome email enabled but mail disabled" do + test "warns if welcome email enabled but mail disabled" do clear_config([:welcome, :email, :enabled], true) clear_config([Pleroma.Emails.Mailer, :enabled], false) - assert_raise Pleroma.ApplicationRequirements.VerifyError, "The mail disabled.", fn -> - capture_log(&Pleroma.ApplicationRequirements.verify!/0) - end + assert capture_log(fn -> + assert Pleroma.ApplicationRequirements.verify!() == :ok + end) =~ "Welcome emails will NOT be sent" end end @@ -57,15 +57,13 @@ test "raises if welcome email enabled but mail disabled" do setup do: clear_config([:instance, :account_activation_required]) - test "raises if account confirmation is required but mailer isn't enable" do + test "warns if account confirmation is required but mailer isn't enabled" do clear_config([:instance, :account_activation_required], true) clear_config([Pleroma.Emails.Mailer, :enabled], false) - assert_raise Pleroma.ApplicationRequirements.VerifyError, - "Account activation enabled, but Mailer is disabled. Cannot send confirmation emails.", - fn -> - capture_log(&Pleroma.ApplicationRequirements.verify!/0) - end + assert capture_log(fn -> + assert Pleroma.ApplicationRequirements.verify!() == :ok + end) =~ "Users will NOT be able to confirm their accounts" end test "doesn't do anything if account confirmation is disabled" do diff --git a/test/pleroma/user_test.exs b/test/pleroma/user_test.exs index 6f5bcab57..f89ea458a 100644 --- a/test/pleroma/user_test.exs +++ b/test/pleroma/user_test.exs @@ -572,6 +572,24 @@ test "it sends a registration confirmed email if no others will be sent" do ) end + test "it fails gracefully with invalid email config" do + cng = User.register_changeset(%User{}, @full_user_data) + + # Disable the mailer but enable all the things that want to send emails + clear_config([Pleroma.Emails.Mailer, :enabled], false) + clear_config([:instance, :account_activation_required], true) + clear_config([:instance, :account_approval_required], true) + clear_config([:welcome, :email, :enabled], true) + clear_config([:welcome, :email, :sender], "lain@lain.com") + + # The user is still created + assert {:ok, %User{nickname: "nick"}} = User.register(cng) + + # No emails are sent + ObanHelpers.perform_all() + refute_email_sent() + end + test "it requires an email, name, nickname and password, bio is optional when account_activation_required is enabled" do clear_config([:instance, :account_activation_required], true) From 90770e0841d3ffea87627b35627bfe38cad52f07 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Mon, 3 May 2021 14:30:21 -0500 Subject: [PATCH 193/339] CHANGELOG: don't crash so hard when email settings are invalid --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a0171763..74086a54b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - MRF (`FollowBotPolicy`): New MRF Policy which makes a designated local Bot account attempt to follow all users in public Notes received by your instance. Users who require approving follower requests or have #nobot in their profile are excluded. - Return OAuth token `id` (primary key) in POST `/oauth/token`. +### Fixed +- Don't crash so hard when email settings are invalid. + ## Unreleased (Patch) ### Fixed From 22b2451edd9e42ba96bf7f815383b2eaad9a5e56 Mon Sep 17 00:00:00 2001 From: faried nawaz <faried@gmail.com> Date: Wed, 21 Apr 2021 02:37:03 +0500 Subject: [PATCH 194/339] migration: add on_delete: :delete_all to hashtags object_id fk --- ...204354_delete_hashtags_objects_cascade.exs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 priv/repo/migrations/20210420204354_delete_hashtags_objects_cascade.exs diff --git a/priv/repo/migrations/20210420204354_delete_hashtags_objects_cascade.exs b/priv/repo/migrations/20210420204354_delete_hashtags_objects_cascade.exs new file mode 100644 index 000000000..f4ebf53d6 --- /dev/null +++ b/priv/repo/migrations/20210420204354_delete_hashtags_objects_cascade.exs @@ -0,0 +1,19 @@ +defmodule Pleroma.Repo.Migrations.DeleteHashtagsObjectsCascade do + use Ecto.Migration + + def up do + execute("ALTER TABLE hashtags_objects DROP CONSTRAINT hashtags_objects_object_id_fkey") + + alter table(:hashtags_objects) do + modify(:object_id, references(:objects, on_delete: :delete_all)) + end + end + + def down do + execute("ALTER TABLE hashtags_objects DROP CONSTRAINT hashtags_objects_object_id_fkey") + + alter table(:hashtags_objects) do + modify(:object_id, references(:objects, on_delete: :nothing)) + end + end +end From a0c9a2b4cc8c22d6238b0f31239c1e655f47730f Mon Sep 17 00:00:00 2001 From: faried nawaz <faried@gmail.com> Date: Wed, 21 Apr 2021 02:38:59 +0500 Subject: [PATCH 195/339] mix prune_objects: remove unused hashtags after pruning remote objects --- lib/mix/tasks/pleroma/database.ex | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index e7f4b67a4..53ad58b64 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -96,6 +96,17 @@ def run(["prune_objects" | args]) do ) |> Repo.delete_all(timeout: :infinity) + prune_hashtags_query = """ + delete from hashtags + where id in ( + select id from hashtags as ht + left join hashtags_objects as hto + on hto.hashtag_id = ht.id + where hto.hashtag_id is null) + """ + + Repo.query(prune_hashtags_query) + if Keyword.get(options, :vacuum) do Maintenance.vacuum("full") end From 5be9d139816fa40ff6227950b58f3c6cea01fc81 Mon Sep 17 00:00:00 2001 From: faried nawaz <faried@gmail.com> Date: Wed, 21 Apr 2021 03:52:32 +0500 Subject: [PATCH 196/339] a better query to delete from hashtags old query: Delete on hashtags (cost=5089.81..5521.63 rows=6160 width=18) -> Hash Semi Join (cost=5089.81..5521.63 rows=6160 width=18) Hash Cond: (hashtags.id = ht.id) -> Seq Scan on hashtags (cost=0.00..317.28 rows=17528 width=14) -> Hash (cost=5012.81..5012.81 rows=6160 width=20) -> Merge Anti Join (cost=0.70..5012.81 rows=6160 width=20) Merge Cond: (ht.id = hto.hashtag_id) -> Index Scan using hashtags_pkey on hashtags ht (cost=0.29..610.53 rows=17528 width=14) -> Index Scan using hashtags_objects_pkey on hashtags_objects hto (cost=0.42..3506.48 rows=68158 width=14) new query: Delete on hashtags ht (cost=0.70..5012.81 rows=6160 width=12) -> Merge Anti Join (cost=0.70..5012.81 rows=6160 width=12) Merge Cond: (ht.id = hto.hashtag_id) -> Index Scan using hashtags_pkey on hashtags ht (cost=0.29..610.53 rows=17528 width=14) -> Index Scan using hashtags_objects_pkey on hashtags_objects hto (cost=0.42..3506.48 rows=68158 width=14) --- lib/mix/tasks/pleroma/database.ex | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index 53ad58b64..bcde07774 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -97,12 +97,10 @@ def run(["prune_objects" | args]) do |> Repo.delete_all(timeout: :infinity) prune_hashtags_query = """ - delete from hashtags - where id in ( - select id from hashtags as ht - left join hashtags_objects as hto - on hto.hashtag_id = ht.id - where hto.hashtag_id is null) + delete from hashtags as ht + where not exists ( + select 1 from hashtags_objects hto + where ht.id = hto.hashtag_id) """ Repo.query(prune_hashtags_query) From ab9eabdf20180f2dd8539cf5d3dc0fdc6412496b Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Wed, 12 May 2021 13:38:11 -0500 Subject: [PATCH 197/339] Add SetMeta filter to store uploaded image sizes --- lib/pleroma/upload.ex | 9 ++++- lib/pleroma/upload/filter/set_meta.ex | 36 +++++++++++++++++++ .../web/mastodon_api/views/status_view.ex | 16 +++++++++ test/pleroma/upload/filter/set_meta_test.exs | 19 ++++++++++ .../mastodon_api/views/status_view_test.exs | 5 ++- 5 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 lib/pleroma/upload/filter/set_meta.ex create mode 100644 test/pleroma/upload/filter/set_meta_test.exs diff --git a/lib/pleroma/upload.ex b/lib/pleroma/upload.ex index 654711351..4d58abd48 100644 --- a/lib/pleroma/upload.ex +++ b/lib/pleroma/upload.ex @@ -23,6 +23,8 @@ defmodule Pleroma.Upload do is once created permanent and changing it (especially in uploaders) is probably a bad idea! * `:tempfile` - path to the temporary file. Prefer in-place changes on the file rather than changing the path as the temporary file is also tracked by `Plug.Upload{}` and automatically deleted once the request is over. + * `:width` - width of the media in pixels + * `:height` - height of the media in pixels Related behaviors: @@ -32,6 +34,7 @@ defmodule Pleroma.Upload do """ alias Ecto.UUID alias Pleroma.Config + alias Pleroma.Maps require Logger @type source :: @@ -53,9 +56,11 @@ defmodule Pleroma.Upload do name: String.t(), tempfile: String.t(), content_type: String.t(), + width: integer(), + height: integer(), path: String.t() } - defstruct [:id, :name, :tempfile, :content_type, :path] + defstruct [:id, :name, :tempfile, :content_type, :width, :height, :path] defp get_description(opts, upload) do case {opts[:description], Pleroma.Config.get([Pleroma.Upload, :default_description])} do @@ -89,6 +94,8 @@ def store(upload, opts \\ []) do "mediaType" => upload.content_type, "href" => url_from_spec(upload, opts.base_url, url_spec) } + |> Maps.put_if_present("width", upload.width) + |> Maps.put_if_present("height", upload.height) ], "name" => description }} diff --git a/lib/pleroma/upload/filter/set_meta.ex b/lib/pleroma/upload/filter/set_meta.ex new file mode 100644 index 000000000..cccb6c371 --- /dev/null +++ b/lib/pleroma/upload/filter/set_meta.ex @@ -0,0 +1,36 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter.SetMeta do + @moduledoc """ + Extracts metadata about the upload, such as width/height + """ + require Logger + + @behaviour Pleroma.Upload.Filter + + @spec filter(Pleroma.Upload.t()) :: + {:ok, :filtered, Pleroma.Upload.t()} | {:ok, :noop} | {:error, String.t()} + def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _} = upload) do + try do + image = + file + |> Mogrify.open() + |> Mogrify.verbose() + + upload = + upload + |> Map.put(:width, image.width) + |> Map.put(:height, image.height) + + {:ok, :filtered, upload} + rescue + e in ErlangError -> + Logger.warn("#{__MODULE__}: #{inspect(e)}") + {:ok, :noop} + end + end + + def filter(_), do: {:ok, :noop} +end diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index bac897a57..5dbdc309e 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -426,10 +426,26 @@ def render("attachment.json", %{attachment: attachment}) do type: type, description: attachment["name"], pleroma: %{mime_type: media_type}, + meta: render("attachment_meta.json", %{attachment: attachment}), blurhash: attachment["blurhash"] } end + def render("attachment_meta.json", %{ + attachment: %{"url" => [%{"width" => width, "height" => height} | _]} + }) + when is_integer(width) and is_integer(height) do + %{ + original: %{ + width: width, + height: height, + aspect: width / height + } + } + end + + def render("attachment_meta.json", _), do: %{} + def render("context.json", %{activity: activity, activities: activities, user: user}) do %{ancestors: ancestors, descendants: descendants} = activities diff --git a/test/pleroma/upload/filter/set_meta_test.exs b/test/pleroma/upload/filter/set_meta_test.exs new file mode 100644 index 000000000..650e527b4 --- /dev/null +++ b/test/pleroma/upload/filter/set_meta_test.exs @@ -0,0 +1,19 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Upload.Filter.SetMetaTest do + use Pleroma.DataCase, async: true + alias Pleroma.Upload.Filter.SetMeta + + test "adds the image dimensions" do + upload = %Pleroma.Upload{ + name: "an… image.jpg", + content_type: "image/jpeg", + path: Path.absname("test/fixtures/image.jpg"), + tempfile: Path.absname("test/fixtures/image.jpg") + } + + assert {:ok, :filtered, %{width: 1024, height: 768}} = SetMeta.filter(upload) + end +end diff --git a/test/pleroma/web/mastodon_api/views/status_view_test.exs b/test/pleroma/web/mastodon_api/views/status_view_test.exs index 2de3afc4f..e6c37e782 100644 --- a/test/pleroma/web/mastodon_api/views/status_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/status_view_test.exs @@ -458,7 +458,9 @@ test "attachments" do "url" => [ %{ "mediaType" => "image/png", - "href" => "someurl" + "href" => "someurl", + "width" => 200, + "height" => 100 } ], "blurhash" => "UJJ8X[xYW,%Jtq%NNFbXB5j]IVM|9GV=WHRn", @@ -474,6 +476,7 @@ test "attachments" do text_url: "someurl", description: nil, pleroma: %{mime_type: "image/png"}, + meta: %{original: %{width: 200, height: 100, aspect: 2}}, blurhash: "UJJ8X[xYW,%Jtq%NNFbXB5j]IVM|9GV=WHRn" } From 4c060ae73371a8567468186e5d1333ec00fbdf41 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Wed, 12 May 2021 15:38:49 -0500 Subject: [PATCH 198/339] Ingest remote attachment width/height --- .../object_validators/attachment_validator.ex | 4 ++- .../web/activity_pub/transmogrifier.ex | 2 ++ .../attachment_validator_test.exs | 33 +++++++++++++++++++ .../transmogrifier/audio_handling_test.exs | 4 ++- .../transmogrifier/video_handling_test.exs | 12 +++++-- 5 files changed, 50 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex index 3175427ad..a99b40adc 100644 --- a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex @@ -21,6 +21,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AttachmentValidator do field(:type, :string) field(:href, ObjectValidators.Uri) field(:mediaType, :string, default: "application/octet-stream") + field(:width, :integer) + field(:height, :integer) end end @@ -52,7 +54,7 @@ def url_changeset(struct, data) do data = fix_media_type(data) struct - |> cast(data, [:type, :href, :mediaType]) + |> cast(data, [:type, :href, :mediaType, :width, :height]) |> validate_inclusion(:type, ["Link"]) |> validate_required([:type, :href, :mediaType]) end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 4d9a5617e..b5767863c 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -245,6 +245,8 @@ def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachm "type" => Map.get(url || %{}, "type", "Link") } |> Maps.put_if_present("mediaType", media_type) + |> Maps.put_if_present("width", (url || %{})["width"]) + |> Maps.put_if_present("height", (url || %{})["height"]) %{ "url" => [attachment_url], diff --git a/test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs b/test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs index b775515e0..0e49fda99 100644 --- a/test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/attachment_validator_test.exs @@ -72,5 +72,38 @@ test "it handles our own uploads" do assert attachment.mediaType == "image/jpeg" end + + test "it handles image dimensions" do + attachment = %{ + "url" => [ + %{ + "type" => "Link", + "mediaType" => "image/jpeg", + "href" => "https://example.com/images/1.jpg", + "width" => 200, + "height" => 100 + } + ], + "type" => "Document", + "name" => nil, + "mediaType" => "image/jpeg" + } + + {:ok, attachment} = + AttachmentValidator.cast_and_validate(attachment) + |> Ecto.Changeset.apply_action(:insert) + + assert [ + %{ + href: "https://example.com/images/1.jpg", + type: "Link", + mediaType: "image/jpeg", + width: 200, + height: 100 + } + ] = attachment.url + + assert attachment.mediaType == "image/jpeg" + end end end diff --git a/test/pleroma/web/activity_pub/transmogrifier/audio_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/audio_handling_test.exs index e733f167d..a21e9e3d3 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/audio_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/audio_handling_test.exs @@ -76,7 +76,9 @@ test "Funkwhale Audio object" do "href" => "https://channels.tests.funkwhale.audio/api/v1/listen/3901e5d8-0445-49d5-9711-e096cf32e515/?upload=42342395-0208-4fee-a38d-259a6dae0871&download=false", "mediaType" => "audio/ogg", - "type" => "Link" + "type" => "Link", + "width" => nil, + "height" => nil } ] } diff --git a/test/pleroma/web/activity_pub/transmogrifier/video_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/video_handling_test.exs index 6ddf7c172..62b4a2cb3 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/video_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/video_handling_test.exs @@ -60,7 +60,9 @@ test "it remaps video URLs as attachments if necessary" do "href" => "https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4", "mediaType" => "video/mp4", - "type" => "Link" + "type" => "Link", + "width" => nil, + "height" => nil } ] } @@ -83,7 +85,9 @@ test "it remaps video URLs as attachments if necessary" do "href" => "https://framatube.org/static/webseed/6050732a-8a7a-43d4-a6cd-809525a1d206-1080.mp4", "mediaType" => "video/mp4", - "type" => "Link" + "type" => "Link", + "width" => nil, + "height" => nil } ] } @@ -113,7 +117,9 @@ test "it works for peertube videos with only their mpegURL map" do "href" => "https://peertube.stream/static/streaming-playlists/hls/abece3c3-b9c6-47f4-8040-f3eed8c602e6/abece3c3-b9c6-47f4-8040-f3eed8c602e6-1080-fragmented.mp4", "mediaType" => "video/mp4", - "type" => "Link" + "type" => "Link", + "width" => nil, + "height" => nil } ] } From 02b9436494998e441fe2119b78c0e4f68c45a9e1 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Wed, 12 May 2021 16:16:10 -0500 Subject: [PATCH 199/339] Don't render media `meta` if nil --- lib/pleroma/web/mastodon_api/views/status_view.ex | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 5dbdc309e..7f318e81b 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do alias Pleroma.Activity alias Pleroma.HTML + alias Pleroma.Maps alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User @@ -406,6 +407,7 @@ def render("attachment.json", %{attachment: attachment}) do media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image" href = attachment_url["href"] |> MediaProxy.url() href_preview = attachment_url["href"] |> MediaProxy.preview_url() + meta = render("attachment_meta.json", %{attachment: attachment}) type = cond do @@ -426,9 +428,9 @@ def render("attachment.json", %{attachment: attachment}) do type: type, description: attachment["name"], pleroma: %{mime_type: media_type}, - meta: render("attachment_meta.json", %{attachment: attachment}), blurhash: attachment["blurhash"] } + |> Maps.put_if_present(:meta, meta) end def render("attachment_meta.json", %{ @@ -444,7 +446,7 @@ def render("attachment_meta.json", %{ } end - def render("attachment_meta.json", _), do: %{} + def render("attachment_meta.json", _), do: nil def render("context.json", %{activity: activity, activities: activities, user: user}) do %{ancestors: ancestors, descendants: descendants} = From 6f0b42656dcce9cd7e4c833be42b6544954ca93b Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Wed, 12 May 2021 19:03:10 -0500 Subject: [PATCH 200/339] Federate attachments as Links instead of Documents --- lib/pleroma/web/activity_pub/transmogrifier.ex | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index b5767863c..acb4f4b3e 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -245,8 +245,8 @@ def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachm "type" => Map.get(url || %{}, "type", "Link") } |> Maps.put_if_present("mediaType", media_type) - |> Maps.put_if_present("width", (url || %{})["width"]) - |> Maps.put_if_present("height", (url || %{})["height"]) + |> Maps.put_if_present("width", (url || %{})["width"] || data["width"]) + |> Maps.put_if_present("height", (url || %{})["height"] || data["height"]) %{ "url" => [attachment_url], @@ -963,7 +963,7 @@ def prepare_attachments(object) do object |> Map.get("attachment", []) |> Enum.map(fn data -> - [%{"mediaType" => media_type, "href" => href} | _] = data["url"] + [%{"mediaType" => media_type, "href" => href} = url | _] = data["url"] %{ "url" => href, @@ -971,6 +971,8 @@ def prepare_attachments(object) do "name" => data["name"], "type" => "Document" } + |> Maps.put_if_present("width", url["width"]) + |> Maps.put_if_present("height", url["height"]) end) Map.put(object, "attachment", attachments) From 5a57b025c7745ebdc7ecf8c7d6b75bcc6770562a Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Wed, 12 May 2021 20:15:33 -0500 Subject: [PATCH 201/339] Changelog: attachment meta --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bb4b1e73..22eaa0b94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - MRF (`FollowBotPolicy`): New MRF Policy which makes a designated local Bot account attempt to follow all users in public Notes received by your instance. Users who require approving follower requests or have #nobot in their profile are excluded. - Return OAuth token `id` (primary key) in POST `/oauth/token`. +- `SetMeta` upload filter for extracting attachment dimensions. +- Attachment dimensions are federated when available. ### Fixed - Don't crash so hard when email settings are invalid. From 543e9402d64bce556f85294f91dc690c9acec51f Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Fri, 14 May 2021 08:38:23 -0500 Subject: [PATCH 202/339] Support blurhash --- lib/pleroma/upload.ex | 7 +++++-- lib/pleroma/upload/filter/set_meta.ex | 9 +++++++++ lib/pleroma/web/activity_pub/transmogrifier.ex | 1 + mix.exs | 3 +++ mix.lock | 1 + 5 files changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/upload.ex b/lib/pleroma/upload.ex index 4d58abd48..5570ed104 100644 --- a/lib/pleroma/upload.ex +++ b/lib/pleroma/upload.ex @@ -25,6 +25,7 @@ defmodule Pleroma.Upload do path as the temporary file is also tracked by `Plug.Upload{}` and automatically deleted once the request is over. * `:width` - width of the media in pixels * `:height` - height of the media in pixels + * `:blurhash` - string hash of the image encoded with the blurhash algorithm (https://blurha.sh/) Related behaviors: @@ -58,9 +59,10 @@ defmodule Pleroma.Upload do content_type: String.t(), width: integer(), height: integer(), + blurhash: String.t(), path: String.t() } - defstruct [:id, :name, :tempfile, :content_type, :width, :height, :path] + defstruct [:id, :name, :tempfile, :content_type, :width, :height, :blurhash, :path] defp get_description(opts, upload) do case {opts[:description], Pleroma.Config.get([Pleroma.Upload, :default_description])} do @@ -98,7 +100,8 @@ def store(upload, opts \\ []) do |> Maps.put_if_present("height", upload.height) ], "name" => description - }} + } + |> Maps.put_if_present("blurhash", upload.blurhash)} else {:description_limit, _} -> {:error, :description_too_long} diff --git a/lib/pleroma/upload/filter/set_meta.ex b/lib/pleroma/upload/filter/set_meta.ex index cccb6c371..81c48228a 100644 --- a/lib/pleroma/upload/filter/set_meta.ex +++ b/lib/pleroma/upload/filter/set_meta.ex @@ -23,6 +23,7 @@ def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _} = upload) upload |> Map.put(:width, image.width) |> Map.put(:height, image.height) + |> Map.put(:blurhash, get_blurhash(file)) {:ok, :filtered, upload} rescue @@ -33,4 +34,12 @@ def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _} = upload) end def filter(_), do: {:ok, :noop} + + defp get_blurhash(file) do + with {:ok, blurhash} <- :eblurhash.magick(file) do + blurhash + else + _ -> nil + end + end end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index acb4f4b3e..f601d6111 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -973,6 +973,7 @@ def prepare_attachments(object) do } |> Maps.put_if_present("width", url["width"]) |> Maps.put_if_present("height", url["height"]) + |> Maps.put_if_present("blurhash", data["blurhash"]) end) Map.put(object, "attachment", attachments) diff --git a/mix.exs b/mix.exs index 436381f32..08581824a 100644 --- a/mix.exs +++ b/mix.exs @@ -198,6 +198,9 @@ defp deps do {:open_api_spex, git: "https://git.pleroma.social/pleroma/elixir-libraries/open_api_spex.git", ref: "f296ac0924ba3cf79c7a588c4c252889df4c2edd"}, + {:eblurhash, + git: "https://github.com/zotonic/eblurhash.git", + ref: "04a0b76eadf4de1be17726f39b6313b88708fd12"}, ## dev & test {:ex_doc, "~> 0.22", only: :dev, runtime: false}, diff --git a/mix.lock b/mix.lock index 99be81826..d24f9c699 100644 --- a/mix.lock +++ b/mix.lock @@ -29,6 +29,7 @@ "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, "earmark_parser": {:hex, :earmark_parser, "1.4.10", "6603d7a603b9c18d3d20db69921527f82ef09990885ed7525003c7fe7dc86c56", [:mix], [], "hexpm", "8e2d5370b732385db2c9b22215c3f59c84ac7dda7ed7e544d7c459496ae519c0"}, + "eblurhash": {:git, "https://github.com/zotonic/eblurhash.git", "04a0b76eadf4de1be17726f39b6313b88708fd12", [ref: "04a0b76eadf4de1be17726f39b6313b88708fd12"]}, "ecto": {:hex, :ecto, "3.4.6", "08f7afad3257d6eb8613309af31037e16c36808dfda5a3cd0cb4e9738db030e4", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6f13a9e2a62e75c2dcfc7207bfc65645ab387af8360db4c89fee8b5a4bf3f70b"}, "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, "ecto_sql": {:hex, :ecto_sql, "3.4.5", "30161f81b167d561a9a2df4329c10ae05ff36eca7ccc84628f2c8b9fa1e43323", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.0", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "31990c6a3579b36a3c0841d34a94c275e727de8b84f58509da5f1b2032c98ac2"}, From b22f54eb29237b4c34a26b497f88770dbebf5578 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Sun, 16 May 2021 12:26:32 -0500 Subject: [PATCH 203/339] Make prod.secret.exs optional (with warning) --- config/prod.exs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/config/prod.exs b/config/prod.exs index adbce5606..0e151000b 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -63,7 +63,12 @@ # Finally import the config/prod.secret.exs # which should be versioned separately. -import_config "prod.secret.exs" +if File.exists?("./config/prod.secret.exs") do + import_config "prod.secret.exs" +else + "`config/prod.secret.exs` not found. You may want to create one by running `mix pleroma.instance gen`" + |> IO.warn([]) +end if File.exists?("./config/prod.exported_from_db.secret.exs"), do: import_config("prod.exported_from_db.secret.exs") From b540fff9081765feeadcc880af43f5d5d49d1e9c Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Sun, 16 May 2021 12:20:20 -0500 Subject: [PATCH 204/339] Docs: use `MIX_ENV=prod mix pleroma.instance gen` --- docs/installation/alpine_linux_en.md | 2 +- docs/installation/arch_linux_en.md | 2 +- docs/installation/debian_based_en.md | 2 +- docs/installation/debian_based_jp.md | 4 ++-- docs/installation/freebsd_en.md | 6 +++--- docs/installation/gentoo_en.md | 10 +++++----- docs/installation/netbsd_en.md | 4 ++-- docs/installation/openbsd_en.md | 2 +- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/installation/alpine_linux_en.md b/docs/installation/alpine_linux_en.md index 7eb1718f2..c2dbd836d 100644 --- a/docs/installation/alpine_linux_en.md +++ b/docs/installation/alpine_linux_en.md @@ -117,7 +117,7 @@ cd /opt/pleroma sudo -Hu pleroma mix deps.get ``` -* Generate the configuration: `sudo -Hu pleroma mix pleroma.instance gen` +* Generate the configuration: `sudo -Hu pleroma MIX_ENV=prod mix pleroma.instance gen` * Answer with `yes` if it asks you to install `rebar3`. * This may take some time, because parts of pleroma get compiled first. * After that it will ask you a few questions about your instance and generates a configuration file in `config/generated_config.exs`. diff --git a/docs/installation/arch_linux_en.md b/docs/installation/arch_linux_en.md index da78c3205..53afccc0f 100644 --- a/docs/installation/arch_linux_en.md +++ b/docs/installation/arch_linux_en.md @@ -92,7 +92,7 @@ cd /opt/pleroma sudo -Hu pleroma mix deps.get ``` -* Generate the configuration: `sudo -Hu pleroma mix pleroma.instance gen` +* Generate the configuration: `sudo -Hu pleroma MIX_ENV=prod mix pleroma.instance gen` * Answer with `yes` if it asks you to install `rebar3`. * This may take some time, because parts of pleroma get compiled first. * After that it will ask you a few questions about your instance and generates a configuration file in `config/generated_config.exs`. diff --git a/docs/installation/debian_based_en.md b/docs/installation/debian_based_en.md index c5687a01e..a9cf86ab3 100644 --- a/docs/installation/debian_based_en.md +++ b/docs/installation/debian_based_en.md @@ -90,7 +90,7 @@ cd /opt/pleroma sudo -Hu pleroma mix deps.get ``` -* Generate the configuration: `sudo -Hu pleroma mix pleroma.instance gen` +* Generate the configuration: `sudo -Hu pleroma MIX_ENV=prod mix pleroma.instance gen` * Answer with `yes` if it asks you to install `rebar3`. * This may take some time, because parts of pleroma get compiled first. * After that it will ask you a few questions about your instance and generates a configuration file in `config/generated_config.exs`. diff --git a/docs/installation/debian_based_jp.md b/docs/installation/debian_based_jp.md index c4bbd4780..e076e2308 100644 --- a/docs/installation/debian_based_jp.md +++ b/docs/installation/debian_based_jp.md @@ -89,7 +89,7 @@ sudo -Hu pleroma mix deps.get * コンフィギュレーションを生成します。 ``` -sudo -Hu pleroma mix pleroma.instance gen +sudo -Hu pleroma MIX_ENV=prod mix pleroma.instance gen ``` * rebar3をインストールしてもよいか聞かれたら、yesを入力してください。 * このときにpleromaの一部がコンパイルされるため、この処理には時間がかかります。 @@ -103,7 +103,7 @@ sudo -Hu pleroma mv config/{generated_config.exs,prod.secret.exs} * 先程のコマンドで、すでに `config/setup_db.psql` というファイルが作られています。このファイルをもとに、データベースを作成します。 ``` -sudo -Hu pleroma mix pleroma.instance gen +sudo -Hu pleroma MIX_ENV=prod mix pleroma.instance gen ``` * そして、データベースのマイグレーションを実行します。 diff --git a/docs/installation/freebsd_en.md b/docs/installation/freebsd_en.md index 2dc466eb8..f4f4d0db9 100644 --- a/docs/installation/freebsd_en.md +++ b/docs/installation/freebsd_en.md @@ -1,8 +1,8 @@ -# Installing on FreeBSD +# Installing on FreeBSD This document was written for FreeBSD 12.1, but should be work on future releases. -## Required software +## Required software This assumes the target system has `pkg(8)`. @@ -54,7 +54,7 @@ Configure Pleroma. Note that you need a domain name at this point: ``` $ cd /home/pleroma/pleroma $ mix deps.get # Enter "y" when asked to install Hex -$ mix pleroma.instance gen # You will be asked a few questions here. +$ MIX_ENV=prod mix pleroma.instance gen # You will be asked a few questions here. $ cp config/generated_config.exs config/prod.secret.exs ``` diff --git a/docs/installation/gentoo_en.md b/docs/installation/gentoo_en.md index f2380ab72..af68db70d 100644 --- a/docs/installation/gentoo_en.md +++ b/docs/installation/gentoo_en.md @@ -54,7 +54,7 @@ Gentoo quite pointedly does not come with a cron daemon installed, and as such i # emerge --ask dev-db/postgresql dev-lang/elixir dev-vcs/git www-servers/nginx app-crypt/certbot app-crypt/certbot-nginx dev-util/cmake sys-apps/file ``` -If you would not like to install the optional packages, remove them from this line. +If you would not like to install the optional packages, remove them from this line. If you're running this from a low-powered virtual machine, it should work though it will take some time. There were no issues on a VPS with a single core and 1GB of RAM; if you are using an even more limited device and run into issues, you can try creating a swapfile or use a more powerful machine running Gentoo to [cross build](https://wiki.gentoo.org/wiki/Cross_build_environment). If you have a wait ahead of you, now would be a good time to take a break, strech a bit, refresh your beverage of choice and/or get a snack, and reply to Arch users' posts with "I use Gentoo btw" as we do. @@ -79,12 +79,12 @@ The output from emerging postgresql should give you a command for initializing t ``` * Start postgres and enable the system service - + ```shell # /etc/init.d/postgresql-11 start # rc-update add postgresql-11 default ``` - + ### A note on licenses, the AGPL, and deployment procedures If you do not plan to make any modifications to your Pleroma instance, cloning directly from the main repo will get you what you need. However, if you plan on doing any contributions to upstream development, making changes or modifications to your instance, making custom themes, or want to play around--and let's be honest here, if you're using Gentoo that is most likely you--you will save yourself a lot of headache later if you take the time right now to fork the Pleroma repo and use that in the following section. @@ -135,7 +135,7 @@ pleroma$ mix deps.get * Generate the configuration: ```shell -pleroma$ mix pleroma.instance gen +pleroma$ MIX_ENV=prod mix pleroma.instance gen ``` * Answer with `yes` if it asks you to install `rebar3`. @@ -241,7 +241,7 @@ First, ensure that the command you will be installing into your crontab works. # /usr/bin/certbot renew --nginx ``` -Assuming not much time has passed since you got certbot working a few steps ago, you should get a message for all domains you installed certificates for saying `Cert not yet due for renewal`. +Assuming not much time has passed since you got certbot working a few steps ago, you should get a message for all domains you installed certificates for saying `Cert not yet due for renewal`. Now, run crontab as a superuser with `crontab -e` or `sudo crontab -e` as appropriate, and add the following line to your cron: diff --git a/docs/installation/netbsd_en.md b/docs/installation/netbsd_en.md index 233cf28b7..22cdd5691 100644 --- a/docs/installation/netbsd_en.md +++ b/docs/installation/netbsd_en.md @@ -1,6 +1,6 @@ # Installing on NetBSD -## Required software +## Required software pkgin should have been installed by the NetBSD installer if you selected the right options. If it isn't installed, install it using pkg_add. @@ -71,7 +71,7 @@ Configure Pleroma. Note that you need a domain name at this point: ``` $ cd /home/pleroma/pleroma $ mix deps.get -$ mix pleroma.instance gen # You will be asked a few questions here. +$ MIX_ENV=prod mix pleroma.instance gen # You will be asked a few questions here. ``` Since Postgres is configured, we can now initialize the database. There should diff --git a/docs/installation/openbsd_en.md b/docs/installation/openbsd_en.md index 0e1269ca5..017b37519 100644 --- a/docs/installation/openbsd_en.md +++ b/docs/installation/openbsd_en.md @@ -239,7 +239,7 @@ Enter a shell as \_pleroma (as root `su _pleroma -`) and enter pleroma's install Then follow the main installation guide: * run `mix deps.get` - * run `mix pleroma.instance gen` and enter your instance's information when asked + * run `MIX_ENV=prod mix pleroma.instance gen` and enter your instance's information when asked * copy config/generated\_config.exs to config/prod.secret.exs. The default values should be sufficient but you should edit it and check that everything seems OK. * exit your current shell back to a root one and run `psql -U postgres -f /home/_pleroma/pleroma/config/setup_db.psql` to setup the database. * return to a \_pleroma shell into pleroma's installation directory (`su _pleroma -;cd ~/pleroma`) and run `MIX_ENV=prod mix ecto.migrate` From 230ad82dadf013cb56909d1e8df2a2d652c47068 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Sun, 16 May 2021 13:22:07 -0500 Subject: [PATCH 205/339] gitignore `config/runtime.exs` --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f30f4cf5f..da73b6f36 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ erl_crash.dump # variables. /config/*.secret.exs /config/generated_config.exs +/config/runtime.exs /config/*.env @@ -56,4 +57,4 @@ pleroma.iml # Editor temp files /*~ -/*# \ No newline at end of file +/*# From 9b6b5ac196d9a2defb74902bffad67505b0de5c5 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 18 May 2021 15:33:33 -0500 Subject: [PATCH 206/339] Rename upload filter to AnalyzeMetadata --- CHANGELOG.md | 2 +- .../upload/filter/{set_meta.ex => analyze_metadata.ex} | 2 +- .../filter/{set_meta_test.exs => analyze_metadata_test.exs} | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) rename lib/pleroma/upload/filter/{set_meta.ex => analyze_metadata.ex} (95%) rename test/pleroma/upload/filter/{set_meta_test.exs => analyze_metadata_test.exs} (70%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22eaa0b94..1a69414a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - MRF (`FollowBotPolicy`): New MRF Policy which makes a designated local Bot account attempt to follow all users in public Notes received by your instance. Users who require approving follower requests or have #nobot in their profile are excluded. - Return OAuth token `id` (primary key) in POST `/oauth/token`. -- `SetMeta` upload filter for extracting attachment dimensions. +- `AnalyzeMetadata` upload filter for extracting attachment dimensions. - Attachment dimensions are federated when available. ### Fixed diff --git a/lib/pleroma/upload/filter/set_meta.ex b/lib/pleroma/upload/filter/analyze_metadata.ex similarity index 95% rename from lib/pleroma/upload/filter/set_meta.ex rename to lib/pleroma/upload/filter/analyze_metadata.ex index 81c48228a..8c23076d4 100644 --- a/lib/pleroma/upload/filter/set_meta.ex +++ b/lib/pleroma/upload/filter/analyze_metadata.ex @@ -2,7 +2,7 @@ # Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Upload.Filter.SetMeta do +defmodule Pleroma.Upload.Filter.AnalyzeMetadata do @moduledoc """ Extracts metadata about the upload, such as width/height """ diff --git a/test/pleroma/upload/filter/set_meta_test.exs b/test/pleroma/upload/filter/analyze_metadata_test.exs similarity index 70% rename from test/pleroma/upload/filter/set_meta_test.exs rename to test/pleroma/upload/filter/analyze_metadata_test.exs index 650e527b4..6f0e432ef 100644 --- a/test/pleroma/upload/filter/set_meta_test.exs +++ b/test/pleroma/upload/filter/analyze_metadata_test.exs @@ -2,9 +2,9 @@ # Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Upload.Filter.SetMetaTest do +defmodule Pleroma.Upload.Filter.AnalyzeMetadataTest do use Pleroma.DataCase, async: true - alias Pleroma.Upload.Filter.SetMeta + alias Pleroma.Upload.Filter.AnalyzeMetadata test "adds the image dimensions" do upload = %Pleroma.Upload{ @@ -14,6 +14,6 @@ test "adds the image dimensions" do tempfile: Path.absname("test/fixtures/image.jpg") } - assert {:ok, :filtered, %{width: 1024, height: 768}} = SetMeta.filter(upload) + assert {:ok, :filtered, %{width: 1024, height: 768}} = AnalyzeMetadata.filter(upload) end end From 4ab3ef07d0f10815e7a91ba3143b7f97cd2a6058 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 18 May 2021 15:51:11 -0500 Subject: [PATCH 207/339] Check AnalyzeMetadata filter's required commands eblurhash:magick uses "convert" Fetching image metadata uses "mogrify" --- lib/pleroma/application_requirements.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/application_requirements.ex b/lib/pleroma/application_requirements.ex index c412dec5e..294eb3b6b 100644 --- a/lib/pleroma/application_requirements.ex +++ b/lib/pleroma/application_requirements.ex @@ -166,7 +166,9 @@ defp check_system_commands!(:ok) do filter_commands_statuses = [ check_filter(Pleroma.Upload.Filters.Exiftool, "exiftool"), check_filter(Pleroma.Upload.Filters.Mogrify, "mogrify"), - check_filter(Pleroma.Upload.Filters.Mogrifun, "mogrify") + check_filter(Pleroma.Upload.Filters.Mogrifun, "mogrify"), + check_filter(Pleroma.Upload.Filters.AnalyzeMetadata, "mogrify"), + check_filter(Pleroma.Upload.Filters.AnalyzeMetadata, "convert") ] preview_proxy_commands_status = From c64cbee26c7b78f9743b668724d4797faa6a942a Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 18 May 2021 16:28:21 -0500 Subject: [PATCH 208/339] Fixed checking for Upload Filter required commands --- CHANGELOG.md | 1 + lib/pleroma/application_requirements.ex | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a69414a5..768405dd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Don't crash so hard when email settings are invalid. +- Checking activated Upload Filters for required commands. ## Unreleased (Patch) diff --git a/lib/pleroma/application_requirements.ex b/lib/pleroma/application_requirements.ex index 294eb3b6b..ee6ee9516 100644 --- a/lib/pleroma/application_requirements.ex +++ b/lib/pleroma/application_requirements.ex @@ -164,11 +164,11 @@ defp do_check_rum!(setting, migrate) do defp check_system_commands!(:ok) do filter_commands_statuses = [ - check_filter(Pleroma.Upload.Filters.Exiftool, "exiftool"), - check_filter(Pleroma.Upload.Filters.Mogrify, "mogrify"), - check_filter(Pleroma.Upload.Filters.Mogrifun, "mogrify"), - check_filter(Pleroma.Upload.Filters.AnalyzeMetadata, "mogrify"), - check_filter(Pleroma.Upload.Filters.AnalyzeMetadata, "convert") + check_filter(Pleroma.Upload.Filter.Exiftool, "exiftool"), + check_filter(Pleroma.Upload.Filter.Mogrify, "mogrify"), + check_filter(Pleroma.Upload.Filter.Mogrifun, "mogrify"), + check_filter(Pleroma.Upload.Filter.AnalyzeMetadata, "mogrify"), + check_filter(Pleroma.Upload.Filter.AnalyzeMetadata, "convert") ] preview_proxy_commands_status = From 2d7f6ce6fb047872083c2db6ad8b75a9032211fd Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 18 May 2021 16:46:51 -0500 Subject: [PATCH 209/339] Clarify AttachmentMetadata changes --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 768405dd6..898f8adb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - MRF (`FollowBotPolicy`): New MRF Policy which makes a designated local Bot account attempt to follow all users in public Notes received by your instance. Users who require approving follower requests or have #nobot in their profile are excluded. - Return OAuth token `id` (primary key) in POST `/oauth/token`. -- `AnalyzeMetadata` upload filter for extracting attachment dimensions. -- Attachment dimensions are federated when available. +- `AnalyzeMetadata` upload filter for extracting attachment dimensions and generating blurhashes. +- Attachment dimensions and blurhashes are federated when available. ### Fixed - Don't crash so hard when email settings are invalid. From 07fed0fda2473fc4e1e3b01e863217391fd2902f Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Tue, 18 May 2021 17:11:25 -0500 Subject: [PATCH 210/339] Switch to aliasing `Router.Helpers` instead of importing --- lib/pleroma/web.ex | 6 ++++-- lib/pleroma/web/feed/user_controller.ex | 2 +- lib/pleroma/web/mastodon_api/controllers/auth_controller.ex | 4 ++-- lib/pleroma/web/o_auth/o_auth_controller.ex | 4 ++-- lib/pleroma/web/templates/feed/feed/tag.atom.eex | 4 ++-- lib/pleroma/web/templates/feed/feed/tag.rss.eex | 2 +- lib/pleroma/web/templates/feed/feed/user.atom.eex | 6 +++--- lib/pleroma/web/templates/feed/feed/user.rss.eex | 6 +++--- lib/pleroma/web/templates/masto_fe/index.html.eex | 2 +- lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex | 4 ++-- lib/pleroma/web/templates/o_auth/mfa/totp.html.eex | 4 ++-- lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex | 2 +- lib/pleroma/web/templates/o_auth/o_auth/register.html.eex | 2 +- lib/pleroma/web/templates/o_auth/o_auth/show.html.eex | 2 +- .../web/templates/twitter_api/password/reset.html.eex | 2 +- .../web/templates/twitter_api/remote_follow/follow.html.eex | 2 +- .../twitter_api/remote_follow/follow_login.html.eex | 2 +- .../templates/twitter_api/remote_follow/follow_mfa.html.eex | 2 +- .../web/templates/twitter_api/util/subscribe.html.eex | 2 +- .../web/twitter_api/controllers/remote_follow_controller.ex | 2 +- lib/pleroma/web/views/masto_fe_view.ex | 2 +- 21 files changed, 33 insertions(+), 31 deletions(-) diff --git a/lib/pleroma/web.ex b/lib/pleroma/web.ex index 8630f244b..24751faba 100644 --- a/lib/pleroma/web.ex +++ b/lib/pleroma/web.ex @@ -35,9 +35,10 @@ def controller do import Plug.Conn import Pleroma.Web.Gettext - import Pleroma.Web.Router.Helpers import Pleroma.Web.TranslationHelpers + alias Pleroma.Web.Router.Helpers, as: Routes + plug(:set_put_layout) defp set_put_layout(conn, _) do @@ -131,7 +132,8 @@ def view do import Pleroma.Web.ErrorHelpers import Pleroma.Web.Gettext - import Pleroma.Web.Router.Helpers + + alias Pleroma.Web.Router.Helpers, as: Routes require Logger diff --git a/lib/pleroma/web/feed/user_controller.ex b/lib/pleroma/web/feed/user_controller.ex index 58d35da1e..fa7879caf 100644 --- a/lib/pleroma/web/feed/user_controller.ex +++ b/lib/pleroma/web/feed/user_controller.ex @@ -28,7 +28,7 @@ def feed_redirect(%{assigns: %{format: format}} = conn, _params) def feed_redirect(conn, %{"nickname" => nickname}) do with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname(nickname)} do - redirect(conn, external: "#{user_feed_url(conn, :feed, user.nickname)}.atom") + redirect(conn, external: "#{Routes.user_feed_url(conn, :feed, user.nickname)}.atom") end end diff --git a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex index eb6639fc5..4920d65da 100644 --- a/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/auth_controller.ex @@ -53,7 +53,7 @@ def login(conn, params) do defp redirect_to_oauth_form(conn, _params) do with {:ok, app} <- local_mastofe_app() do path = - o_auth_path(conn, :authorize, + Routes.o_auth_path(conn, :authorize, response_type: "code", client_id: app.client_id, redirect_uri: ".", @@ -90,7 +90,7 @@ def password_reset(conn, params) do defp local_mastodon_post_login_path(conn) do case get_session(conn, :return_to) do nil -> - masto_fe_path(conn, :index, ["getting-started"]) + Routes.masto_fe_path(conn, :index, ["getting-started"]) return_to -> delete_session(conn, :return_to) diff --git a/lib/pleroma/web/o_auth/o_auth_controller.ex b/lib/pleroma/web/o_auth/o_auth_controller.ex index 215d97b3a..42f4d768f 100644 --- a/lib/pleroma/web/o_auth/o_auth_controller.ex +++ b/lib/pleroma/web/o_auth/o_auth_controller.ex @@ -427,7 +427,7 @@ def prepare_request(%Plug.Conn{} = conn, %{ |> Map.put("state", state) # Handing the request to Ueberauth - redirect(conn, to: o_auth_path(conn, :request, provider, params)) + redirect(conn, to: Routes.o_auth_path(conn, :request, provider, params)) end def request(%Plug.Conn{} = conn, params) do @@ -601,7 +601,7 @@ def login(%User{} = user, %App{} = app, requested_scopes) when is_list(requested end # Special case: Local MastodonFE - defp redirect_uri(%Plug.Conn{} = conn, "."), do: auth_url(conn, :login) + defp redirect_uri(%Plug.Conn{} = conn, "."), do: Routes.auth_url(conn, :login) defp redirect_uri(%Plug.Conn{}, redirect_uri), do: redirect_uri diff --git a/lib/pleroma/web/templates/feed/feed/tag.atom.eex b/lib/pleroma/web/templates/feed/feed/tag.atom.eex index a288539ed..de0731085 100644 --- a/lib/pleroma/web/templates/feed/feed/tag.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/tag.atom.eex @@ -9,13 +9,13 @@ xmlns:ostatus="http://ostatus.org/schema/1.0" xmlns:statusnet="http://status.net/schema/api/1/"> - <id><%= '#{tag_feed_url(@conn, :feed, @tag)}.rss' %></id> + <id><%= '#{Routes.tag_feed_url(@conn, :feed, @tag)}.rss' %></id> <title>#<%= @tag %></title> <subtitle>These are public toots tagged with #<%= @tag %>. You can interact with them if you have an account anywhere in the fediverse.</subtitle> <logo><%= feed_logo() %></logo> <updated><%= most_recent_update(@activities) %></updated> - <link rel="self" href="<%= '#{tag_feed_url(@conn, :feed, @tag)}.atom' %>" type="application/atom+xml"/> + <link rel="self" href="<%= '#{Routes.tag_feed_url(@conn, :feed, @tag)}.atom' %>" type="application/atom+xml"/> <%= for activity <- @activities do %> <%= render @view_module, "_tag_activity.atom", Map.merge(assigns, prepare_activity(activity, actor: true)) %> <% end %> diff --git a/lib/pleroma/web/templates/feed/feed/tag.rss.eex b/lib/pleroma/web/templates/feed/feed/tag.rss.eex index eeda01a04..9c3613feb 100644 --- a/lib/pleroma/web/templates/feed/feed/tag.rss.eex +++ b/lib/pleroma/web/templates/feed/feed/tag.rss.eex @@ -5,7 +5,7 @@ <title>#<%= @tag %></title> <description>These are public toots tagged with #<%= @tag %>. You can interact with them if you have an account anywhere in the fediverse.</description> - <link><%= '#{tag_feed_url(@conn, :feed, @tag)}.rss' %></link> + <link><%= '#{Routes.tag_feed_url(@conn, :feed, @tag)}.rss' %></link> <webfeeds:logo><%= feed_logo() %></webfeeds:logo> <webfeeds:accentColor>2b90d9</webfeeds:accentColor> <%= for activity <- @activities do %> diff --git a/lib/pleroma/web/templates/feed/feed/user.atom.eex b/lib/pleroma/web/templates/feed/feed/user.atom.eex index c6acd848f..5c1f0ecbc 100644 --- a/lib/pleroma/web/templates/feed/feed/user.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/user.atom.eex @@ -6,16 +6,16 @@ xmlns:poco="http://portablecontacts.net/spec/1.0" xmlns:ostatus="http://ostatus.org/schema/1.0"> - <id><%= user_feed_url(@conn, :feed, @user.nickname) <> ".atom" %></id> + <id><%= Routes.user_feed_url(@conn, :feed, @user.nickname) <> ".atom" %></id> <title><%= @user.nickname <> "'s timeline" %></title> <updated><%= most_recent_update(@activities, @user) %></updated> <logo><%= logo(@user) %></logo> - <link rel="self" href="<%= '#{user_feed_url(@conn, :feed, @user.nickname)}.atom' %>" type="application/atom+xml"/> + <link rel="self" href="<%= '#{Routes.user_feed_url(@conn, :feed, @user.nickname)}.atom' %>" type="application/atom+xml"/> <%= render @view_module, "_author.atom", assigns %> <%= if last_activity(@activities) do %> - <link rel="next" href="<%= '#{user_feed_url(@conn, :feed, @user.nickname)}.atom?max_id=#{last_activity(@activities).id}' %>" type="application/atom+xml"/> + <link rel="next" href="<%= '#{Routes.user_feed_url(@conn, :feed, @user.nickname)}.atom?max_id=#{last_activity(@activities).id}' %>" type="application/atom+xml"/> <% end %> <%= for activity <- @activities do %> diff --git a/lib/pleroma/web/templates/feed/feed/user.rss.eex b/lib/pleroma/web/templates/feed/feed/user.rss.eex index d69120480..6b842a085 100644 --- a/lib/pleroma/web/templates/feed/feed/user.rss.eex +++ b/lib/pleroma/web/templates/feed/feed/user.rss.eex @@ -1,16 +1,16 @@ <?xml version="1.0" encoding="UTF-8" ?> <rss version="2.0"> <channel> - <guid><%= user_feed_url(@conn, :feed, @user.nickname) <> ".rss" %></guid> + <guid><%= Routes.user_feed_url(@conn, :feed, @user.nickname) <> ".rss" %></guid> <title><%= @user.nickname <> "'s timeline" %></title> <updated><%= most_recent_update(@activities, @user) %></updated> <image><%= logo(@user) %></image> - <link><%= '#{user_feed_url(@conn, :feed, @user.nickname)}.rss' %></link> + <link><%= '#{Routes.user_feed_url(@conn, :feed, @user.nickname)}.rss' %></link> <%= render @view_module, "_author.rss", assigns %> <%= if last_activity(@activities) do %> - <link rel="next"><%= '#{user_feed_url(@conn, :feed, @user.nickname)}.rss?max_id=#{last_activity(@activities).id}' %></link> + <link rel="next"><%= '#{Routes.user_feed_url(@conn, :feed, @user.nickname)}.rss?max_id=#{last_activity(@activities).id}' %></link> <% end %> <%= for activity <- @activities do %> diff --git a/lib/pleroma/web/templates/masto_fe/index.html.eex b/lib/pleroma/web/templates/masto_fe/index.html.eex index c330960fa..6f2b98957 100644 --- a/lib/pleroma/web/templates/masto_fe/index.html.eex +++ b/lib/pleroma/web/templates/masto_fe/index.html.eex @@ -7,7 +7,7 @@ <%= Config.get([:instance, :name]) %> </title> <link rel="icon" type="image/png" href="/favicon.png"/> -<link rel="manifest" type="applicaton/manifest+json" href="<%= masto_fe_path(Pleroma.Web.Endpoint, :manifest) %>" /> +<link rel="manifest" type="applicaton/manifest+json" href="<%= Routes.masto_fe_path(Pleroma.Web.Endpoint, :manifest) %>" /> <meta name="theme-color" content="<%= Config.get([:manifest, :theme_color]) %>" /> diff --git a/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex b/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex index 5ab59b57b..b9daa8d8b 100644 --- a/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex +++ b/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex @@ -7,7 +7,7 @@ <h2>Two-factor recovery</h2> -<%= form_for @conn, mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %> +<%= form_for @conn, Routes.mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %> <div class="input"> <%= label f, :code, "Recovery code" %> <%= text_input f, :code, [autocomplete: false, autocorrect: "off", autocapitalize: "off", autofocus: true, spellcheck: false] %> @@ -19,6 +19,6 @@ <%= submit "Verify" %> <% end %> -<a href="<%= mfa_path(@conn, :show, %{challenge_type: "totp", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri}) %>"> +<a href="<%= Routes.mfa_path(@conn, :show, %{challenge_type: "totp", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri}) %>"> Enter a two-factor code </a> diff --git a/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex b/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex index af85777eb..29ea7c5fb 100644 --- a/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex +++ b/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex @@ -7,7 +7,7 @@ <h2>Two-factor authentication</h2> -<%= form_for @conn, mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %> +<%= form_for @conn, Routes.mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %> <div class="input"> <%= label f, :code, "Authentication code" %> <%= text_input f, :code, [autocomplete: false, autocorrect: "off", autocapitalize: "off", autofocus: true, pattern: "[0-9]*", spellcheck: false] %> @@ -19,6 +19,6 @@ <%= submit "Verify" %> <% end %> -<a href="<%= mfa_path(@conn, :show, %{challenge_type: "recovery", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri}) %>"> +<a href="<%= Routes.mfa_path(@conn, :show, %{challenge_type: "recovery", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri}) %>"> Enter a two-factor recovery code </a> diff --git a/lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex b/lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex index 4a0718851..dc4521a62 100644 --- a/lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex +++ b/lib/pleroma/web/templates/o_auth/o_auth/consumer.html.eex @@ -1,6 +1,6 @@ <h2>Sign in with external provider</h2> -<%= form_for @conn, o_auth_path(@conn, :prepare_request), [as: "authorization", method: "get"], fn f -> %> +<%= form_for @conn, Routes.o_auth_path(@conn, :prepare_request), [as: "authorization", method: "get"], fn f -> %> <div style="display: none"> <%= render @view_module, "_scopes.html", Map.merge(assigns, %{form: f}) %> </div> diff --git a/lib/pleroma/web/templates/o_auth/o_auth/register.html.eex b/lib/pleroma/web/templates/o_auth/o_auth/register.html.eex index facedc8db..99f900fb7 100644 --- a/lib/pleroma/web/templates/o_auth/o_auth/register.html.eex +++ b/lib/pleroma/web/templates/o_auth/o_auth/register.html.eex @@ -8,7 +8,7 @@ <h2>Registration Details</h2> <p>If you'd like to register a new account, please provide the details below.</p> -<%= form_for @conn, o_auth_path(@conn, :register), [as: "authorization"], fn f -> %> +<%= form_for @conn, Routes.o_auth_path(@conn, :register), [as: "authorization"], fn f -> %> <div class="input"> <%= label f, :nickname, "Nickname" %> diff --git a/lib/pleroma/web/templates/o_auth/o_auth/show.html.eex b/lib/pleroma/web/templates/o_auth/o_auth/show.html.eex index 1a85818ec..2846ec7e7 100644 --- a/lib/pleroma/web/templates/o_auth/o_auth/show.html.eex +++ b/lib/pleroma/web/templates/o_auth/o_auth/show.html.eex @@ -5,7 +5,7 @@ <p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p> <% end %> -<%= form_for @conn, o_auth_path(@conn, :authorize), [as: "authorization"], fn f -> %> +<%= form_for @conn, Routes.o_auth_path(@conn, :authorize), [as: "authorization"], fn f -> %> <%= if @user do %> <div class="account-header"> diff --git a/lib/pleroma/web/templates/twitter_api/password/reset.html.eex b/lib/pleroma/web/templates/twitter_api/password/reset.html.eex index 7d3ef6b0d..fbcacdc14 100644 --- a/lib/pleroma/web/templates/twitter_api/password/reset.html.eex +++ b/lib/pleroma/web/templates/twitter_api/password/reset.html.eex @@ -1,5 +1,5 @@ <h2>Password Reset for <%= @user.nickname %></h2> -<%= form_for @conn, reset_password_path(@conn, :do_reset), [as: "data"], fn f -> %> +<%= form_for @conn, Routes.reset_password_path(@conn, :do_reset), [as: "data"], fn f -> %> <div class="form-row"> <%= label f, :password, "Password" %> <%= password_input f, :password %> diff --git a/lib/pleroma/web/templates/twitter_api/remote_follow/follow.html.eex b/lib/pleroma/web/templates/twitter_api/remote_follow/follow.html.eex index 5ba192cd7..a7be53091 100644 --- a/lib/pleroma/web/templates/twitter_api/remote_follow/follow.html.eex +++ b/lib/pleroma/web/templates/twitter_api/remote_follow/follow.html.eex @@ -4,7 +4,7 @@ <h2>Remote follow</h2> <img height="128" width="128" src="<%= avatar_url(@followee) %>"> <p><%= @followee.nickname %></p> - <%= form_for @conn, remote_follow_path(@conn, :do_follow), [as: "user"], fn f -> %> + <%= form_for @conn, Routes.remote_follow_path(@conn, :do_follow), [as: "user"], fn f -> %> <%= hidden_input f, :id, value: @followee.id %> <%= submit "Authorize" %> <% end %> diff --git a/lib/pleroma/web/templates/twitter_api/remote_follow/follow_login.html.eex b/lib/pleroma/web/templates/twitter_api/remote_follow/follow_login.html.eex index df44988ee..a8026fa9d 100644 --- a/lib/pleroma/web/templates/twitter_api/remote_follow/follow_login.html.eex +++ b/lib/pleroma/web/templates/twitter_api/remote_follow/follow_login.html.eex @@ -4,7 +4,7 @@ <h2>Log in to follow</h2> <p><%= @followee.nickname %></p> <img height="128" width="128" src="<%= avatar_url(@followee) %>"> -<%= form_for @conn, remote_follow_path(@conn, :do_follow), [as: "authorization"], fn f -> %> +<%= form_for @conn, Routes.remote_follow_path(@conn, :do_follow), [as: "authorization"], fn f -> %> <%= text_input f, :name, placeholder: "Username", required: true %> <br> <%= password_input f, :password, placeholder: "Password", required: true %> diff --git a/lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex b/lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex index adc3a3e3d..a54ed83b5 100644 --- a/lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex +++ b/lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex @@ -4,7 +4,7 @@ <h2>Two-factor authentication</h2> <p><%= @followee.nickname %></p> <img height="128" width="128" src="<%= avatar_url(@followee) %>"> -<%= form_for @conn, remote_follow_path(@conn, :do_follow), [as: "mfa"], fn f -> %> +<%= form_for @conn, Routes.remote_follow_path(@conn, :do_follow), [as: "mfa"], fn f -> %> <%= text_input f, :code, placeholder: "Authentication code", required: true %> <br> <%= hidden_input f, :id, value: @followee.id %> diff --git a/lib/pleroma/web/templates/twitter_api/util/subscribe.html.eex b/lib/pleroma/web/templates/twitter_api/util/subscribe.html.eex index f60accebf..a6b313d8a 100644 --- a/lib/pleroma/web/templates/twitter_api/util/subscribe.html.eex +++ b/lib/pleroma/web/templates/twitter_api/util/subscribe.html.eex @@ -2,7 +2,7 @@ <h2>Error: <%= @error %></h2> <% else %> <h2>Remotely follow <%= @nickname %></h2> - <%= form_for @conn, util_path(@conn, :remote_subscribe), [as: "user"], fn f -> %> + <%= form_for @conn, Routes.util_path(@conn, :remote_subscribe), [as: "user"], fn f -> %> <%= hidden_input f, :nickname, value: @nickname %> <%= text_input f, :profile, placeholder: "Your account ID, e.g. lain@quitter.se" %> <%= submit "Follow" %> diff --git a/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex b/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex index 6ca02fbd7..9843cc362 100644 --- a/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex @@ -38,7 +38,7 @@ def follow(%{assigns: %{user: user}} = conn, %{"acct" => acct}) do defp follow_status(conn, _user, acct) do with {:ok, object} <- Fetcher.fetch_object_from_id(acct), %Activity{id: activity_id} <- Activity.get_create_by_object_ap_id(object.data["id"]) do - redirect(conn, to: o_status_path(conn, :notice, activity_id)) + redirect(conn, to: Routes.o_status_path(conn, :notice, activity_id)) else error -> handle_follow_error(conn, error) diff --git a/lib/pleroma/web/views/masto_fe_view.ex b/lib/pleroma/web/views/masto_fe_view.ex index b9055cb7f..82b301949 100644 --- a/lib/pleroma/web/views/masto_fe_view.ex +++ b/lib/pleroma/web/views/masto_fe_view.ex @@ -79,7 +79,7 @@ def render("manifest.json", _params) do background_color: Config.get([:manifest, :background_color]), display: "standalone", scope: Pleroma.Web.base_url(), - start_url: masto_fe_path(Pleroma.Web.Endpoint, :index, ["getting-started"]), + start_url: Routes.masto_fe_path(Pleroma.Web.Endpoint, :index, ["getting-started"]), categories: [ "social" ], From e3173a279dad89dfce6eae89368ad3ba180c0490 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Wed, 19 May 2021 14:27:02 -0500 Subject: [PATCH 211/339] Put Plugs in runtime mode in :dev, :test to speed up recompilation --- config/dev.exs | 4 ++++ config/test.exs | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/config/dev.exs b/config/dev.exs index 4faaeff5b..8e7d5c587 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -54,6 +54,10 @@ config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: true +# Reduce recompilation time +# https://dashbit.co/blog/speeding-up-re-compilation-of-elixir-projects +config :phoenix, :plug_init_mode, :runtime + if File.exists?("./config/dev.secret.exs") do import_config "dev.secret.exs" else diff --git a/config/test.exs b/config/test.exs index 87396a88d..007951097 100644 --- a/config/test.exs +++ b/config/test.exs @@ -133,6 +133,10 @@ ap_streamer: Pleroma.Web.ActivityPub.ActivityPubMock, logger: Pleroma.LoggerMock +# Reduce recompilation time +# https://dashbit.co/blog/speeding-up-re-compilation-of-elixir-projects +config :phoenix, :plug_init_mode, :runtime + if File.exists?("./config/test.secret.exs") do import_config "test.secret.exs" else From 05d678c070b47848c400103a029f6ed278bce9e6 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Thu, 20 May 2021 12:50:43 -0500 Subject: [PATCH 212/339] Expose user email address to user/owner; not publicly. --- CHANGELOG.md | 1 + .../web/mastodon_api/views/account_view.ex | 11 +++++++++++ .../mastodon_api/views/account_view_test.exs | 17 +++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 898f8adb5..61339a1aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - The `application` metadata returned with statuses is no longer hardcoded. Apps that want to display these details will now have valid data for new posts after this change. - HTTPSecurityPlug now sends a response header to opt out of Google's FLoC (Federated Learning of Cohorts) targeted advertising. +- Email address is now returned if requesting user is the owner of the user account so it can be exposed in client and FE user settings UIs. ### Added diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index ac25aefdd..9e9de33f6 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -292,6 +292,7 @@ defp do_render("show.json", %{user: user} = opts) do |> maybe_put_allow_following_move(user, opts[:for]) |> maybe_put_unread_conversation_count(user, opts[:for]) |> maybe_put_unread_notification_count(user, opts[:for]) + |> maybe_put_email_address(user, opts[:for]) end defp username_from_nickname(string) when is_binary(string) do @@ -403,6 +404,16 @@ defp maybe_put_unread_notification_count(data, %User{id: user_id}, %User{id: use defp maybe_put_unread_notification_count(data, _, _), do: data + defp maybe_put_email_address(data, %User{id: user_id}, %User{id: user_id} = user) do + Kernel.put_in( + data, + [:pleroma, :email], + user.email + ) + end + + defp maybe_put_email_address(data, _, _), do: data + defp image_url(%{"url" => [%{"href" => href} | _]}), do: href defp image_url(_), do: nil end diff --git a/test/pleroma/web/mastodon_api/views/account_view_test.exs b/test/pleroma/web/mastodon_api/views/account_view_test.exs index 5373a17c3..3fa17a6ca 100644 --- a/test/pleroma/web/mastodon_api/views/account_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/account_view_test.exs @@ -468,6 +468,23 @@ test "shows unread_count only to the account owner" do %{user: user, for: user} )[:pleroma][:unread_notifications_count] == 7 end + + test "shows email only to the account owner" do + user = insert(:user) + other_user = insert(:user) + + user = User.get_cached_by_ap_id(user.ap_id) + + assert AccountView.render( + "show.json", + %{user: user, for: other_user} + )[:pleroma][:email] == nil + + assert AccountView.render( + "show.json", + %{user: user, for: user} + )[:pleroma][:email] == user.email + end end describe "follow requests counter" do From fe40f6f2910967609f7aa952d69981cadc47372c Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Thu, 20 May 2021 13:55:37 -0500 Subject: [PATCH 213/339] Switch from the deprecated "use Mix.config" to "import Config" --- config/benchmark.exs | 2 +- config/config.exs | 2 +- config/description.exs | 2 +- config/dev.exs | 2 +- config/dokku.exs | 2 +- config/prod.exs | 2 +- config/test.exs | 2 +- test/fixtures/config/temp.exported_from_db.secret.exs | 2 +- test/fixtures/config/temp.secret.exs | 2 +- test/mix/tasks/pleroma/config_test.exs | 9 +-------- 10 files changed, 10 insertions(+), 17 deletions(-) diff --git a/config/benchmark.exs b/config/benchmark.exs index 5567ff26e..a4d048f1b 100644 --- a/config/benchmark.exs +++ b/config/benchmark.exs @@ -1,4 +1,4 @@ -use Mix.Config +import Config # We don't run a server during test. If one is required, # you can enable the server option below. diff --git a/config/config.exs b/config/config.exs index 4381068ac..d333c618e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -41,7 +41,7 @@ # # This configuration file is loaded before any dependency and # is restricted to this project. -use Mix.Config +import Config # General application configuration config :pleroma, ecto_repos: [Pleroma.Repo] diff --git a/config/description.exs b/config/description.exs index bb1f43305..f00c53d28 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1,4 +1,4 @@ -use Mix.Config +import Config websocket_config = [ path: "/websocket", diff --git a/config/dev.exs b/config/dev.exs index 4faaeff5b..cfe3cce47 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -1,4 +1,4 @@ -use Mix.Config +import Config # For development, we disable any cache and enable # debugging and code reloading. diff --git a/config/dokku.exs b/config/dokku.exs index 9ea0ec450..1cc396c3d 100644 --- a/config/dokku.exs +++ b/config/dokku.exs @@ -1,4 +1,4 @@ -use Mix.Config +import Config config :pleroma, Pleroma.Web.Endpoint, http: [ diff --git a/config/prod.exs b/config/prod.exs index 0e151000b..968f596e0 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -1,4 +1,4 @@ -use Mix.Config +import Config # For production, we often load configuration from external # sources, such as your system environment. For this reason, diff --git a/config/test.exs b/config/test.exs index 87396a88d..c531b1290 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,4 +1,4 @@ -use Mix.Config +import Config # We don't run a server during test. If one is required, # you can enable the server option below. diff --git a/test/fixtures/config/temp.exported_from_db.secret.exs b/test/fixtures/config/temp.exported_from_db.secret.exs index 64bee7f32..dda5d0fa6 100644 --- a/test/fixtures/config/temp.exported_from_db.secret.exs +++ b/test/fixtures/config/temp.exported_from_db.secret.exs @@ -1,4 +1,4 @@ -use Mix.Config +import Config config :pleroma, exported_config_merged: true diff --git a/test/fixtures/config/temp.secret.exs b/test/fixtures/config/temp.secret.exs index 4b3af39ec..9c5c88d98 100644 --- a/test/fixtures/config/temp.secret.exs +++ b/test/fixtures/config/temp.secret.exs @@ -2,7 +2,7 @@ # Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only -use Mix.Config +import Config config :pleroma, :first_setting, key: "value", key2: [Pleroma.Repo] diff --git a/test/mix/tasks/pleroma/config_test.exs b/test/mix/tasks/pleroma/config_test.exs index 3ed1e94b8..2b8252db7 100644 --- a/test/mix/tasks/pleroma/config_test.exs +++ b/test/mix/tasks/pleroma/config_test.exs @@ -188,15 +188,8 @@ test "load a settings with large values and pass to file", %{temp_file: temp_fil assert File.exists?(temp_file) {:ok, file} = File.read(temp_file) - header = - if Code.ensure_loaded?(Config.Reader) do - "import Config" - else - "use Mix.Config" - end - assert file == - "#{header}\n\nconfig :pleroma, :instance,\n name: \"Pleroma\",\n email: \"example@example.com\",\n notify_email: \"noreply@example.com\",\n description: \"A Pleroma instance, an alternative fediverse server\",\n limit: 5000,\n chat_limit: 5000,\n remote_limit: 100_000,\n upload_limit: 16_000_000,\n avatar_upload_limit: 2_000_000,\n background_upload_limit: 4_000_000,\n banner_upload_limit: 4_000_000,\n poll_limits: %{\n max_expiration: 31_536_000,\n max_option_chars: 200,\n max_options: 20,\n min_expiration: 0\n },\n registrations_open: true,\n federating: true,\n federation_incoming_replies_max_depth: 100,\n federation_reachability_timeout_days: 7,\n federation_publisher_modules: [Pleroma.Web.ActivityPub.Publisher],\n allow_relay: true,\n public: true,\n quarantined_instances: [],\n managed_config: true,\n static_dir: \"instance/static/\",\n allowed_post_formats: [\"text/plain\", \"text/html\", \"text/markdown\", \"text/bbcode\"],\n autofollowed_nicknames: [],\n max_pinned_statuses: 1,\n attachment_links: false,\n max_report_comment_size: 1000,\n safe_dm_mentions: false,\n healthcheck: false,\n remote_post_retention_days: 90,\n skip_thread_containment: true,\n limit_to_local_content: :unauthenticated,\n user_bio_length: 5000,\n user_name_length: 100,\n max_account_fields: 10,\n max_remote_account_fields: 20,\n account_field_name_length: 512,\n account_field_value_length: 2048,\n external_user_synchronization: true,\n extended_nickname_format: true,\n multi_factor_authentication: [\n totp: [digits: 6, period: 30],\n backup_codes: [number: 2, length: 6]\n ]\n" + "import Config\n\nconfig :pleroma, :instance,\n name: \"Pleroma\",\n email: \"example@example.com\",\n notify_email: \"noreply@example.com\",\n description: \"A Pleroma instance, an alternative fediverse server\",\n limit: 5000,\n chat_limit: 5000,\n remote_limit: 100_000,\n upload_limit: 16_000_000,\n avatar_upload_limit: 2_000_000,\n background_upload_limit: 4_000_000,\n banner_upload_limit: 4_000_000,\n poll_limits: %{\n max_expiration: 31_536_000,\n max_option_chars: 200,\n max_options: 20,\n min_expiration: 0\n },\n registrations_open: true,\n federating: true,\n federation_incoming_replies_max_depth: 100,\n federation_reachability_timeout_days: 7,\n federation_publisher_modules: [Pleroma.Web.ActivityPub.Publisher],\n allow_relay: true,\n public: true,\n quarantined_instances: [],\n managed_config: true,\n static_dir: \"instance/static/\",\n allowed_post_formats: [\"text/plain\", \"text/html\", \"text/markdown\", \"text/bbcode\"],\n autofollowed_nicknames: [],\n max_pinned_statuses: 1,\n attachment_links: false,\n max_report_comment_size: 1000,\n safe_dm_mentions: false,\n healthcheck: false,\n remote_post_retention_days: 90,\n skip_thread_containment: true,\n limit_to_local_content: :unauthenticated,\n user_bio_length: 5000,\n user_name_length: 100,\n max_account_fields: 10,\n max_remote_account_fields: 20,\n account_field_name_length: 512,\n account_field_value_length: 2048,\n external_user_synchronization: true,\n extended_nickname_format: true,\n multi_factor_authentication: [\n totp: [digits: 6, period: 30],\n backup_codes: [number: 2, length: 6]\n ]\n" end end From 46948537664a4296e7d5c517cbdbf3adccef1272 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Thu, 27 May 2021 12:04:42 -0500 Subject: [PATCH 214/339] Provide totalItems field for featured collections --- lib/pleroma/web/activity_pub/views/user_view.ex | 3 ++- test/pleroma/web/activity_pub/activity_pub_controller_test.exs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index 462f3b4a7..344da19d3 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -261,7 +261,8 @@ def render("featured.json", %{ %{ "id" => featured_address, "type" => "OrderedCollection", - "orderedItems" => objects + "orderedItems" => objects, + "totalItems" => length(objects) } |> Map.merge(Utils.make_json_ld_header()) end diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index cea4b3a97..c1e13c7cb 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -1966,7 +1966,7 @@ test "pinned collection", %{conn: conn} do %{nickname: nickname, featured_address: featured_address, pinned_objects: pinned_objects} = refresh_record(user) - %{"id" => ^featured_address, "orderedItems" => items} = + %{"id" => ^featured_address, "orderedItems" => items, "totalItems" => 2} = conn |> get("/users/#{nickname}/collections/featured") |> json_response(200) From cd4352a86fe37983cbc617c42f3f6c8a631fb9b3 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Thu, 27 May 2021 12:20:21 -0500 Subject: [PATCH 215/339] Missing entry for pinned posts federation from MR !3312 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61339a1aa..eacba0208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Return OAuth token `id` (primary key) in POST `/oauth/token`. - `AnalyzeMetadata` upload filter for extracting attachment dimensions and generating blurhashes. - Attachment dimensions and blurhashes are federated when available. +- Pinned posts federation ### Fixed - Don't crash so hard when email settings are invalid. From 21787546c01069d1d1d8261f0bc37d13a73122a9 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Sun, 23 May 2021 17:25:18 -0500 Subject: [PATCH 216/339] Router: move StaticFEPlug to a pipeline Speed up recompilation by breaking a cycle. Removes StaticFEPlug as a compile-time dep of Router. --- lib/pleroma/web/router.ex | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 72ad14f05..4e7de2b89 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -140,6 +140,10 @@ defmodule Pleroma.Web.Router do plug(Pleroma.Web.Plugs.MappedSignatureToIdentityPlug) end + pipeline :static_fe do + plug(Pleroma.Web.Plugs.StaticFEPlug) + end + scope "/api/v1/pleroma", Pleroma.Web.TwitterAPI do pipe_through(:pleroma_api) @@ -631,7 +635,7 @@ defmodule Pleroma.Web.Router do scope "/", Pleroma.Web do # Note: html format is supported only if static FE is enabled # Note: http signature is only considered for json requests (no auth for non-json requests) - pipe_through([:accepts_html_json, :http_signature, Pleroma.Web.Plugs.StaticFEPlug]) + pipe_through([:accepts_html_json, :http_signature, :static_fe]) get("/objects/:uuid", OStatus.OStatusController, :object) get("/activities/:uuid", OStatus.OStatusController, :activity) @@ -645,7 +649,7 @@ defmodule Pleroma.Web.Router do scope "/", Pleroma.Web do # Note: html format is supported only if static FE is enabled # Note: http signature is only considered for json requests (no auth for non-json requests) - pipe_through([:accepts_html_xml_json, :http_signature, Pleroma.Web.Plugs.StaticFEPlug]) + pipe_through([:accepts_html_xml_json, :http_signature, :static_fe]) # Note: returns user _profile_ for json requests, redirects to user _feed_ for non-json ones get("/users/:nickname", Feed.UserController, :feed_redirect, as: :user_feed) @@ -653,7 +657,7 @@ defmodule Pleroma.Web.Router do scope "/", Pleroma.Web do # Note: html format is supported only if static FE is enabled - pipe_through([:accepts_html_xml, Pleroma.Web.Plugs.StaticFEPlug]) + pipe_through([:accepts_html_xml, :static_fe]) get("/users/:nickname/feed", Feed.UserController, :feed, as: :user_feed) end From fda34591cefad94277385311c6391d1ca2adb36c Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Sat, 22 May 2021 15:04:05 -0500 Subject: [PATCH 217/339] Don't make MediaProxy be a compile-dep of Router Speeds up recompilation by removing MediaProxy as a compile-time dep of Router --- lib/pleroma/web/router.ex | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 72ad14f05..257455616 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -764,11 +764,11 @@ defmodule Pleroma.Web.Router do get("/embed/:id", EmbedController, :show) end - scope "/proxy/", Pleroma.Web.MediaProxy do - get("/preview/:sig/:url", MediaProxyController, :preview) - get("/preview/:sig/:url/:filename", MediaProxyController, :preview) - get("/:sig/:url", MediaProxyController, :remote) - get("/:sig/:url/:filename", MediaProxyController, :remote) + scope "/proxy/", Pleroma.Web do + get("/preview/:sig/:url", MediaProxy.MediaProxyController, :preview) + get("/preview/:sig/:url/:filename", MediaProxy.MediaProxyController, :preview) + get("/:sig/:url", MediaProxy.MediaProxyController, :remote) + get("/:sig/:url/:filename", MediaProxy.MediaProxyController, :remote) end if Pleroma.Config.get(:env) == :dev do From c23b81e399d5be6fc30f4acb1d757d5eb291d8e1 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Fri, 21 May 2021 14:47:11 -0500 Subject: [PATCH 218/339] Pleroma.Web.get_api_routes/0 --> Pleroma.Web.Router.get_api_routes/0 Reduce recompilation time by breaking compile-time cycles --- lib/pleroma/web.ex | 12 ------------ lib/pleroma/web/plugs/frontend_static.ex | 2 +- lib/pleroma/web/router.ex | 12 ++++++++++++ test/pleroma/web/plugs/frontend_static_plug_test.exs | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/pleroma/web.ex b/lib/pleroma/web.ex index 8630f244b..f1f9d6229 100644 --- a/lib/pleroma/web.ex +++ b/lib/pleroma/web.ex @@ -233,16 +233,4 @@ defmacro __using__(which) when is_atom(which) do def base_url do Pleroma.Web.Endpoint.url() end - - # TODO: Change to Phoenix.Router.routes/1 for Phoenix 1.6.0+ - def get_api_routes do - Pleroma.Web.Router.__routes__() - |> Enum.reject(fn r -> r.plug == Pleroma.Web.Fallback.RedirectController end) - |> Enum.map(fn r -> - r.path - |> String.split("/", trim: true) - |> List.first() - end) - |> Enum.uniq() - end end diff --git a/lib/pleroma/web/plugs/frontend_static.ex b/lib/pleroma/web/plugs/frontend_static.ex index eb385e94d..e7c943b41 100644 --- a/lib/pleroma/web/plugs/frontend_static.ex +++ b/lib/pleroma/web/plugs/frontend_static.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do """ @behaviour Plug - @api_routes Pleroma.Web.get_api_routes() + @api_routes Pleroma.Web.Router.get_api_routes() def file_path(path, frontend_type \\ :primary) do if configuration = Pleroma.Config.get([:frontends, frontend_type]) do diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 72ad14f05..3550088bb 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -821,4 +821,16 @@ defmodule Pleroma.Web.Router do options("/*path", RedirectController, :empty) end + + # TODO: Change to Phoenix.Router.routes/1 for Phoenix 1.6.0+ + def get_api_routes do + __MODULE__.__routes__() + |> Enum.reject(fn r -> r.plug == Pleroma.Web.Fallback.RedirectController end) + |> Enum.map(fn r -> + r.path + |> String.split("/", trim: true) + |> List.first() + end) + |> Enum.uniq() + end end diff --git a/test/pleroma/web/plugs/frontend_static_plug_test.exs b/test/pleroma/web/plugs/frontend_static_plug_test.exs index 100b83d6a..4152cdefe 100644 --- a/test/pleroma/web/plugs/frontend_static_plug_test.exs +++ b/test/pleroma/web/plugs/frontend_static_plug_test.exs @@ -103,6 +103,6 @@ test "api routes are detected correctly" do "check_password" ] - assert expected_routes == Pleroma.Web.get_api_routes() + assert expected_routes == Pleroma.Web.Router.get_api_routes() end end From 69aed310de48ccd80326c7b639d8e179dfd21ba8 Mon Sep 17 00:00:00 2001 From: Snow <xxnmacsjuyidktezdy@awdrt.com> Date: Sat, 29 May 2021 02:22:33 +0000 Subject: [PATCH 219/339] Adding description --- config/description.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/description.exs b/config/description.exs index f00c53d28..1c3e3f900 100644 --- a/config/description.exs +++ b/config/description.exs @@ -682,7 +682,7 @@ %{ key: :allow_relay, type: :boolean, - description: "Enable Pleroma's Relay, which makes it possible to follow a whole instance" + description: "Enable Pleroma's Relay, which makes it possible to follow a whole instance. (Important!) This will increase the visibility of your instance." }, %{ key: :public, From 3ebede4b514a69b41b34a0fe8e8fc27ce94a2071 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Thu, 20 May 2021 17:23:02 -0500 Subject: [PATCH 220/339] Gun: make Gun.API a runtime dep Speed up recompilation by breaking a compile-time cycle --- lib/pleroma/gun.ex | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/pleroma/gun.ex b/lib/pleroma/gun.ex index f9c828fac..bef1c9872 100644 --- a/lib/pleroma/gun.ex +++ b/lib/pleroma/gun.ex @@ -11,9 +11,7 @@ defmodule Pleroma.Gun do @callback await(pid(), reference()) :: {:response, :fin, 200, []} @callback set_owner(pid(), pid()) :: :ok - @api Pleroma.Config.get([Pleroma.Gun], Pleroma.Gun.API) - - defp api, do: @api + defp api, do: Pleroma.Config.get([Pleroma.Gun], Pleroma.Gun.API) def open(host, port, opts), do: api().open(host, port, opts) From 0ada3fe823a3c2e6c5835431bdacfbdb8b3d02a7 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Fri, 21 May 2021 12:31:28 -0500 Subject: [PATCH 221/339] Gun: use runtime deps in ConnectionPool Speed up recompilation time by breaking compile-time cycles --- lib/pleroma/gun/connection_pool/reclaimer.ex | 6 +++--- lib/pleroma/gun/connection_pool/worker.ex | 10 +++++----- lib/pleroma/http/adapter_helper/gun.ex | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/pleroma/gun/connection_pool/reclaimer.ex b/lib/pleroma/gun/connection_pool/reclaimer.ex index c37b62bf2..4c643d7cb 100644 --- a/lib/pleroma/gun/connection_pool/reclaimer.ex +++ b/lib/pleroma/gun/connection_pool/reclaimer.ex @@ -5,11 +5,11 @@ defmodule Pleroma.Gun.ConnectionPool.Reclaimer do use GenServer, restart: :temporary - @registry Pleroma.Gun.ConnectionPool + defp registry, do: Pleroma.Gun.ConnectionPool def start_monitor do pid = - case :gen_server.start(__MODULE__, [], name: {:via, Registry, {@registry, "reclaimer"}}) do + case :gen_server.start(__MODULE__, [], name: {:via, Registry, {registry(), "reclaimer"}}) do {:ok, pid} -> pid @@ -46,7 +46,7 @@ def handle_continue(:reclaim, _) do # {worker_pid, crf, last_reference} end) unused_conns = Registry.select( - @registry, + registry(), [ {{:_, :"$1", {:_, :"$2", :"$3", :"$4"}}, [{:==, :"$2", []}], [{{:"$1", :"$3", :"$4"}}]} ] diff --git a/lib/pleroma/gun/connection_pool/worker.ex b/lib/pleroma/gun/connection_pool/worker.ex index 02bfff274..a3fa75386 100644 --- a/lib/pleroma/gun/connection_pool/worker.ex +++ b/lib/pleroma/gun/connection_pool/worker.ex @@ -6,10 +6,10 @@ defmodule Pleroma.Gun.ConnectionPool.Worker do alias Pleroma.Gun use GenServer, restart: :temporary - @registry Pleroma.Gun.ConnectionPool + defp registry, do: Pleroma.Gun.ConnectionPool def start_link([key | _] = opts) do - GenServer.start_link(__MODULE__, opts, name: {:via, Registry, {@registry, key}}) + GenServer.start_link(__MODULE__, opts, name: {:via, Registry, {registry(), key}}) end @impl true @@ -24,7 +24,7 @@ def handle_continue({:connect, [key, uri, opts, client_pid]}, _) do time = :erlang.monotonic_time(:millisecond) {_, _} = - Registry.update_value(@registry, key, fn _ -> + Registry.update_value(registry(), key, fn _ -> {conn_pid, [client_pid], 1, time} end) @@ -65,7 +65,7 @@ def handle_call(:add_client, {client_pid, _}, %{key: key, protocol: protocol} = time = :erlang.monotonic_time(:millisecond) {{conn_pid, used_by, _, _}, _} = - Registry.update_value(@registry, key, fn {conn_pid, used_by, crf, last_reference} -> + Registry.update_value(registry(), key, fn {conn_pid, used_by, crf, last_reference} -> {conn_pid, [client_pid | used_by], crf(time - last_reference, crf), time} end) @@ -92,7 +92,7 @@ def handle_call(:add_client, {client_pid, _}, %{key: key, protocol: protocol} = @impl true def handle_call(:remove_client, {client_pid, _}, %{key: key} = state) do {{_conn_pid, used_by, _crf, _last_reference}, _} = - Registry.update_value(@registry, key, fn {conn_pid, used_by, crf, last_reference} -> + Registry.update_value(registry(), key, fn {conn_pid, used_by, crf, last_reference} -> {conn_pid, List.delete(used_by, client_pid), crf, last_reference} end) diff --git a/lib/pleroma/http/adapter_helper/gun.ex b/lib/pleroma/http/adapter_helper/gun.ex index 82c7fd654..251539f34 100644 --- a/lib/pleroma/http/adapter_helper/gun.ex +++ b/lib/pleroma/http/adapter_helper/gun.ex @@ -54,8 +54,8 @@ def pool_timeout(pool) do Config.get([:pools, pool, :recv_timeout], default) end - @prefix Pleroma.Gun.ConnectionPool def limiter_setup do + prefix = Pleroma.Gun.ConnectionPool wait = Config.get([:connections_pool, :connection_acquisition_wait]) retries = Config.get([:connections_pool, :connection_acquisition_retries]) @@ -66,7 +66,7 @@ def limiter_setup do max_waiting = Keyword.get(opts, :max_waiting, 10) result = - ConcurrentLimiter.new(:"#{@prefix}.#{name}", max_running, max_waiting, + ConcurrentLimiter.new(:"#{prefix}.#{name}", max_running, max_waiting, wait: wait, max_retries: retries ) From 32d263cb905dd7fffd43a4955295af0b2b378537 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Fri, 21 May 2021 13:04:57 -0500 Subject: [PATCH 222/339] Config: use runtime deps instead of module attributes Speeds up recompilation time by breaking compile-time cycles --- lib/pleroma/config/loader.ex | 28 +++++++++++---------- lib/pleroma/config/transfer_task.ex | 38 +++++++++++++++-------------- 2 files changed, 35 insertions(+), 31 deletions(-) diff --git a/lib/pleroma/config/loader.ex b/lib/pleroma/config/loader.ex index b64d06707..9489f58c4 100644 --- a/lib/pleroma/config/loader.ex +++ b/lib/pleroma/config/loader.ex @@ -3,19 +3,21 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Config.Loader do - @reject_keys [ - Pleroma.Repo, - Pleroma.Web.Endpoint, - :env, - :configurable_from_database, - :database, - :swarm - ] + defp reject_keys, + do: [ + Pleroma.Repo, + Pleroma.Web.Endpoint, + :env, + :configurable_from_database, + :database, + :swarm + ] - @reject_groups [ - :postgrex, - :tesla - ] + defp reject_groups, + do: [ + :postgrex, + :tesla + ] if Code.ensure_loaded?(Config.Reader) do @reader Config.Reader @@ -52,7 +54,7 @@ defp filter(configs) do @spec filter_group(atom(), keyword()) :: keyword() def filter_group(group, configs) do Enum.reject(configs[group], fn {key, _v} -> - key in @reject_keys or group in @reject_groups or + key in reject_keys() or group in reject_groups() or (group == :phoenix and key == :serve_endpoints) end) end diff --git a/lib/pleroma/config/transfer_task.ex b/lib/pleroma/config/transfer_task.ex index aad45aab8..1e3ae82d0 100644 --- a/lib/pleroma/config/transfer_task.ex +++ b/lib/pleroma/config/transfer_task.ex @@ -13,23 +13,25 @@ defmodule Pleroma.Config.TransferTask do @type env() :: :test | :benchmark | :dev | :prod - @reboot_time_keys [ - {:pleroma, :hackney_pools}, - {:pleroma, :chat}, - {:pleroma, Oban}, - {:pleroma, :rate_limit}, - {:pleroma, :markup}, - {:pleroma, :streamer}, - {:pleroma, :pools}, - {:pleroma, :connections_pool} - ] + defp reboot_time_keys, + do: [ + {:pleroma, :hackney_pools}, + {:pleroma, :chat}, + {:pleroma, Oban}, + {:pleroma, :rate_limit}, + {:pleroma, :markup}, + {:pleroma, :streamer}, + {:pleroma, :pools}, + {:pleroma, :connections_pool} + ] - @reboot_time_subkeys [ - {:pleroma, Pleroma.Captcha, [:seconds_valid]}, - {:pleroma, Pleroma.Upload, [:proxy_remote]}, - {:pleroma, :instance, [:upload_limit]}, - {:pleroma, :gopher, [:enabled]} - ] + defp reboot_time_subkeys, + do: [ + {:pleroma, Pleroma.Captcha, [:seconds_valid]}, + {:pleroma, Pleroma.Upload, [:proxy_remote]}, + {:pleroma, :instance, [:upload_limit]}, + {:pleroma, :gopher, [:enabled]} + ] def start_link(restart_pleroma? \\ true) do load_and_update_env([], restart_pleroma?) @@ -165,12 +167,12 @@ def pleroma_need_restart?(group, key, value) do end defp group_and_key_need_reboot?(group, key) do - Enum.any?(@reboot_time_keys, fn {g, k} -> g == group and k == key end) + Enum.any?(reboot_time_keys(), fn {g, k} -> g == group and k == key end) end defp group_and_subkey_need_reboot?(group, key, value) do Keyword.keyword?(value) and - Enum.any?(@reboot_time_subkeys, fn {g, k, subkeys} -> + Enum.any?(reboot_time_subkeys(), fn {g, k, subkeys} -> g == group and k == key and Enum.any?(Keyword.keys(value), &(&1 in subkeys)) end) From c9e4200ed2167772294fceb4f282979b5ea04981 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Sat, 22 May 2021 14:59:12 -0500 Subject: [PATCH 223/339] Create real Views for all Controllers This makes views depend on each other at runtime instead of compile-time --- .../web/admin_api/controllers/o_auth_app_controller.ex | 1 - lib/pleroma/web/admin_api/views/o_auth_app_view.ex | 10 ++++++++++ .../controllers/follow_request_controller.ex | 1 - .../web/mastodon_api/controllers/media_controller.ex | 1 - .../mastodon_api/controllers/timeline_controller.ex | 2 -- .../web/mastodon_api/views/follow_request_view.ex | 10 ++++++++++ lib/pleroma/web/mastodon_api/views/media_view.ex | 10 ++++++++++ lib/pleroma/web/mastodon_api/views/timeline_view.ex | 10 ++++++++++ .../web/pleroma_api/controllers/account_controller.ex | 1 - .../pleroma_api/controllers/conversation_controller.ex | 1 - .../pleroma_api/controllers/notification_controller.ex | 2 -- lib/pleroma/web/pleroma_api/views/account_view.ex | 10 ++++++++++ lib/pleroma/web/pleroma_api/views/conversation_view.ex | 10 ++++++++++ lib/pleroma/web/pleroma_api/views/notification_view.ex | 10 ++++++++++ lib/pleroma/web/static_fe/static_fe_controller.ex | 1 - 15 files changed, 70 insertions(+), 10 deletions(-) create mode 100644 lib/pleroma/web/admin_api/views/o_auth_app_view.ex create mode 100644 lib/pleroma/web/mastodon_api/views/follow_request_view.ex create mode 100644 lib/pleroma/web/mastodon_api/views/media_view.ex create mode 100644 lib/pleroma/web/mastodon_api/views/timeline_view.ex create mode 100644 lib/pleroma/web/pleroma_api/views/account_view.ex create mode 100644 lib/pleroma/web/pleroma_api/views/conversation_view.ex create mode 100644 lib/pleroma/web/pleroma_api/views/notification_view.ex diff --git a/lib/pleroma/web/admin_api/controllers/o_auth_app_controller.ex b/lib/pleroma/web/admin_api/controllers/o_auth_app_controller.ex index 005fe67e2..51b17d392 100644 --- a/lib/pleroma/web/admin_api/controllers/o_auth_app_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/o_auth_app_controller.ex @@ -13,7 +13,6 @@ defmodule Pleroma.Web.AdminAPI.OAuthAppController do require Logger plug(Pleroma.Web.ApiSpec.CastAndValidate) - plug(:put_view, Pleroma.Web.MastodonAPI.AppView) plug( OAuthScopesPlug, diff --git a/lib/pleroma/web/admin_api/views/o_auth_app_view.ex b/lib/pleroma/web/admin_api/views/o_auth_app_view.ex new file mode 100644 index 000000000..af046f343 --- /dev/null +++ b/lib/pleroma/web/admin_api/views/o_auth_app_view.ex @@ -0,0 +1,10 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.OAuthAppView do + use Pleroma.Web, :view + alias Pleroma.Web.MastodonAPI + + def render(view, opts), do: MastodonAPI.AppView.render(view, opts) +end diff --git a/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex index 63d0e2c35..d915298f1 100644 --- a/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex @@ -9,7 +9,6 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestController do alias Pleroma.Web.CommonAPI alias Pleroma.Web.Plugs.OAuthScopesPlug - plug(:put_view, Pleroma.Web.MastodonAPI.AccountView) plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(:assign_follower when action != :index) diff --git a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex index d6949ed80..5918b288d 100644 --- a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex @@ -13,7 +13,6 @@ defmodule Pleroma.Web.MastodonAPI.MediaController do action_fallback(Pleroma.Web.MastodonAPI.FallbackController) plug(Majic.Plug, [pool: Pleroma.MajicPool] when action in [:create, :create2]) plug(Pleroma.Web.ApiSpec.CastAndValidate) - plug(:put_view, Pleroma.Web.MastodonAPI.StatusView) plug(OAuthScopesPlug, %{scopes: ["read:media"]} when action == :show) plug(OAuthScopesPlug, %{scopes: ["write:media"]} when action != :show) diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index cef299aa4..3f5849777 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -37,8 +37,6 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do when action in [:public, :hashtag] ) - plug(:put_view, Pleroma.Web.MastodonAPI.StatusView) - defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.TimelineOperation # GET /api/v1/timelines/home diff --git a/lib/pleroma/web/mastodon_api/views/follow_request_view.ex b/lib/pleroma/web/mastodon_api/views/follow_request_view.ex new file mode 100644 index 000000000..4c7d9fc65 --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/follow_request_view.ex @@ -0,0 +1,10 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.FollowRequestView do + use Pleroma.Web, :view + alias Pleroma.Web.MastodonAPI + + def render(view, opts), do: MastodonAPI.AccountView.render(view, opts) +end diff --git a/lib/pleroma/web/mastodon_api/views/media_view.ex b/lib/pleroma/web/mastodon_api/views/media_view.ex new file mode 100644 index 000000000..cf521887e --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/media_view.ex @@ -0,0 +1,10 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.MediaView do + use Pleroma.Web, :view + alias Pleroma.Web.MastodonAPI + + def render(view, opts), do: MastodonAPI.StatusView.render(view, opts) +end diff --git a/lib/pleroma/web/mastodon_api/views/timeline_view.ex b/lib/pleroma/web/mastodon_api/views/timeline_view.ex new file mode 100644 index 000000000..91226d78e --- /dev/null +++ b/lib/pleroma/web/mastodon_api/views/timeline_view.ex @@ -0,0 +1,10 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.MastodonAPI.TimelineView do + use Pleroma.Web, :view + alias Pleroma.Web.MastodonAPI + + def render(view, opts), do: MastodonAPI.StatusView.render(view, opts) +end diff --git a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex index 165afd3b4..6e01c5497 100644 --- a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex @@ -47,7 +47,6 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do plug(RateLimiter, [name: :account_confirmation_resend] when action == :confirmation_resend) plug(:assign_account_by_id when action in [:favourites, :subscribe, :unsubscribe]) - plug(:put_view, Pleroma.Web.MastodonAPI.AccountView) defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaAccountOperation diff --git a/lib/pleroma/web/pleroma_api/controllers/conversation_controller.ex b/lib/pleroma/web/pleroma_api/controllers/conversation_controller.ex index d285e4907..be2f4617d 100644 --- a/lib/pleroma/web/pleroma_api/controllers/conversation_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/conversation_controller.ex @@ -13,7 +13,6 @@ defmodule Pleroma.Web.PleromaAPI.ConversationController do alias Pleroma.Web.Plugs.OAuthScopesPlug plug(Pleroma.Web.ApiSpec.CastAndValidate) - plug(:put_view, Pleroma.Web.MastodonAPI.ConversationView) plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in [:show, :statuses]) plug( diff --git a/lib/pleroma/web/pleroma_api/controllers/notification_controller.ex b/lib/pleroma/web/pleroma_api/controllers/notification_controller.ex index 257bcd550..bcb3a9ae1 100644 --- a/lib/pleroma/web/pleroma_api/controllers/notification_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/notification_controller.ex @@ -14,8 +14,6 @@ defmodule Pleroma.Web.PleromaAPI.NotificationController do %{scopes: ["write:notifications"]} when action == :mark_as_read ) - plug(:put_view, Pleroma.Web.MastodonAPI.NotificationView) - defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaNotificationOperation def mark_as_read(%{assigns: %{user: user}, body_params: %{id: notification_id}} = conn, _) do diff --git a/lib/pleroma/web/pleroma_api/views/account_view.ex b/lib/pleroma/web/pleroma_api/views/account_view.ex new file mode 100644 index 000000000..28941f471 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/views/account_view.ex @@ -0,0 +1,10 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.AccountView do + use Pleroma.Web, :view + alias Pleroma.Web.MastodonAPI + + def render(view, opts), do: MastodonAPI.AccountView.render(view, opts) +end diff --git a/lib/pleroma/web/pleroma_api/views/conversation_view.ex b/lib/pleroma/web/pleroma_api/views/conversation_view.ex new file mode 100644 index 000000000..173006360 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/views/conversation_view.ex @@ -0,0 +1,10 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.ConversationView do + use Pleroma.Web, :view + alias Pleroma.Web.MastodonAPI + + def render(view, opts), do: MastodonAPI.ConversationView.render(view, opts) +end diff --git a/lib/pleroma/web/pleroma_api/views/notification_view.ex b/lib/pleroma/web/pleroma_api/views/notification_view.ex new file mode 100644 index 000000000..36b2fdfe8 --- /dev/null +++ b/lib/pleroma/web/pleroma_api/views/notification_view.ex @@ -0,0 +1,10 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.PleromaAPI.NotificationView do + use Pleroma.Web, :view + alias Pleroma.Web.MastodonAPI + + def render(view, opts), do: MastodonAPI.NotificationView.render(view, opts) +end diff --git a/lib/pleroma/web/static_fe/static_fe_controller.ex b/lib/pleroma/web/static_fe/static_fe_controller.ex index fe485d10d..50f0927a3 100644 --- a/lib/pleroma/web/static_fe/static_fe_controller.ex +++ b/lib/pleroma/web/static_fe/static_fe_controller.ex @@ -14,7 +14,6 @@ defmodule Pleroma.Web.StaticFE.StaticFEController do alias Pleroma.Web.Router.Helpers plug(:put_layout, :static_fe) - plug(:put_view, Pleroma.Web.StaticFE.StaticFEView) plug(:assign_id) @page_keys ["max_id", "min_id", "limit", "since_id", "order"] From 3ff9c5e2a67ab83c2abdb14cd246dea059079e75 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Sat, 22 May 2021 16:44:51 -0500 Subject: [PATCH 224/339] Break out activity-specific HTML functions into Pleroma.Activity.HTML Fixes cycles in lib/pleroma/ecto_type/activity_pub/object_validators/safe_text.ex --- lib/pleroma/activity/html.ex | 45 +++++++++++++++++++ lib/pleroma/html.ex | 35 --------------- .../web/mastodon_api/views/status_view.ex | 4 +- lib/pleroma/web/metadata/utils.ex | 3 +- 4 files changed, 49 insertions(+), 38 deletions(-) create mode 100644 lib/pleroma/activity/html.ex diff --git a/lib/pleroma/activity/html.ex b/lib/pleroma/activity/html.ex new file mode 100644 index 000000000..0bf393836 --- /dev/null +++ b/lib/pleroma/activity/html.ex @@ -0,0 +1,45 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Activity.HTML do + alias Pleroma.HTML + alias Pleroma.Object + + @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + + def get_cached_scrubbed_html_for_activity( + content, + scrubbers, + activity, + key \\ "", + callback \\ fn x -> x end + ) do + key = "#{key}#{generate_scrubber_signature(scrubbers)}|#{activity.id}" + + @cachex.fetch!(:scrubber_cache, key, fn _key -> + object = Object.normalize(activity, fetch: false) + HTML.ensure_scrubbed_html(content, scrubbers, object.data["fake"] || false, callback) + end) + end + + def get_cached_stripped_html_for_activity(content, activity, key) do + get_cached_scrubbed_html_for_activity( + content, + FastSanitize.Sanitizer.StripTags, + activity, + key, + &HtmlEntities.decode/1 + ) + end + + defp generate_scrubber_signature(scrubber) when is_atom(scrubber) do + generate_scrubber_signature([scrubber]) + end + + defp generate_scrubber_signature(scrubbers) do + Enum.reduce(scrubbers, "", fn scrubber, signature -> + "#{signature}#{to_string(scrubber)}" + end) + end +end diff --git a/lib/pleroma/html.ex b/lib/pleroma/html.ex index 2dfdca693..bee66169d 100644 --- a/lib/pleroma/html.ex +++ b/lib/pleroma/html.ex @@ -49,31 +49,6 @@ def filter_tags(html, scrubber) do def filter_tags(html), do: filter_tags(html, nil) def strip_tags(html), do: filter_tags(html, FastSanitize.Sanitizer.StripTags) - def get_cached_scrubbed_html_for_activity( - content, - scrubbers, - activity, - key \\ "", - callback \\ fn x -> x end - ) do - key = "#{key}#{generate_scrubber_signature(scrubbers)}|#{activity.id}" - - @cachex.fetch!(:scrubber_cache, key, fn _key -> - object = Pleroma.Object.normalize(activity, fetch: false) - ensure_scrubbed_html(content, scrubbers, object.data["fake"] || false, callback) - end) - end - - def get_cached_stripped_html_for_activity(content, activity, key) do - get_cached_scrubbed_html_for_activity( - content, - FastSanitize.Sanitizer.StripTags, - activity, - key, - &HtmlEntities.decode/1 - ) - end - def ensure_scrubbed_html( content, scrubbers, @@ -92,16 +67,6 @@ def ensure_scrubbed_html( end end - defp generate_scrubber_signature(scrubber) when is_atom(scrubber) do - generate_scrubber_signature([scrubber]) - end - - defp generate_scrubber_signature(scrubbers) do - Enum.reduce(scrubbers, "", fn scrubber, signature -> - "#{signature}#{to_string(scrubber)}" - end) - end - def extract_first_external_url_from_object(%{data: %{"content" => content}} = object) when is_binary(content) do unless object.data["fake"] do diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index bac897a57..da2cf0f95 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -254,7 +254,7 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} content_html = content - |> HTML.get_cached_scrubbed_html_for_activity( + |> Activity.HTML.get_cached_scrubbed_html_for_activity( User.html_filter_policy(opts[:for]), activity, "mastoapi:content" @@ -262,7 +262,7 @@ def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} content_plaintext = content - |> HTML.get_cached_stripped_html_for_activity( + |> Activity.HTML.get_cached_stripped_html_for_activity( activity, "mastoapi:content" ) diff --git a/lib/pleroma/web/metadata/utils.ex b/lib/pleroma/web/metadata/utils.ex index de7195435..bc31d66b9 100644 --- a/lib/pleroma/web/metadata/utils.ex +++ b/lib/pleroma/web/metadata/utils.ex @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Metadata.Utils do + alias Pleroma.Activity alias Pleroma.Emoji alias Pleroma.Formatter alias Pleroma.HTML @@ -13,7 +14,7 @@ def scrub_html_and_truncate(%{data: %{"content" => content}} = object) do # html content comes from DB already encoded, decode first and scrub after |> HtmlEntities.decode() |> String.replace(~r/<br\s?\/?>/, " ") - |> HTML.get_cached_stripped_html_for_activity(object, "metadata") + |> Activity.HTML.get_cached_stripped_html_for_activity(object, "metadata") |> Emoji.Formatter.demojify() |> HtmlEntities.decode() |> Formatter.truncate() From fa543a936124abee524f9a103c17d2601176dcd4 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Fri, 21 May 2021 16:58:20 -0500 Subject: [PATCH 225/339] ActivityPub.Pipeline: switch to runtime deps Speed up recompilation by breaking compile-time cycles --- lib/pleroma/web/activity_pub/pipeline.ex | 31 +++++++++++------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 195596f94..f04557a47 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -7,26 +7,23 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do alias Pleroma.Config alias Pleroma.Object alias Pleroma.Repo - alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.ActivityPub.MRF - alias Pleroma.Web.ActivityPub.ObjectValidator - alias Pleroma.Web.ActivityPub.SideEffects + alias Pleroma.Web.ActivityPub alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.Federator - @side_effects Config.get([:pipeline, :side_effects], SideEffects) - @federator Config.get([:pipeline, :federator], Federator) - @object_validator Config.get([:pipeline, :object_validator], ObjectValidator) - @mrf Config.get([:pipeline, :mrf], MRF) - @activity_pub Config.get([:pipeline, :activity_pub], ActivityPub) - @config Config.get([:pipeline, :config], Config) + defp side_effects, do: Config.get([:pipeline, :side_effects], SideEffects) + defp federator, do: Config.get([:pipeline, :federator], Federator) + defp object_validator, do: Config.get([:pipeline, :object_validator], ObjectValidator) + defp mrf, do: Config.get([:pipeline, :mrf], MRF) + defp activity_pub, do: Config.get([:pipeline, :activity_pub], ActivityPub) + defp config, do: Config.get([:pipeline, :config], Config) @spec common_pipeline(map(), keyword()) :: {:ok, Activity.t() | Object.t(), keyword()} | {:error, any()} def common_pipeline(object, meta) do case Repo.transaction(fn -> do_common_pipeline(object, meta) end) do {:ok, {:ok, activity, meta}} -> - @side_effects.handle_after_transaction(meta) + side_effects().handle_after_transaction(meta) {:ok, activity, meta} {:ok, value} -> @@ -42,13 +39,13 @@ def common_pipeline(object, meta) do def do_common_pipeline(object, meta) do with {_, {:ok, validated_object, meta}} <- - {:validate_object, @object_validator.validate(object, meta)}, + {:validate_object, object_validator().validate(object, meta)}, {_, {:ok, mrfd_object, meta}} <- - {:mrf_object, @mrf.pipeline_filter(validated_object, meta)}, + {:mrf_object, mrf().pipeline_filter(validated_object, meta)}, {_, {:ok, activity, meta}} <- - {:persist_object, @activity_pub.persist(mrfd_object, meta)}, + {:persist_object, activity_pub().persist(mrfd_object, meta)}, {_, {:ok, activity, meta}} <- - {:execute_side_effects, @side_effects.handle(activity, meta)}, + {:execute_side_effects, side_effects().handle(activity, meta)}, {_, {:ok, _}} <- {:federation, maybe_federate(activity, meta)} do {:ok, activity, meta} else @@ -61,7 +58,7 @@ defp maybe_federate(%Object{}, _), do: {:ok, :not_federated} defp maybe_federate(%Activity{} = activity, meta) do with {:ok, local} <- Keyword.fetch(meta, :local) do - do_not_federate = meta[:do_not_federate] || !@config.get([:instance, :federating]) + do_not_federate = meta[:do_not_federate] || !config().get([:instance, :federating]) if !do_not_federate and local and not Visibility.is_local_public?(activity) do activity = @@ -71,7 +68,7 @@ defp maybe_federate(%Activity{} = activity, meta) do activity end - @federator.publish(activity) + federator().publish(activity) {:ok, :federated} else {:ok, :not_federated} From 0204ceff7f66cffd87f06926ad856742940e02ff Mon Sep 17 00:00:00 2001 From: shibao <shibao@bubbletea.dev> Date: Sun, 30 May 2021 10:27:58 -0400 Subject: [PATCH 226/339] Add ffmpeg --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index b1b5171af..db1a6b457 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,7 +33,7 @@ ARG DATA=/var/lib/pleroma RUN echo "http://nl.alpinelinux.org/alpine/latest-stable/community" >> /etc/apk/repositories &&\ apk update &&\ - apk add exiftool imagemagick libmagic ncurses postgresql-client &&\ + apk add exiftool ffmpeg imagemagick libmagic ncurses postgresql-client &&\ adduser --system --shell /bin/false --home ${HOME} pleroma &&\ mkdir -p ${DATA}/uploads &&\ mkdir -p ${DATA}/static &&\ From 721c966842c2f9b4f4d6f227ecf3de69d2e66346 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Fri, 21 May 2021 17:37:34 -0500 Subject: [PATCH 227/339] FrontendStatic: make Router a runtime dep Speeds up recompilation by removing compile-time cycles --- lib/pleroma/web/plugs/frontend_static.ex | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/plugs/frontend_static.ex b/lib/pleroma/web/plugs/frontend_static.ex index e7c943b41..ebe7eaf86 100644 --- a/lib/pleroma/web/plugs/frontend_static.ex +++ b/lib/pleroma/web/plugs/frontend_static.ex @@ -10,8 +10,6 @@ defmodule Pleroma.Web.Plugs.FrontendStatic do """ @behaviour Plug - @api_routes Pleroma.Web.Router.get_api_routes() - def file_path(path, frontend_type \\ :primary) do if configuration = Pleroma.Config.get([:frontends, frontend_type]) do instance_static_path = Pleroma.Config.get([:instance, :static_dir], "instance/static") @@ -55,10 +53,13 @@ defp invalid_path?([h | _], _match) when h in [".", "..", ""], do: true defp invalid_path?([h | t], match), do: String.contains?(h, match) or invalid_path?(t) defp invalid_path?([], _match), do: false - defp api_route?([h | _]) when h in @api_routes, do: true - defp api_route?([_ | t]), do: api_route?(t) defp api_route?([]), do: false + defp api_route?([h | t]) do + api_routes = Pleroma.Web.Router.get_api_routes() + if h in api_routes, do: true, else: api_route?(t) + end + defp call_static(conn, opts, from) do opts = Map.put(opts, :from, from) Plug.Static.call(conn, opts) From 0107ec63a24784a78f81e3267230e26bf8337ca2 Mon Sep 17 00:00:00 2001 From: Snow <build-a-website@protonmail.com> Date: Mon, 15 Mar 2021 15:10:17 +0000 Subject: [PATCH 228/339] Added translation using Weblate (Chinese (Traditional)) --- priv/gettext/zh_Hant/LC_MESSAGES/errors.po | 578 +++++++++++++++++++++ 1 file changed, 578 insertions(+) create mode 100644 priv/gettext/zh_Hant/LC_MESSAGES/errors.po diff --git a/priv/gettext/zh_Hant/LC_MESSAGES/errors.po b/priv/gettext/zh_Hant/LC_MESSAGES/errors.po new file mode 100644 index 000000000..63e0d36b4 --- /dev/null +++ b/priv/gettext/zh_Hant/LC_MESSAGES/errors.po @@ -0,0 +1,578 @@ +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-03-15 15:10+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: zh_Hant\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Translate Toolkit 2.5.1\n" + +## This file is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here as no +## effect: edit them in PO (`.po`) files instead. +## From Ecto.Changeset.cast/4 +msgid "can't be blank" +msgstr "" + +## From Ecto.Changeset.unique_constraint/3 +msgid "has already been taken" +msgstr "" + +## From Ecto.Changeset.put_change/3 +msgid "is invalid" +msgstr "" + +## From Ecto.Changeset.validate_format/3 +msgid "has invalid format" +msgstr "" + +## From Ecto.Changeset.validate_subset/3 +msgid "has an invalid entry" +msgstr "" + +## From Ecto.Changeset.validate_exclusion/3 +msgid "is reserved" +msgstr "" + +## From Ecto.Changeset.validate_confirmation/3 +msgid "does not match confirmation" +msgstr "" + +## From Ecto.Changeset.no_assoc_constraint/3 +msgid "is still associated with this entry" +msgstr "" + +msgid "are still associated with this entry" +msgstr "" + +## From Ecto.Changeset.validate_length/3 +msgid "should be %{count} character(s)" +msgid_plural "should be %{count} character(s)" +msgstr[0] "" + +msgid "should have %{count} item(s)" +msgid_plural "should have %{count} item(s)" +msgstr[0] "" + +msgid "should be at least %{count} character(s)" +msgid_plural "should be at least %{count} character(s)" +msgstr[0] "" + +msgid "should have at least %{count} item(s)" +msgid_plural "should have at least %{count} item(s)" +msgstr[0] "" + +msgid "should be at most %{count} character(s)" +msgid_plural "should be at most %{count} character(s)" +msgstr[0] "" + +msgid "should have at most %{count} item(s)" +msgid_plural "should have at most %{count} item(s)" +msgstr[0] "" + +## From Ecto.Changeset.validate_number/3 +msgid "must be less than %{number}" +msgstr "" + +msgid "must be greater than %{number}" +msgstr "" + +msgid "must be less than or equal to %{number}" +msgstr "" + +msgid "must be greater than or equal to %{number}" +msgstr "" + +msgid "must be equal to %{number}" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:505 +#, elixir-format +msgid "Account not found" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:339 +#, elixir-format +msgid "Already voted" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:359 +#, elixir-format +msgid "Bad request" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:426 +#, elixir-format +msgid "Can't delete object" +msgstr "" + +#: lib/pleroma/web/controller_helper.ex:105 +#: lib/pleroma/web/controller_helper.ex:111 +#, elixir-format +msgid "Can't display this activity" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:285 +#, elixir-format +msgid "Can't find user" +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/account_controller.ex:61 +#, elixir-format +msgid "Can't get favorites" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:438 +#, elixir-format +msgid "Can't like object" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:563 +#, elixir-format +msgid "Cannot post an empty status without attachments" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:511 +#, elixir-format +msgid "Comment must be up to %{max_size} characters" +msgstr "" + +#: lib/pleroma/config/config_db.ex:191 +#, elixir-format +msgid "Config with params %{params} not found" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:181 +#: lib/pleroma/web/common_api/common_api.ex:185 +#, elixir-format +msgid "Could not delete" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:231 +#, elixir-format +msgid "Could not favorite" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:453 +#, elixir-format +msgid "Could not pin" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:278 +#, elixir-format +msgid "Could not unfavorite" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:463 +#, elixir-format +msgid "Could not unpin" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:216 +#, elixir-format +msgid "Could not unrepeat" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:512 +#: lib/pleroma/web/common_api/common_api.ex:521 +#, elixir-format +msgid "Could not update state" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:207 +#, elixir-format +msgid "Error." +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:106 +#, elixir-format +msgid "Invalid CAPTCHA" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:116 +#: lib/pleroma/web/oauth/oauth_controller.ex:568 +#, elixir-format +msgid "Invalid credentials" +msgstr "" + +#: lib/pleroma/plugs/ensure_authenticated_plug.ex:38 +#, elixir-format +msgid "Invalid credentials." +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:355 +#, elixir-format +msgid "Invalid indices" +msgstr "" + +#: lib/pleroma/web/admin_api/controllers/fallback_controller.ex:29 +#, elixir-format +msgid "Invalid parameters" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:414 +#, elixir-format +msgid "Invalid password." +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:220 +#, elixir-format +msgid "Invalid request" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:109 +#, elixir-format +msgid "Kocaptcha service unavailable" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:112 +#, elixir-format +msgid "Missing parameters" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:547 +#, elixir-format +msgid "No such conversation" +msgstr "" + +#: lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:388 +#: lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:414 lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:456 +#, elixir-format +msgid "No such permission_group" +msgstr "" + +#: lib/pleroma/plugs/uploaded_media.ex:84 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:486 lib/pleroma/web/admin_api/controllers/fallback_controller.ex:11 +#: lib/pleroma/web/feed/user_controller.ex:71 lib/pleroma/web/ostatus/ostatus_controller.ex:143 +#, elixir-format +msgid "Not found" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:331 +#, elixir-format +msgid "Poll's author can't vote" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:20 +#: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:37 lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:49 +#: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:50 lib/pleroma/web/mastodon_api/controllers/status_controller.ex:306 +#: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:71 +#, elixir-format +msgid "Record not found" +msgstr "" + +#: lib/pleroma/web/admin_api/controllers/fallback_controller.ex:35 +#: lib/pleroma/web/feed/user_controller.ex:77 lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:36 +#: lib/pleroma/web/ostatus/ostatus_controller.ex:149 +#, elixir-format +msgid "Something went wrong" +msgstr "" + +#: lib/pleroma/web/common_api/activity_draft.ex:107 +#, elixir-format +msgid "The message visibility must be direct" +msgstr "" + +#: lib/pleroma/web/common_api/utils.ex:573 +#, elixir-format +msgid "The status is over the character limit" +msgstr "" + +#: lib/pleroma/plugs/ensure_public_or_authenticated_plug.ex:31 +#, elixir-format +msgid "This resource requires authentication." +msgstr "" + +#: lib/pleroma/plugs/rate_limiter/rate_limiter.ex:206 +#, elixir-format +msgid "Throttled" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:356 +#, elixir-format +msgid "Too many choices" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:443 +#, elixir-format +msgid "Unhandled activity type" +msgstr "" + +#: lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:485 +#, elixir-format +msgid "You can't revoke your own admin status." +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:221 +#: lib/pleroma/web/oauth/oauth_controller.ex:308 +#, elixir-format +msgid "Your account is currently disabled" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:183 +#: lib/pleroma/web/oauth/oauth_controller.ex:331 +#, elixir-format +msgid "Your login is missing a confirmed e-mail address" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:390 +#, elixir-format +msgid "can't read inbox of %{nickname} as %{as_nickname}" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:473 +#, elixir-format +msgid "can't update outbox of %{nickname} as %{as_nickname}" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:471 +#, elixir-format +msgid "conversation is already muted" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:314 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:492 +#, elixir-format +msgid "error" +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex:32 +#, elixir-format +msgid "mascots can only be images" +msgstr "" + +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:62 +#, elixir-format +msgid "not found" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:394 +#, elixir-format +msgid "Bad OAuth request." +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:115 +#, elixir-format +msgid "CAPTCHA already used" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:112 +#, elixir-format +msgid "CAPTCHA expired" +msgstr "" + +#: lib/pleroma/plugs/uploaded_media.ex:57 +#, elixir-format +msgid "Failed" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:410 +#, elixir-format +msgid "Failed to authenticate: %{message}." +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:441 +#, elixir-format +msgid "Failed to set up user account." +msgstr "" + +#: lib/pleroma/plugs/oauth_scopes_plug.ex:38 +#, elixir-format +msgid "Insufficient permissions: %{permissions}." +msgstr "" + +#: lib/pleroma/plugs/uploaded_media.ex:104 +#, elixir-format +msgid "Internal Error" +msgstr "" + +#: lib/pleroma/web/oauth/fallback_controller.ex:22 +#: lib/pleroma/web/oauth/fallback_controller.ex:29 +#, elixir-format +msgid "Invalid Username/Password" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:118 +#, elixir-format +msgid "Invalid answer data" +msgstr "" + +#: lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:33 +#, elixir-format +msgid "Nodeinfo schema version not handled" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:172 +#, elixir-format +msgid "This action is outside the authorized scopes" +msgstr "" + +#: lib/pleroma/web/oauth/fallback_controller.ex:14 +#, elixir-format +msgid "Unknown error, please check the details and try again." +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:119 +#: lib/pleroma/web/oauth/oauth_controller.ex:158 +#, elixir-format +msgid "Unlisted redirect_uri." +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:390 +#, elixir-format +msgid "Unsupported OAuth provider: %{provider}." +msgstr "" + +#: lib/pleroma/uploaders/uploader.ex:72 +#, elixir-format +msgid "Uploader callback timeout" +msgstr "" + +#: lib/pleroma/web/uploader_controller.ex:23 +#, elixir-format +msgid "bad request" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:103 +#, elixir-format +msgid "CAPTCHA Error" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:290 +#, elixir-format +msgid "Could not add reaction emoji" +msgstr "" + +#: lib/pleroma/web/common_api/common_api.ex:301 +#, elixir-format +msgid "Could not remove reaction emoji" +msgstr "" + +#: lib/pleroma/web/twitter_api/twitter_api.ex:129 +#, elixir-format +msgid "Invalid CAPTCHA (Missing parameter: %{name})" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/list_controller.ex:92 +#, elixir-format +msgid "List not found" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:123 +#, elixir-format +msgid "Missing parameter: %{name}" +msgstr "" + +#: lib/pleroma/web/oauth/oauth_controller.ex:210 +#: lib/pleroma/web/oauth/oauth_controller.ex:321 +#, elixir-format +msgid "Password reset is required" +msgstr "" + +#: lib/pleroma/tests/auth_test_controller.ex:9 +#: lib/pleroma/web/activity_pub/activity_pub_controller.ex:6 lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:6 +#: lib/pleroma/web/admin_api/controllers/config_controller.ex:6 lib/pleroma/web/admin_api/controllers/fallback_controller.ex:6 +#: lib/pleroma/web/admin_api/controllers/invite_controller.ex:6 lib/pleroma/web/admin_api/controllers/media_proxy_cache_controller.ex:6 +#: lib/pleroma/web/admin_api/controllers/oauth_app_controller.ex:6 lib/pleroma/web/admin_api/controllers/relay_controller.ex:6 +#: lib/pleroma/web/admin_api/controllers/report_controller.ex:6 lib/pleroma/web/admin_api/controllers/status_controller.ex:6 +#: lib/pleroma/web/controller_helper.ex:6 lib/pleroma/web/embed_controller.ex:6 +#: lib/pleroma/web/fallback_redirect_controller.ex:6 lib/pleroma/web/feed/tag_controller.ex:6 +#: lib/pleroma/web/feed/user_controller.ex:6 lib/pleroma/web/mailer/subscription_controller.ex:2 +#: lib/pleroma/web/masto_fe_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/account_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/app_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/auth_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/conversation_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/domain_block_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/filter_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/follow_request_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/instance_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/list_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/marker_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex:14 +#: lib/pleroma/web/mastodon_api/controllers/media_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/notification_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/poll_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/report_controller.ex:8 +#: lib/pleroma/web/mastodon_api/controllers/scheduled_activity_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/search_controller.ex:6 +#: lib/pleroma/web/mastodon_api/controllers/status_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:7 +#: lib/pleroma/web/mastodon_api/controllers/suggestion_controller.ex:6 lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:6 +#: lib/pleroma/web/media_proxy/media_proxy_controller.ex:6 lib/pleroma/web/mongooseim/mongoose_im_controller.ex:6 +#: lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:6 lib/pleroma/web/oauth/fallback_controller.ex:6 +#: lib/pleroma/web/oauth/mfa_controller.ex:10 lib/pleroma/web/oauth/oauth_controller.ex:6 +#: lib/pleroma/web/ostatus/ostatus_controller.ex:6 lib/pleroma/web/pleroma_api/controllers/account_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/chat_controller.ex:5 lib/pleroma/web/pleroma_api/controllers/conversation_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:2 lib/pleroma/web/pleroma_api/controllers/emoji_reaction_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/mascot_controller.ex:6 lib/pleroma/web/pleroma_api/controllers/notification_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/scrobble_controller.ex:6 +#: lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex:7 lib/pleroma/web/static_fe/static_fe_controller.ex:6 +#: lib/pleroma/web/twitter_api/controllers/password_controller.ex:10 lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex:6 +#: lib/pleroma/web/twitter_api/controllers/util_controller.ex:6 lib/pleroma/web/twitter_api/twitter_api_controller.ex:6 +#: lib/pleroma/web/uploader_controller.ex:6 lib/pleroma/web/web_finger/web_finger_controller.ex:6 +#, elixir-format +msgid "Security violation: OAuth scopes check was neither handled nor explicitly skipped." +msgstr "" + +#: lib/pleroma/plugs/ensure_authenticated_plug.ex:28 +#, elixir-format +msgid "Two-factor authentication enabled, you must use a access token." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:210 +#, elixir-format +msgid "Unexpected error occurred while adding file to pack." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:138 +#, elixir-format +msgid "Unexpected error occurred while creating pack." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:278 +#, elixir-format +msgid "Unexpected error occurred while removing file from pack." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:250 +#, elixir-format +msgid "Unexpected error occurred while updating file in pack." +msgstr "" + +#: lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex:179 +#, elixir-format +msgid "Unexpected error occurred while updating pack metadata." +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/subscription_controller.ex:61 +#, elixir-format +msgid "Web push subscription is disabled on this Pleroma instance" +msgstr "" + +#: lib/pleroma/web/admin_api/controllers/admin_api_controller.ex:451 +#, elixir-format +msgid "You can't revoke your own admin/moderator status." +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex:126 +#, elixir-format +msgid "authorization required for timeline view" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/fallback_controller.ex:24 +#, elixir-format +msgid "Access denied" +msgstr "" + +#: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:282 +#, elixir-format +msgid "This API requires an authenticated user" +msgstr "" + +#: lib/pleroma/plugs/user_is_admin_plug.ex:21 +#, elixir-format +msgid "User is not an admin." +msgstr "" From b3209c31bc1582665399ffe65785857b40b2733a Mon Sep 17 00:00:00 2001 From: Snow <build-a-website@protonmail.com> Date: Thu, 25 Mar 2021 06:35:52 +0000 Subject: [PATCH 229/339] Translated using Weblate (Chinese (Traditional)) Currently translated at 0.9% (1 of 106 strings) Translation: Pleroma/Pleroma backend Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma/zh_Hant/ --- priv/gettext/zh_Hant/LC_MESSAGES/errors.po | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/priv/gettext/zh_Hant/LC_MESSAGES/errors.po b/priv/gettext/zh_Hant/LC_MESSAGES/errors.po index 63e0d36b4..8e6905976 100644 --- a/priv/gettext/zh_Hant/LC_MESSAGES/errors.po +++ b/priv/gettext/zh_Hant/LC_MESSAGES/errors.po @@ -3,14 +3,16 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2021-03-15 15:10+0000\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: Automatically generated\n" -"Language-Team: none\n" +"PO-Revision-Date: 2021-03-25 13:08+0000\n" +"Last-Translator: Snow <build-a-website@protonmail.com>\n" +"Language-Team: Chinese (Traditional) <https://translate.pleroma.social/" +"projects/pleroma/pleroma/zh_Hant/>\n" "Language: zh_Hant\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: Translate Toolkit 2.5.1\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Weblate 4.0.4\n" ## This file is a PO Template file. ## @@ -23,7 +25,7 @@ msgstr "" ## effect: edit them in PO (`.po`) files instead. ## From Ecto.Changeset.cast/4 msgid "can't be blank" -msgstr "" +msgstr "不能為空" ## From Ecto.Changeset.unique_constraint/3 msgid "has already been taken" From 2fde1f25492da61c531c086589cbf4a56eef5001 Mon Sep 17 00:00:00 2001 From: Snow <build-a-website@protonmail.com> Date: Tue, 11 May 2021 01:39:31 +0000 Subject: [PATCH 230/339] Translated using Weblate (Chinese (Traditional)) Currently translated at 5.6% (6 of 106 strings) Translation: Pleroma/Pleroma backend Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma/zh_Hant/ --- priv/gettext/zh_Hant/LC_MESSAGES/errors.po | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/priv/gettext/zh_Hant/LC_MESSAGES/errors.po b/priv/gettext/zh_Hant/LC_MESSAGES/errors.po index 8e6905976..9678ca297 100644 --- a/priv/gettext/zh_Hant/LC_MESSAGES/errors.po +++ b/priv/gettext/zh_Hant/LC_MESSAGES/errors.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2021-03-15 15:10+0000\n" -"PO-Revision-Date: 2021-03-25 13:08+0000\n" +"PO-Revision-Date: 2021-05-12 01:41+0000\n" "Last-Translator: Snow <build-a-website@protonmail.com>\n" "Language-Team: Chinese (Traditional) <https://translate.pleroma.social/" "projects/pleroma/pleroma/zh_Hant/>\n" @@ -29,7 +29,7 @@ msgstr "不能為空" ## From Ecto.Changeset.unique_constraint/3 msgid "has already been taken" -msgstr "" +msgstr "已被占用" ## From Ecto.Changeset.put_change/3 msgid "is invalid" @@ -45,7 +45,7 @@ msgstr "" ## From Ecto.Changeset.validate_exclusion/3 msgid "is reserved" -msgstr "" +msgstr "是被保留的" ## From Ecto.Changeset.validate_confirmation/3 msgid "does not match confirmation" @@ -85,10 +85,10 @@ msgstr[0] "" ## From Ecto.Changeset.validate_number/3 msgid "must be less than %{number}" -msgstr "" +msgstr "必須小於{number}%" msgid "must be greater than %{number}" -msgstr "" +msgstr "must be greater than {number}%" msgid "must be less than or equal to %{number}" msgstr "" @@ -200,7 +200,7 @@ msgstr "" #: lib/pleroma/web/twitter_api/twitter_api.ex:106 #, elixir-format msgid "Invalid CAPTCHA" -msgstr "" +msgstr "無效的驗證碼" #: lib/pleroma/web/mastodon_api/controllers/account_controller.ex:116 #: lib/pleroma/web/oauth/oauth_controller.ex:568 From 03232a82235be5f8811ce79c5c4afc830b12472a Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Wed, 26 May 2021 06:14:45 +0200 Subject: [PATCH 231/339] Changing references of freenode to libera.chat --- README.md | 4 ++-- docs/installation/alpine_linux_en.md | 2 +- docs/installation/arch_linux_en.md | 2 +- docs/installation/debian_based_en.md | 2 +- docs/installation/debian_based_jp.md | 4 ++-- docs/installation/freebsd_en.md | 2 +- docs/installation/gentoo_en.md | 2 +- docs/installation/netbsd_en.md | 4 +--- docs/installation/openbsd_en.md | 2 +- docs/installation/openbsd_fi.md | 4 ++-- docs/installation/otp_en.md | 4 ++-- 11 files changed, 15 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 7a05b9e48..6aa36d89a 100644 --- a/README.md +++ b/README.md @@ -50,5 +50,5 @@ If you are not developing Pleroma, it is better to use the OTP release, which co - Latest Git revision: <https://docs-develop.pleroma.social> ## Community Channels -* IRC: **#pleroma** and **#pleroma-dev** on freenode, webchat is available at <https://irc.pleroma.social> -* Matrix: <https://matrix.to/#/#freenode_#pleroma:matrix.org> and <https://matrix.to/#/#freenode_#pleroma-dev:matrix.org> +* IRC: **#pleroma** and **#pleroma-dev** on libera.chat, webchat is available at <https://irc.pleroma.social> +* Matrix: [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) and [#pleroma-dev:libera.chat](https://matrix.to/#/#pleroma-dev:libera.chat) diff --git a/docs/installation/alpine_linux_en.md b/docs/installation/alpine_linux_en.md index 7eb1718f2..a449db39b 100644 --- a/docs/installation/alpine_linux_en.md +++ b/docs/installation/alpine_linux_en.md @@ -240,4 +240,4 @@ sudo -Hu pleroma MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress ## Questions -Questions about the installation or didn’t it work as it should be, ask in [#pleroma:matrix.org](https://matrix.heldscal.la/#/room/#freenode_#pleroma:matrix.org) or IRC Channel **#pleroma** on **Freenode**. +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) via Matrix or **#pleroma** on **libera.chat** via IRC. diff --git a/docs/installation/arch_linux_en.md b/docs/installation/arch_linux_en.md index da78c3205..44b00aed8 100644 --- a/docs/installation/arch_linux_en.md +++ b/docs/installation/arch_linux_en.md @@ -215,4 +215,4 @@ sudo -Hu pleroma MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress ## Questions -Questions about the installation or didn’t it work as it should be, ask in [#pleroma:matrix.org](https://matrix.heldscal.la/#/room/#freenode_#pleroma:matrix.org) or IRC Channel **#pleroma** on **Freenode**. +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) via Matrix or **#pleroma** on **libera.chat** via IRC. diff --git a/docs/installation/debian_based_en.md b/docs/installation/debian_based_en.md index c5687a01e..ca983274e 100644 --- a/docs/installation/debian_based_en.md +++ b/docs/installation/debian_based_en.md @@ -202,4 +202,4 @@ sudo -Hu pleroma MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress ## Questions -Questions about the installation or didn’t it work as it should be, ask in [#pleroma:matrix.org](https://matrix.heldscal.la/#/room/#freenode_#pleroma:matrix.org) or IRC Channel **#pleroma** on **Freenode**. +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) via Matrix or **#pleroma** on **libera.chat** via IRC. diff --git a/docs/installation/debian_based_jp.md b/docs/installation/debian_based_jp.md index c4bbd4780..dedf555d6 100644 --- a/docs/installation/debian_based_jp.md +++ b/docs/installation/debian_based_jp.md @@ -191,5 +191,5 @@ sudo -Hu pleroma MIX_ENV=prod mix pleroma.user new <username> <your@emailaddress インストールについて質問がある、もしくは、うまくいかないときは、以下のところで質問できます。 -* [#pleroma:matrix.org](https://matrix.heldscal.la/#/room/#freenode_#pleroma:matrix.org) -* **Freenode** の **#pleroma** IRCチャンネル +* [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) +* **libera.chat** の **#pleroma** IRCチャンネル diff --git a/docs/installation/freebsd_en.md b/docs/installation/freebsd_en.md index 2dc466eb8..bdf0dd24f 100644 --- a/docs/installation/freebsd_en.md +++ b/docs/installation/freebsd_en.md @@ -213,4 +213,4 @@ incorrect timestamps. You should have ntpd running. ## Questions -Questions about the installation or didn’t it work as it should be, ask in [#pleroma:matrix.org](https://matrix.heldscal.la/#/room/#freenode_#pleroma:matrix.org) or IRC Channel **#pleroma** on **Freenode**. +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) via Matrix or **#pleroma** on **libera.chat** via IRC. diff --git a/docs/installation/gentoo_en.md b/docs/installation/gentoo_en.md index f2380ab72..1df1fede2 100644 --- a/docs/installation/gentoo_en.md +++ b/docs/installation/gentoo_en.md @@ -298,4 +298,4 @@ If you opted to allow sudo for the `pleroma` user but would like to remove the a ## Questions -Questions about the installation or didn’t it work as it should be, ask in [#pleroma:matrix.org](https://matrix.heldscal.la/#/room/#freenode_#pleroma:matrix.org) or IRC Channel **#pleroma** on **Freenode**. +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) via Matrix or **#pleroma** on **libera.chat** via IRC. diff --git a/docs/installation/netbsd_en.md b/docs/installation/netbsd_en.md index 233cf28b7..780ad2d8d 100644 --- a/docs/installation/netbsd_en.md +++ b/docs/installation/netbsd_en.md @@ -193,8 +193,6 @@ Run `# /etc/rc.d/pleroma start` to start Pleroma. Restart nginx with `# /etc/rc.d/nginx restart` and you should be up and running. -If you need further help, contact niaa on freenode. - Make sure your time is in sync, or other instances will receive your posts with incorrect timestamps. You should have ntpd running. @@ -208,4 +206,4 @@ incorrect timestamps. You should have ntpd running. ## Questions -Questions about the installation or didn’t it work as it should be, ask in [#pleroma:matrix.org](https://matrix.heldscal.la/#/room/#freenode_#pleroma:matrix.org) or IRC Channel **#pleroma** on **Freenode**. +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) via Matrix or **#pleroma** on **libera.chat** via IRC. diff --git a/docs/installation/openbsd_en.md b/docs/installation/openbsd_en.md index 0e1269ca5..a805a9235 100644 --- a/docs/installation/openbsd_en.md +++ b/docs/installation/openbsd_en.md @@ -264,4 +264,4 @@ LC_ALL=en_US.UTF-8 MIX_ENV=prod mix pleroma.user new <username> <your@emailaddre ## Questions -Questions about the installation or didn’t it work as it should be, ask in [#pleroma:matrix.org](https://matrix.heldscal.la/#/room/#freenode_#pleroma:matrix.org) or IRC Channel **#pleroma** on **Freenode**. +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) via Matrix or **#pleroma** on **libera.chat** via IRC. diff --git a/docs/installation/openbsd_fi.md b/docs/installation/openbsd_fi.md index a61434147..3c40b2d1a 100644 --- a/docs/installation/openbsd_fi.md +++ b/docs/installation/openbsd_fi.md @@ -10,8 +10,8 @@ suositeltavaa tehdä komennon `doas` avulla, katso `doas (1)` ja `doas.conf (5)` Tästä eteenpäin oletuksena on, että domain "esimerkki.com" osoittaa serverin IP-osoitteeseen. -Jos asennuksen kanssa on ongelmia, IRC-kanava #pleroma Freenodessa tai -Matrix-kanava #freenode_#pleroma:matrix.org ovat hyviä paikkoja löytää apua +Jos asennuksen kanssa on ongelmia, IRC-kanava #pleroma Libera.chat tai +Matrix-kanava #pleroma:libera.chat ovat hyviä paikkoja löytää apua (englanniksi), `/msg eal kukkuu` jos haluat välttämättä puhua härmää. Asenna tarvittava ohjelmisto: diff --git a/docs/installation/otp_en.md b/docs/installation/otp_en.md index 13f9636f3..8e43e3239 100644 --- a/docs/installation/otp_en.md +++ b/docs/installation/otp_en.md @@ -232,7 +232,7 @@ At this point if you open your (sub)domain in a browser you should see a 502 err If everything worked, you should see Pleroma-FE when visiting your domain. If that didn't happen, try reviewing the installation steps, starting Pleroma in the foreground and seeing if there are any errrors. -Still doesn't work? Feel free to contact us on [#pleroma on freenode](https://irc.pleroma.social) or via matrix at <https://matrix.heldscal.la/#/room/#freenode_#pleroma:matrix.org>, you can also [file an issue on our Gitlab](https://git.pleroma.social/pleroma/pleroma-support/issues/new) +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) via Matrix or **#pleroma** on **libera.chat** via IRC, you can also [file an issue on our Gitlab](https://git.pleroma.social/pleroma/pleroma-support/issues/new). ## Post installation @@ -301,4 +301,4 @@ This will create an account withe the username of 'joeuser' with the email addre ## Questions -Questions about the installation or didn’t it work as it should be, ask in [#pleroma:matrix.org](https://matrix.heldscal.la/#/room/#freenode_#pleroma:matrix.org) or IRC Channel **#pleroma** on **Freenode**. +Questions about the installation or didn’t it work as it should be, ask in [#pleroma:libera.chat](https://matrix.to/#/#pleroma:libera.chat) via Matrix or **#pleroma** on **libera.chat** via IRC, you can also [file an issue on our Gitlab](https://git.pleroma.social/pleroma/pleroma-support/issues/new). From da1ee5c46ac4296232ac5cd43f4357136608af48 Mon Sep 17 00:00:00 2001 From: Guy Sheffer <guysoft@gmail.com> Date: Mon, 31 May 2021 19:17:49 +0300 Subject: [PATCH 232/339] Add Raspberry Pi install instructions --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 7a05b9e48..0099fe990 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,9 @@ Currently Pleroma is not packaged by any OS/Distros, but if you want to package ### Docker While we don’t provide docker files, other people have written very good ones. Take a look at <https://github.com/angristan/docker-pleroma> or <https://glitch.sh/sn0w/pleroma-docker>. +### Raspberry Pi +Community maintained Raspberry Pi image that you can flash and run Pleroma on your Raspberry Pi. Available here <https://github.com/guysoft/PleromaPi>. + ### Compilation Troubleshooting If you ever encounter compilation issues during the updating of Pleroma, you can try these commands and see if they fix things: From 10dfe814795f16d6c32f5b6a7421e3e7c597f1ad Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Mon, 31 May 2021 13:39:15 -0500 Subject: [PATCH 233/339] Pleroma.Constants.as_local_public/0 --> Pleroma.Web.ActivityPub.Utils.as_local_public/0 Move as_local_public/0 to stop making modules depend on Web at compile-time --- lib/pleroma/constants.ex | 2 -- lib/pleroma/web/activity_pub/builder.ex | 2 +- .../activity_pub/object_validators/announce_validator.ex | 2 +- lib/pleroma/web/activity_pub/utils.ex | 2 ++ lib/pleroma/web/activity_pub/visibility.ex | 6 +++--- lib/pleroma/web/common_api/utils.ex | 2 +- .../web/mastodon_api/controllers/status_controller_test.exs | 3 ++- 7 files changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index b24338cc6..bf92f65cb 100644 --- a/lib/pleroma/constants.ex +++ b/lib/pleroma/constants.ex @@ -27,6 +27,4 @@ defmodule Pleroma.Constants do do: ~w(index.html robots.txt static static-fe finmoji emoji packs sounds images instance sw.js sw-pleroma.js favicon.png schemas doc embed.js embed.css) ) - - def as_local_public, do: Pleroma.Web.base_url() <> "/#Public" end diff --git a/lib/pleroma/web/activity_pub/builder.ex b/lib/pleroma/web/activity_pub/builder.ex index f56bfc600..f74888b67 100644 --- a/lib/pleroma/web/activity_pub/builder.ex +++ b/lib/pleroma/web/activity_pub/builder.ex @@ -223,7 +223,7 @@ def announce(actor, object, options \\ []) do [actor.follower_address] public? and Visibility.is_local_public?(object) -> - [actor.follower_address, object.data["actor"], Pleroma.Constants.as_local_public()] + [actor.follower_address, object.data["actor"], Utils.as_local_public()] public? -> [actor.follower_address, object.data["actor"], Pleroma.Constants.as_public()] diff --git a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex index b08a33e68..004500742 100644 --- a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex @@ -68,7 +68,7 @@ def validate_announcable(cng) do false <- Visibility.is_public?(object) do same_actor = object.data["actor"] == actor.ap_id recipients = get_field(cng, :to) ++ get_field(cng, :cc) - local_public = Pleroma.Constants.as_local_public() + local_public = Utils.as_local_public() is_public = Enum.member?(recipients, Pleroma.Constants.as_public()) or diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index a4dc469dc..984f39aa7 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -38,6 +38,8 @@ defmodule Pleroma.Web.ActivityPub.Utils do @supported_report_states ~w(open closed resolved) @valid_visibilities ~w(public unlisted private direct) + def as_local_public, do: Web.base_url() <> "/#Public" + # Some implementations send the actor URI as the actor field, others send the entire actor object, # so figure out what the actor's URI is based on what we have. def get_ap_id(%{"id" => id} = _), do: id diff --git a/lib/pleroma/web/activity_pub/visibility.ex b/lib/pleroma/web/activity_pub/visibility.ex index 00234c0b0..2be59144d 100644 --- a/lib/pleroma/web/activity_pub/visibility.ex +++ b/lib/pleroma/web/activity_pub/visibility.ex @@ -20,14 +20,14 @@ def is_public?(%{"directMessage" => true}), do: false def is_public?(data) do Utils.label_in_message?(Pleroma.Constants.as_public(), data) or - Utils.label_in_message?(Pleroma.Constants.as_local_public(), data) + Utils.label_in_message?(Utils.as_local_public(), data) end def is_local_public?(%Object{data: data}), do: is_local_public?(data) def is_local_public?(%Activity{data: data}), do: is_local_public?(data) def is_local_public?(data) do - Utils.label_in_message?(Pleroma.Constants.as_local_public(), data) and + Utils.label_in_message?(Utils.as_local_public(), data) and not Utils.label_in_message?(Pleroma.Constants.as_public(), data) end @@ -127,7 +127,7 @@ def get_visibility(object) do Pleroma.Constants.as_public() in cc -> "unlisted" - Pleroma.Constants.as_local_public() in to -> + Utils.as_local_public() in to -> "local" # this should use the sql for the object's activity diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 9587dfa25..93bb8e8fa 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -69,7 +69,7 @@ def get_to_and_cc(%{visibility: visibility} = draft) when visibility in ["public to = case visibility do "public" -> [Pleroma.Constants.as_public() | draft.mentions] - "local" -> [Pleroma.Constants.as_local_public() | draft.mentions] + "local" -> [Utils.as_local_public() | draft.mentions] end cc = [draft.user.follower_address] diff --git a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs index e76c2760d..fe0a5c28d 100644 --- a/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/status_controller_test.exs @@ -14,6 +14,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusControllerTest do alias Pleroma.Tests.ObanHelpers alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI import Pleroma.Factory @@ -1875,7 +1876,7 @@ test "posting a local only status" do "visibility" => "local" }) - local = Pleroma.Constants.as_local_public() + local = Utils.as_local_public() assert %{"content" => "cofe", "id" => id, "visibility" => "local"} = json_response(conn_one, 200) From 51a9f97e87823cbd9e92c375f4bc4c0bfa8b79db Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Mon, 31 May 2021 15:09:11 -0500 Subject: [PATCH 234/339] Deprecate Pleroma.Web.base_url/0 Use Pleroma.Web.Endpoint.url/0 directly instead. Reduces compiler cycles. --- benchmarks/load_testing/activities.ex | 2 +- lib/pleroma/application.ex | 2 +- lib/pleroma/constants.ex | 2 +- lib/pleroma/emails/admin_email.ex | 4 +- lib/pleroma/emoji/formatter.ex | 4 +- lib/pleroma/formatter.ex | 2 +- lib/pleroma/object.ex | 2 +- lib/pleroma/upload.ex | 4 +- lib/pleroma/user.ex | 8 ++-- lib/pleroma/web.ex | 4 -- .../web/activity_pub/mrf/no_empty_policy.ex | 4 +- lib/pleroma/web/activity_pub/publisher.ex | 2 +- lib/pleroma/web/activity_pub/utils.ex | 5 +- lib/pleroma/web/feed/feed_view.ex | 4 +- .../controllers/search_controller.ex | 4 +- .../mastodon_api/views/custom_emoji_view.ex | 4 +- .../web/mastodon_api/views/instance_view.ex | 6 +-- .../web/mastodon_api/views/status_view.ex | 2 +- lib/pleroma/web/media_proxy.ex | 8 ++-- .../web/nodeinfo/nodeinfo_controller.ex | 6 +-- .../templates/feed/feed/_activity.atom.eex | 2 +- .../web/templates/feed/feed/_activity.rss.eex | 2 +- .../feed/feed/_tag_activity.atom.eex | 2 +- .../password/reset_failed.html.eex | 2 +- .../password/reset_success.html.eex | 2 +- .../web/twitter_api/views/util_view.ex | 4 +- lib/pleroma/web/views/masto_fe_view.ex | 2 +- lib/pleroma/web/web_finger.ex | 4 +- test/pleroma/user_test.exs | 2 +- .../activity_pub/mrf/simple_policy_test.exs | 2 +- .../web/activity_pub/publisher_test.exs | 2 +- .../o_auth_app_controller_test.exs | 6 +-- .../controllers/user_controller_test.exs | 4 +- test/pleroma/web/common_api_test.exs | 2 +- test/pleroma/web/feed/tag_controller_test.exs | 4 +- .../pleroma/web/feed/user_controller_test.exs | 4 +- .../controllers/instance_controller_test.exs | 4 +- .../controllers/search_controller_test.exs | 46 +++++++++---------- .../mastodon_api/views/account_view_test.exs | 6 +-- test/pleroma/web/media_proxy_test.exs | 2 +- .../web/o_status/o_status_controller_test.exs | 2 +- .../web_finger/web_finger_controller_test.exs | 2 +- test/pleroma/web/web_finger_test.exs | 2 +- 43 files changed, 93 insertions(+), 96 deletions(-) diff --git a/benchmarks/load_testing/activities.ex b/benchmarks/load_testing/activities.ex index f5c7bfce8..b9f6b24da 100644 --- a/benchmarks/load_testing/activities.ex +++ b/benchmarks/load_testing/activities.ex @@ -299,7 +299,7 @@ defp insert_activity(:attachment, visibility, group, users, _opts) do "url" => [ %{ "href" => - "#{Pleroma.Web.base_url()}/media/b1b873552422a07bf53af01f3c231c841db4dfc42c35efde681abaf0f2a4eab7.jpg", + "#{Pleroma.Web.Endpoint.url()}/media/b1b873552422a07bf53af01f3c231c841db4dfc42c35efde681abaf0f2a4eab7.jpg", "mediaType" => "image/jpeg", "type" => "Link" } diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index c853a2bb4..e67646a9a 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -25,7 +25,7 @@ def user_agent do if Process.whereis(Pleroma.Web.Endpoint) do case Config.get([:http, :user_agent], :default) do :default -> - info = "#{Pleroma.Web.base_url()} <#{Config.get([:instance, :email], "")}>" + info = "#{Pleroma.Web.Endpoint.url()} <#{Config.get([:instance, :email], "")}>" named_version() <> "; " <> info custom -> diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index b24338cc6..bdca8279c 100644 --- a/lib/pleroma/constants.ex +++ b/lib/pleroma/constants.ex @@ -28,5 +28,5 @@ defmodule Pleroma.Constants do ~w(index.html robots.txt static static-fe finmoji emoji packs sounds images instance sw.js sw-pleroma.js favicon.png schemas doc embed.js embed.css) ) - def as_local_public, do: Pleroma.Web.base_url() <> "/#Public" + def as_local_public, do: Pleroma.Web.Endpoint.url() <> "/#Public" end diff --git a/lib/pleroma/emails/admin_email.ex b/lib/pleroma/emails/admin_email.ex index 5fe74e2f7..88bc78aec 100644 --- a/lib/pleroma/emails/admin_email.ex +++ b/lib/pleroma/emails/admin_email.ex @@ -73,7 +73,7 @@ def report(to, reporter, account, statuses, comment) do #{comment_html} #{statuses_html} <p> - <a href="#{Pleroma.Web.base_url()}/pleroma/admin/#/reports/index">View Reports in AdminFE</a> + <a href="#{Pleroma.Web.Endpoint.url()}/pleroma/admin/#/reports/index">View Reports in AdminFE</a> """ new() @@ -87,7 +87,7 @@ def new_unapproved_registration(to, account) do html_body = """ <p>New account for review: <a href="#{account.ap_id}">@#{account.nickname}</a></p> <blockquote>#{HTML.strip_tags(account.registration_reason)}</blockquote> - <a href="#{Pleroma.Web.base_url()}/pleroma/admin/#/users/#{account.id}/">Visit AdminFE</a> + <a href="#{Pleroma.Web.Endpoint.url()}/pleroma/admin/#/users/#{account.id}/">Visit AdminFE</a> """ new() diff --git a/lib/pleroma/emoji/formatter.ex b/lib/pleroma/emoji/formatter.ex index 50150e951..191451952 100644 --- a/lib/pleroma/emoji/formatter.ex +++ b/lib/pleroma/emoji/formatter.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Emoji.Formatter do alias Pleroma.Emoji alias Pleroma.HTML - alias Pleroma.Web + alias Pleroma.Web.Endpoint alias Pleroma.Web.MediaProxy def emojify(text) do @@ -44,7 +44,7 @@ def get_emoji_map(text) when is_binary(text) do Emoji.get_all() |> Enum.filter(fn {emoji, %Emoji{}} -> String.contains?(text, ":#{emoji}:") end) |> Enum.reduce(%{}, fn {name, %Emoji{file: file}}, acc -> - Map.put(acc, name, to_string(URI.merge(Web.base_url(), file))) + Map.put(acc, name, to_string(URI.merge(Endpoint.url(), file))) end) end diff --git a/lib/pleroma/formatter.ex b/lib/pleroma/formatter.ex index 7a08e48a9..535ad5f10 100644 --- a/lib/pleroma/formatter.ex +++ b/lib/pleroma/formatter.ex @@ -62,7 +62,7 @@ def mention_handler("@" <> nickname, buffer, opts, acc) do def hashtag_handler("#" <> tag = tag_text, _buffer, _opts, acc) do tag = String.downcase(tag) - url = "#{Pleroma.Web.base_url()}/tag/#{tag}" + url = "#{Pleroma.Web.Endpoint.url()}/tag/#{tag}" link = Phoenix.HTML.Tag.content_tag(:a, tag_text, diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index aaf123840..f0e15f0f7 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -325,7 +325,7 @@ def update_data(%Object{data: data} = object, attrs \\ %{}) do end def local?(%Object{data: %{"id" => id}}) do - String.starts_with?(id, Pleroma.Web.base_url() <> "/") + String.starts_with?(id, Pleroma.Web.Endpoint.url() <> "/") end def replies(object, opts \\ []) do diff --git a/lib/pleroma/upload.ex b/lib/pleroma/upload.ex index 654711351..b32131bb6 100644 --- a/lib/pleroma/upload.ex +++ b/lib/pleroma/upload.ex @@ -225,7 +225,7 @@ def base_url do case uploader do Pleroma.Uploaders.Local -> - upload_base_url || Pleroma.Web.base_url() <> "/media/" + upload_base_url || Pleroma.Web.Endpoint.url() <> "/media/" Pleroma.Uploaders.S3 -> bucket = Config.get([Pleroma.Uploaders.S3, :bucket]) @@ -251,7 +251,7 @@ def base_url do end _ -> - public_endpoint || upload_base_url || Pleroma.Web.base_url() <> "/media/" + public_endpoint || upload_base_url || Pleroma.Web.Endpoint.url() <> "/media/" end end end diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 9942617d8..4c697cb1b 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -27,13 +27,13 @@ defmodule Pleroma.User do alias Pleroma.Repo alias Pleroma.User alias Pleroma.UserRelationship - alias Pleroma.Web alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI.Utils, as: CommonUtils + alias Pleroma.Web.Endpoint alias Pleroma.Web.OAuth alias Pleroma.Web.RelMe alias Pleroma.Workers.BackgroundWorker @@ -359,7 +359,7 @@ def avatar_url(user, options \\ []) do _ -> unless options[:no_default] do - Config.get([:assets, :default_user_avatar], "#{Web.base_url()}/images/avi.png") + Config.get([:assets, :default_user_avatar], "#{Endpoint.url()}/images/avi.png") end end end @@ -367,12 +367,12 @@ def avatar_url(user, options \\ []) do def banner_url(user, options \\ []) do case user.banner do %{"url" => [%{"href" => href} | _]} -> href - _ -> !options[:no_default] && "#{Web.base_url()}/images/banner.png" + _ -> !options[:no_default] && "#{Endpoint.url()}/images/banner.png" end end # Should probably be renamed or removed - def ap_id(%User{nickname: nickname}), do: "#{Web.base_url()}/users/#{nickname}" + def ap_id(%User{nickname: nickname}), do: "#{Endpoint.url()}/users/#{nickname}" def ap_followers(%User{follower_address: fa}) when is_binary(fa), do: fa def ap_followers(%User{} = user), do: "#{ap_id(user)}/followers" diff --git a/lib/pleroma/web.ex b/lib/pleroma/web.ex index 397e4d1e7..d26931af9 100644 --- a/lib/pleroma/web.ex +++ b/lib/pleroma/web.ex @@ -231,8 +231,4 @@ def call(%Plug.Conn{} = conn, options) do defmacro __using__(which) when is_atom(which) do apply(__MODULE__, which, []) end - - def base_url do - Pleroma.Web.Endpoint.url() - end end diff --git a/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex b/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex index 32bb1b645..f4c5db05c 100644 --- a/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoEmptyPolicy do @moduledoc "Filter local activities which have no content" @behaviour Pleroma.Web.ActivityPub.MRF - alias Pleroma.Web + alias Pleroma.Web.Endpoint @impl true def filter(%{"actor" => actor} = object) do @@ -24,7 +24,7 @@ def filter(%{"actor" => actor} = object) do def filter(object), do: {:ok, object} defp is_local?(actor) do - if actor |> String.starts_with?("#{Web.base_url()}") do + if actor |> String.starts_with?("#{Endpoint.url()}") do true else false diff --git a/lib/pleroma/web/activity_pub/publisher.ex b/lib/pleroma/web/activity_pub/publisher.ex index b12b2fc24..590beef64 100644 --- a/lib/pleroma/web/activity_pub/publisher.ex +++ b/lib/pleroma/web/activity_pub/publisher.ex @@ -272,7 +272,7 @@ def gather_webfinger_links(%User{} = user) do }, %{ "rel" => "http://ostatus.org/schema/1.0/subscribe", - "template" => "#{Pleroma.Web.base_url()}/ostatus_subscribe?acct={uri}" + "template" => "#{Pleroma.Web.Endpoint.url()}/ostatus_subscribe?acct={uri}" } ] end diff --git a/lib/pleroma/web/activity_pub/utils.ex b/lib/pleroma/web/activity_pub/utils.ex index a4dc469dc..0b5f496e3 100644 --- a/lib/pleroma/web/activity_pub/utils.ex +++ b/lib/pleroma/web/activity_pub/utils.ex @@ -12,7 +12,6 @@ defmodule Pleroma.Web.ActivityPub.Utils do alias Pleroma.Object alias Pleroma.Repo alias Pleroma.User - alias Pleroma.Web alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.AdminAPI.AccountView @@ -107,7 +106,7 @@ def make_json_ld_header do %{ "@context" => [ "https://www.w3.org/ns/activitystreams", - "#{Web.base_url()}/schemas/litepub-0.1.jsonld", + "#{Endpoint.url()}/schemas/litepub-0.1.jsonld", %{ "@language" => "und" } @@ -132,7 +131,7 @@ def generate_object_id do end def generate_id(type) do - "#{Web.base_url()}/#{type}/#{UUID.generate()}" + "#{Endpoint.url()}/#{type}/#{UUID.generate()}" end def get_notified_from_object(%{"type" => type} = object) when type in @supported_object_types do diff --git a/lib/pleroma/web/feed/feed_view.ex b/lib/pleroma/web/feed/feed_view.ex index df97d2f46..51254ad93 100644 --- a/lib/pleroma/web/feed/feed_view.ex +++ b/lib/pleroma/web/feed/feed_view.ex @@ -51,10 +51,10 @@ def most_recent_update(activities, user) do def feed_logo do case Pleroma.Config.get([:feed, :logo]) do nil -> - "#{Pleroma.Web.base_url()}/static/logo.svg" + "#{Pleroma.Web.Endpoint.url()}/static/logo.svg" logo -> - "#{Pleroma.Web.base_url()}#{logo}" + "#{Pleroma.Web.Endpoint.url()}#{logo}" end |> MediaProxy.url() end diff --git a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex index af93e453d..64b177eb3 100644 --- a/lib/pleroma/web/mastodon_api/controllers/search_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/search_controller.ex @@ -8,8 +8,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do alias Pleroma.Activity alias Pleroma.Repo alias Pleroma.User - alias Pleroma.Web alias Pleroma.Web.ControllerHelper + alias Pleroma.Web.Endpoint alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.Plugs.OAuthScopesPlug @@ -108,7 +108,7 @@ defp resource_search(_, "statuses", query, options) do end defp resource_search(:v2, "hashtags", query, options) do - tags_path = Web.base_url() <> "/tag/" + tags_path = Endpoint.url() <> "/tag/" query |> prepare_tags(options) diff --git a/lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex b/lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex index 40e314164..7d2d605e9 100644 --- a/lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex +++ b/lib/pleroma/web/mastodon_api/views/custom_emoji_view.ex @@ -6,14 +6,14 @@ defmodule Pleroma.Web.MastodonAPI.CustomEmojiView do use Pleroma.Web, :view alias Pleroma.Emoji - alias Pleroma.Web + alias Pleroma.Web.Endpoint def render("index.json", %{custom_emojis: custom_emojis}) do render_many(custom_emojis, __MODULE__, "show.json") end def render("show.json", %{custom_emoji: {shortcode, %Emoji{file: relative_url, tags: tags}}}) do - url = Web.base_url() |> URI.merge(relative_url) |> to_string() + url = Endpoint.url() |> URI.merge(relative_url) |> to_string() %{ "shortcode" => shortcode, diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 73205fb6d..510cac236 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -14,7 +14,7 @@ def render("show.json", _) do instance = Config.get(:instance) %{ - uri: Pleroma.Web.base_url(), + uri: Pleroma.Web.Endpoint.url(), title: Keyword.get(instance, :name), description: Keyword.get(instance, :description), version: "#{@mastodon_api_level} (compatible; #{Pleroma.Application.named_version()})", @@ -23,7 +23,7 @@ def render("show.json", _) do streaming_api: Pleroma.Web.Endpoint.websocket_url() }, stats: Pleroma.Stats.get_stats(), - thumbnail: Pleroma.Web.base_url() <> Keyword.get(instance, :instance_thumbnail), + thumbnail: Pleroma.Web.Endpoint.url() <> Keyword.get(instance, :instance_thumbnail), languages: ["en"], registrations: Keyword.get(instance, :registrations_open), approval_required: Keyword.get(instance, :account_approval_required), @@ -34,7 +34,7 @@ def render("show.json", _) do avatar_upload_limit: Keyword.get(instance, :avatar_upload_limit), background_upload_limit: Keyword.get(instance, :background_upload_limit), banner_upload_limit: Keyword.get(instance, :banner_upload_limit), - background_image: Pleroma.Web.base_url() <> Keyword.get(instance, :background_image), + background_image: Pleroma.Web.Endpoint.url() <> Keyword.get(instance, :background_image), chat_limit: Keyword.get(instance, :chat_limit), description_limit: Keyword.get(instance, :description_limit), pleroma: %{ diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index da2cf0f95..e8de1ed28 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -485,7 +485,7 @@ def render_content(object), do: object.data["content"] || "" def build_tags(object_tags) when is_list(object_tags) do object_tags |> Enum.filter(&is_binary/1) - |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.base_url()}/tag/#{URI.encode(&1)}"}) + |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.Endpoint.url()}/tag/#{URI.encode(&1)}"}) end def build_tags(_), do: [] diff --git a/lib/pleroma/web/media_proxy.ex b/lib/pleroma/web/media_proxy.ex index 27f337138..7df591201 100644 --- a/lib/pleroma/web/media_proxy.ex +++ b/lib/pleroma/web/media_proxy.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.MediaProxy do alias Pleroma.Config alias Pleroma.Helpers.UriHelper alias Pleroma.Upload - alias Pleroma.Web + alias Pleroma.Web.Endpoint alias Pleroma.Web.MediaProxy.Invalidation @base64_opts [padding: false] @@ -69,7 +69,7 @@ def enabled?, do: Config.get([:media_proxy, :enabled], false) # non-local non-whitelisted URLs through it and be sure that body size constraint is preserved. def preview_enabled?, do: enabled?() and !!Config.get([:media_preview_proxy, :enabled]) - def local?(url), do: String.starts_with?(url, Web.base_url()) + def local?(url), do: String.starts_with?(url, Endpoint.url()) def whitelisted?(url) do %{host: domain} = URI.parse(url) @@ -122,7 +122,7 @@ def decode_url(sig, url) do end defp signed_url(url) do - :crypto.hmac(:sha, Config.get([Web.Endpoint, :secret_key_base]), url) + :crypto.hmac(:sha, Config.get([Endpoint, :secret_key_base]), url) end def filename(url_or_path) do @@ -130,7 +130,7 @@ def filename(url_or_path) do end def base_url do - Config.get([:media_proxy, :base_url], Web.base_url()) + Config.get([:media_proxy, :base_url], Endpoint.url()) end defp proxy_url(path, sig_base64, url_base64, filename) do diff --git a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex index bca94d236..69ec27ba0 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.Nodeinfo.NodeinfoController do use Pleroma.Web, :controller - alias Pleroma.Web + alias Pleroma.Web.Endpoint alias Pleroma.Web.Nodeinfo.Nodeinfo def schemas(conn, _params) do @@ -13,11 +13,11 @@ def schemas(conn, _params) do links: [ %{ rel: "http://nodeinfo.diaspora.software/ns/schema/2.0", - href: Web.base_url() <> "/nodeinfo/2.0.json" + href: Endpoint.url() <> "/nodeinfo/2.0.json" }, %{ rel: "http://nodeinfo.diaspora.software/ns/schema/2.1", - href: Web.base_url() <> "/nodeinfo/2.1.json" + href: Endpoint.url() <> "/nodeinfo/2.1.json" } ] } diff --git a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex index 3fd150c4e..ca31223fc 100644 --- a/lib/pleroma/web/templates/feed/feed/_activity.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/_activity.atom.eex @@ -38,7 +38,7 @@ <%= if id == Pleroma.Constants.as_public() do %> <link rel="mentioned" ostatus:object-type="http://activitystrea.ms/schema/1.0/collection" href="http://activityschema.org/collection/public"/> <% else %> - <%= unless Regex.match?(~r/^#{Pleroma.Web.base_url()}.+followers$/, id) do %> + <%= unless Regex.match?(~r/^#{Pleroma.Web.Endpoint.url()}.+followers$/, id) do %> <link rel="mentioned" ostatus:object-type="http://activitystrea.ms/schema/1.0/person" href="<%= id %>"/> <% end %> <% end %> diff --git a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex index 947bbb099..01dddba07 100644 --- a/lib/pleroma/web/templates/feed/feed/_activity.rss.eex +++ b/lib/pleroma/web/templates/feed/feed/_activity.rss.eex @@ -38,7 +38,7 @@ <%= if id == Pleroma.Constants.as_public() do %> <link rel="mentioned" ostatus:object-type="http://activitystrea.ms/schema/1.0/collection">http://activityschema.org/collection/public</link> <% else %> - <%= unless Regex.match?(~r/^#{Pleroma.Web.base_url()}.+followers$/, id) do %> + <%= unless Regex.match?(~r/^#{Pleroma.Web.Endpoint.url()}.+followers$/, id) do %> <link rel="mentioned" ostatus:object-type="http://activitystrea.ms/schema/1.0/person"><%= id %></link> <% end %> <% end %> diff --git a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex index cf5874a91..9ae28b48a 100644 --- a/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex +++ b/lib/pleroma/web/templates/feed/feed/_tag_activity.atom.eex @@ -33,7 +33,7 @@ ostatus:object-type="http://activitystrea.ms/schema/1.0/collection" href="http://activityschema.org/collection/public"/> <% else %> - <%= unless Regex.match?(~r/^#{Pleroma.Web.base_url()}.+followers$/, id) do %> + <%= unless Regex.match?(~r/^#{Pleroma.Web.Endpoint.url()}.+followers$/, id) do %> <link rel="mentioned" ostatus:object-type="http://activitystrea.ms/schema/1.0/person" href="<%= id %>" /> diff --git a/lib/pleroma/web/templates/twitter_api/password/reset_failed.html.eex b/lib/pleroma/web/templates/twitter_api/password/reset_failed.html.eex index df037c01e..4ed4ac8bc 100644 --- a/lib/pleroma/web/templates/twitter_api/password/reset_failed.html.eex +++ b/lib/pleroma/web/templates/twitter_api/password/reset_failed.html.eex @@ -1,2 +1,2 @@ <h2>Password reset failed</h2> -<h3><a href="<%= Pleroma.Web.base_url() %>">Homepage</a></h3> +<h3><a href="<%= Pleroma.Web.Endpoint.url() %>">Homepage</a></h3> diff --git a/lib/pleroma/web/templates/twitter_api/password/reset_success.html.eex b/lib/pleroma/web/templates/twitter_api/password/reset_success.html.eex index f30ba3274..086d4e08b 100644 --- a/lib/pleroma/web/templates/twitter_api/password/reset_success.html.eex +++ b/lib/pleroma/web/templates/twitter_api/password/reset_success.html.eex @@ -1,2 +1,2 @@ <h2>Password changed!</h2> -<h3><a href="<%= Pleroma.Web.base_url() %>">Homepage</a></h3> +<h3><a href="<%= Pleroma.Web.Endpoint.url() %>">Homepage</a></h3> diff --git a/lib/pleroma/web/twitter_api/views/util_view.ex b/lib/pleroma/web/twitter_api/views/util_view.ex index 9b13c09b3..87cb79dd7 100644 --- a/lib/pleroma/web/twitter_api/views/util_view.ex +++ b/lib/pleroma/web/twitter_api/views/util_view.ex @@ -6,14 +6,14 @@ defmodule Pleroma.Web.TwitterAPI.UtilView do use Pleroma.Web, :view import Phoenix.HTML.Form alias Pleroma.Config - alias Pleroma.Web + alias Pleroma.Web.Endpoint def status_net_config(instance) do """ <config> <site> <name>#{Keyword.get(instance, :name)}</name> - <site>#{Web.base_url()}</site> + <site>#{Endpoint.url()}</site> <textlimit>#{Keyword.get(instance, :limit)}</textlimit> <closed>#{!Keyword.get(instance, :registrations_open)}</closed> </site> diff --git a/lib/pleroma/web/views/masto_fe_view.ex b/lib/pleroma/web/views/masto_fe_view.ex index 82b301949..63a9c8179 100644 --- a/lib/pleroma/web/views/masto_fe_view.ex +++ b/lib/pleroma/web/views/masto_fe_view.ex @@ -78,7 +78,7 @@ def render("manifest.json", _params) do theme_color: Config.get([:manifest, :theme_color]), background_color: Config.get([:manifest, :background_color]), display: "standalone", - scope: Pleroma.Web.base_url(), + scope: Pleroma.Web.Endpoint.url(), start_url: Routes.masto_fe_path(Pleroma.Web.Endpoint, :index, ["getting-started"]), categories: [ "social" diff --git a/lib/pleroma/web/web_finger.ex b/lib/pleroma/web/web_finger.ex index 15002b29f..74b236aba 100644 --- a/lib/pleroma/web/web_finger.ex +++ b/lib/pleroma/web/web_finger.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.WebFinger do alias Pleroma.HTTP alias Pleroma.User - alias Pleroma.Web + alias Pleroma.Web.Endpoint alias Pleroma.Web.Federator.Publisher alias Pleroma.Web.XML alias Pleroma.XmlBuilder @@ -13,7 +13,7 @@ defmodule Pleroma.Web.WebFinger do require Logger def host_meta do - base_url = Web.base_url() + base_url = Endpoint.url() { :XRD, diff --git a/test/pleroma/user_test.exs b/test/pleroma/user_test.exs index 6f5bcab57..79c7d7ed1 100644 --- a/test/pleroma/user_test.exs +++ b/test/pleroma/user_test.exs @@ -151,7 +151,7 @@ test "untagging a user" do test "ap_id returns the activity pub id for the user" do user = UserBuilder.build() - expected_ap_id = "#{Pleroma.Web.base_url()}/users/#{user.nickname}" + expected_ap_id = "#{Pleroma.Web.Endpoint.url()}/users/#{user.nickname}" assert expected_ap_id == User.ap_id(user) end diff --git a/test/pleroma/web/activity_pub/mrf/simple_policy_test.exs b/test/pleroma/web/activity_pub/mrf/simple_policy_test.exs index f48e5b39b..ebd38caca 100644 --- a/test/pleroma/web/activity_pub/mrf/simple_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/simple_policy_test.exs @@ -504,7 +504,7 @@ test "it rejects the deletion" do defp build_local_message do %{ - "actor" => "#{Pleroma.Web.base_url()}/users/alice", + "actor" => "#{Pleroma.Web.Endpoint.url()}/users/alice", "to" => [], "cc" => [] } diff --git a/test/pleroma/web/activity_pub/publisher_test.exs b/test/pleroma/web/activity_pub/publisher_test.exs index f0ce3d7f2..89f3ad411 100644 --- a/test/pleroma/web/activity_pub/publisher_test.exs +++ b/test/pleroma/web/activity_pub/publisher_test.exs @@ -38,7 +38,7 @@ test "it returns links" do }, %{ "rel" => "http://ostatus.org/schema/1.0/subscribe", - "template" => "#{Pleroma.Web.base_url()}/ostatus_subscribe?acct={uri}" + "template" => "#{Pleroma.Web.Endpoint.url()}/ostatus_subscribe?acct={uri}" } ] diff --git a/test/pleroma/web/admin_api/controllers/o_auth_app_controller_test.exs b/test/pleroma/web/admin_api/controllers/o_auth_app_controller_test.exs index 8c7b63f34..d9b25719a 100644 --- a/test/pleroma/web/admin_api/controllers/o_auth_app_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/o_auth_app_controller_test.exs @@ -8,7 +8,7 @@ defmodule Pleroma.Web.AdminAPI.OAuthAppControllerTest do import Pleroma.Factory - alias Pleroma.Web + alias Pleroma.Web.Endpoint setup do admin = insert(:user, is_admin: true) @@ -36,7 +36,7 @@ test "errors", %{conn: conn} do end test "success", %{conn: conn} do - base_url = Web.base_url() + base_url = Endpoint.url() app_name = "Trusted app" response = @@ -58,7 +58,7 @@ test "success", %{conn: conn} do end test "with trusted", %{conn: conn} do - base_url = Web.base_url() + base_url = Endpoint.url() app_name = "Trusted app" response = diff --git a/test/pleroma/web/admin_api/controllers/user_controller_test.exs b/test/pleroma/web/admin_api/controllers/user_controller_test.exs index beb8a5d58..af295be42 100644 --- a/test/pleroma/web/admin_api/controllers/user_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/user_controller_test.exs @@ -14,9 +14,9 @@ defmodule Pleroma.Web.AdminAPI.UserControllerTest do alias Pleroma.Repo alias Pleroma.Tests.ObanHelpers alias Pleroma.User - alias Pleroma.Web alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.CommonAPI + alias Pleroma.Web.Endpoint alias Pleroma.Web.MediaProxy setup_all do @@ -403,7 +403,7 @@ test "renders users array for the first page", %{conn: conn, admin: admin} do end test "pagination works correctly with service users", %{conn: conn} do - service1 = User.get_or_create_service_actor_by_ap_id(Web.base_url() <> "/meido", "meido") + service1 = User.get_or_create_service_actor_by_ap_id(Endpoint.url() <> "/meido", "meido") insert_list(25, :user) diff --git a/test/pleroma/web/common_api_test.exs b/test/pleroma/web/common_api_test.exs index adfe58def..5ab3a48ad 100644 --- a/test/pleroma/web/common_api_test.exs +++ b/test/pleroma/web/common_api_test.exs @@ -514,7 +514,7 @@ test "it adds an emoji on an external site" do {:ok, activity} = CommonAPI.post(user, %{status: "hey :blank:"}) assert %{"blank" => url} = Object.normalize(activity).data["emoji"] - assert url == "#{Pleroma.Web.base_url()}/emoji/blank.png" + assert url == "#{Pleroma.Web.Endpoint.url()}/emoji/blank.png" end test "deactivated users can't post" do diff --git a/test/pleroma/web/feed/tag_controller_test.exs b/test/pleroma/web/feed/tag_controller_test.exs index 5c9201de1..140cdb8bf 100644 --- a/test/pleroma/web/feed/tag_controller_test.exs +++ b/test/pleroma/web/feed/tag_controller_test.exs @@ -127,10 +127,10 @@ test "gets a feed (RSS)", %{conn: conn} do "These are public toots tagged with #pleromaart. You can interact with them if you have an account anywhere in the fediverse." assert xpath(xml, ~x"//channel/link/text()") == - '#{Pleroma.Web.base_url()}/tags/pleromaart.rss' + '#{Pleroma.Web.Endpoint.url()}/tags/pleromaart.rss' assert xpath(xml, ~x"//channel/webfeeds:logo/text()") == - '#{Pleroma.Web.base_url()}/static/logo.svg' + '#{Pleroma.Web.Endpoint.url()}/static/logo.svg' assert xpath(xml, ~x"//channel/item/title/text()"l) == [ '42 This is :moominmamm...', diff --git a/test/pleroma/web/feed/user_controller_test.exs b/test/pleroma/web/feed/user_controller_test.exs index 408653d92..6f6ff433f 100644 --- a/test/pleroma/web/feed/user_controller_test.exs +++ b/test/pleroma/web/feed/user_controller_test.exs @@ -217,7 +217,9 @@ test "with non-html / non-json format, it redirects to user feed in atom format" |> get("/users/#{user.nickname}") assert conn.status == 302 - assert redirected_to(conn) == "#{Pleroma.Web.base_url()}/users/#{user.nickname}/feed.atom" + + assert redirected_to(conn) == + "#{Pleroma.Web.Endpoint.url()}/users/#{user.nickname}/feed.atom" end test "with non-html / non-json format, it returns error when user is not found", %{conn: conn} do diff --git a/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs index b99856659..f137743be 100644 --- a/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs @@ -14,8 +14,8 @@ test "get instance information", %{conn: conn} do assert result = json_response_and_validate_schema(conn, 200) email = Pleroma.Config.get([:instance, :email]) - thumbnail = Pleroma.Web.base_url() <> Pleroma.Config.get([:instance, :instance_thumbnail]) - background = Pleroma.Web.base_url() <> Pleroma.Config.get([:instance, :background_image]) + thumbnail = Pleroma.Web.Endpoint.url() <> Pleroma.Config.get([:instance, :instance_thumbnail]) + background = Pleroma.Web.Endpoint.url() <> Pleroma.Config.get([:instance, :background_image]) # Note: not checking for "max_toot_chars" since it's optional assert %{ diff --git a/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs index 1dd0fa3b8..7b0bbd8bd 100644 --- a/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/search_controller_test.exs @@ -6,8 +6,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchControllerTest do use Pleroma.Web.ConnCase alias Pleroma.Object - alias Pleroma.Web alias Pleroma.Web.CommonAPI + alias Pleroma.Web.Endpoint import Pleroma.Factory import ExUnit.CaptureLog import Tesla.Mock @@ -61,7 +61,7 @@ test "search", %{conn: conn} do assert account["id"] == to_string(user_three.id) assert results["hashtags"] == [ - %{"name" => "private", "url" => "#{Web.base_url()}/tag/private"} + %{"name" => "private", "url" => "#{Endpoint.url()}/tag/private"} ] [status] = results["statuses"] @@ -72,7 +72,7 @@ test "search", %{conn: conn} do |> json_response_and_validate_schema(200) assert results["hashtags"] == [ - %{"name" => "天子", "url" => "#{Web.base_url()}/tag/天子"} + %{"name" => "天子", "url" => "#{Endpoint.url()}/tag/天子"} ] [status] = results["statuses"] @@ -87,8 +87,8 @@ test "constructs hashtags from search query", %{conn: conn} do |> json_response_and_validate_schema(200) assert results["hashtags"] == [ - %{"name" => "explicit", "url" => "#{Web.base_url()}/tag/explicit"}, - %{"name" => "hashtags", "url" => "#{Web.base_url()}/tag/hashtags"} + %{"name" => "explicit", "url" => "#{Endpoint.url()}/tag/explicit"}, + %{"name" => "hashtags", "url" => "#{Endpoint.url()}/tag/hashtags"} ] results = @@ -97,9 +97,9 @@ test "constructs hashtags from search query", %{conn: conn} do |> json_response_and_validate_schema(200) assert results["hashtags"] == [ - %{"name" => "john", "url" => "#{Web.base_url()}/tag/john"}, - %{"name" => "doe", "url" => "#{Web.base_url()}/tag/doe"}, - %{"name" => "JohnDoe", "url" => "#{Web.base_url()}/tag/JohnDoe"} + %{"name" => "john", "url" => "#{Endpoint.url()}/tag/john"}, + %{"name" => "doe", "url" => "#{Endpoint.url()}/tag/doe"}, + %{"name" => "JohnDoe", "url" => "#{Endpoint.url()}/tag/JohnDoe"} ] results = @@ -108,9 +108,9 @@ test "constructs hashtags from search query", %{conn: conn} do |> json_response_and_validate_schema(200) assert results["hashtags"] == [ - %{"name" => "accident", "url" => "#{Web.base_url()}/tag/accident"}, - %{"name" => "prone", "url" => "#{Web.base_url()}/tag/prone"}, - %{"name" => "AccidentProne", "url" => "#{Web.base_url()}/tag/AccidentProne"} + %{"name" => "accident", "url" => "#{Endpoint.url()}/tag/accident"}, + %{"name" => "prone", "url" => "#{Endpoint.url()}/tag/prone"}, + %{"name" => "AccidentProne", "url" => "#{Endpoint.url()}/tag/AccidentProne"} ] results = @@ -119,7 +119,7 @@ test "constructs hashtags from search query", %{conn: conn} do |> json_response_and_validate_schema(200) assert results["hashtags"] == [ - %{"name" => "shpuld", "url" => "#{Web.base_url()}/tag/shpuld"} + %{"name" => "shpuld", "url" => "#{Endpoint.url()}/tag/shpuld"} ] results = @@ -136,18 +136,18 @@ test "constructs hashtags from search query", %{conn: conn} do |> json_response_and_validate_schema(200) assert results["hashtags"] == [ - %{"name" => "nascar", "url" => "#{Web.base_url()}/tag/nascar"}, - %{"name" => "ban", "url" => "#{Web.base_url()}/tag/ban"}, - %{"name" => "display", "url" => "#{Web.base_url()}/tag/display"}, - %{"name" => "confederate", "url" => "#{Web.base_url()}/tag/confederate"}, - %{"name" => "flag", "url" => "#{Web.base_url()}/tag/flag"}, - %{"name" => "all", "url" => "#{Web.base_url()}/tag/all"}, - %{"name" => "events", "url" => "#{Web.base_url()}/tag/events"}, - %{"name" => "properties", "url" => "#{Web.base_url()}/tag/properties"}, + %{"name" => "nascar", "url" => "#{Endpoint.url()}/tag/nascar"}, + %{"name" => "ban", "url" => "#{Endpoint.url()}/tag/ban"}, + %{"name" => "display", "url" => "#{Endpoint.url()}/tag/display"}, + %{"name" => "confederate", "url" => "#{Endpoint.url()}/tag/confederate"}, + %{"name" => "flag", "url" => "#{Endpoint.url()}/tag/flag"}, + %{"name" => "all", "url" => "#{Endpoint.url()}/tag/all"}, + %{"name" => "events", "url" => "#{Endpoint.url()}/tag/events"}, + %{"name" => "properties", "url" => "#{Endpoint.url()}/tag/properties"}, %{ "name" => "NascarBanDisplayConfederateFlagAllEventsProperties", "url" => - "#{Web.base_url()}/tag/NascarBanDisplayConfederateFlagAllEventsProperties" + "#{Endpoint.url()}/tag/NascarBanDisplayConfederateFlagAllEventsProperties" } ] end @@ -163,8 +163,8 @@ test "supports pagination of hashtags search results", %{conn: conn} do |> json_response_and_validate_schema(200) assert results["hashtags"] == [ - %{"name" => "text", "url" => "#{Web.base_url()}/tag/text"}, - %{"name" => "with", "url" => "#{Web.base_url()}/tag/with"} + %{"name" => "text", "url" => "#{Endpoint.url()}/tag/text"}, + %{"name" => "with", "url" => "#{Endpoint.url()}/tag/with"} ] end diff --git a/test/pleroma/web/mastodon_api/views/account_view_test.exs b/test/pleroma/web/mastodon_api/views/account_view_test.exs index 5373a17c3..28eb4f1d0 100644 --- a/test/pleroma/web/mastodon_api/views/account_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/account_view_test.exs @@ -562,12 +562,12 @@ test "uses mediaproxy urls when it's enabled (regardless of media preview proxy AccountView.render("show.json", %{user: user, skip_visibility_check: true}) |> Enum.all?(fn {key, url} when key in [:avatar, :avatar_static, :header, :header_static] -> - String.starts_with?(url, Pleroma.Web.base_url()) + String.starts_with?(url, Pleroma.Web.Endpoint.url()) {:emojis, emojis} -> Enum.all?(emojis, fn %{url: url, static_url: static_url} -> - String.starts_with?(url, Pleroma.Web.base_url()) && - String.starts_with?(static_url, Pleroma.Web.base_url()) + String.starts_with?(url, Pleroma.Web.Endpoint.url()) && + String.starts_with?(static_url, Pleroma.Web.Endpoint.url()) end) _ -> diff --git a/test/pleroma/web/media_proxy_test.exs b/test/pleroma/web/media_proxy_test.exs index 7411d0a7a..254ac3266 100644 --- a/test/pleroma/web/media_proxy_test.exs +++ b/test/pleroma/web/media_proxy_test.exs @@ -42,7 +42,7 @@ test "encodes and decodes URL" do assert String.starts_with?( encoded, - Config.get([:media_proxy, :base_url], Pleroma.Web.base_url()) + Config.get([:media_proxy, :base_url], Pleroma.Web.Endpoint.url()) ) assert String.ends_with?(encoded, "/logo.png") diff --git a/test/pleroma/web/o_status/o_status_controller_test.exs b/test/pleroma/web/o_status/o_status_controller_test.exs index 2038f4ddd..81d669837 100644 --- a/test/pleroma/web/o_status/o_status_controller_test.exs +++ b/test/pleroma/web/o_status/o_status_controller_test.exs @@ -182,7 +182,7 @@ test "render html for redirect for html format", %{conn: conn} do |> response(200) assert resp =~ - "<meta content=\"#{Pleroma.Web.base_url()}/notice/#{note_activity.id}\" property=\"og:url\">" + "<meta content=\"#{Pleroma.Web.Endpoint.url()}/notice/#{note_activity.id}\" property=\"og:url\">" user = insert(:user) diff --git a/test/pleroma/web/web_finger/web_finger_controller_test.exs b/test/pleroma/web/web_finger/web_finger_controller_test.exs index 7059850bd..2421c5800 100644 --- a/test/pleroma/web/web_finger/web_finger_controller_test.exs +++ b/test/pleroma/web/web_finger/web_finger_controller_test.exs @@ -25,7 +25,7 @@ test "GET host-meta" do assert response.resp_body == ~s(<?xml version="1.0" encoding="UTF-8"?><XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0"><Link rel="lrdd" template="#{ - Pleroma.Web.base_url() + Pleroma.Web.Endpoint.url() }/.well-known/webfinger?resource={uri}" type="application/xrd+xml" /></XRD>) end diff --git a/test/pleroma/web/web_finger_test.exs b/test/pleroma/web/web_finger_test.exs index 84477d5a1..7b90c5457 100644 --- a/test/pleroma/web/web_finger_test.exs +++ b/test/pleroma/web/web_finger_test.exs @@ -17,7 +17,7 @@ defmodule Pleroma.Web.WebFingerTest do test "returns a link to the xml lrdd" do host_info = WebFinger.host_meta() - assert String.contains?(host_info, Pleroma.Web.base_url()) + assert String.contains?(host_info, Pleroma.Web.Endpoint.url()) end end From ff00b354fa5067c898e860e275748dd757cb04cd Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Mon, 3 Aug 2020 17:08:35 -0500 Subject: [PATCH 235/339] Rename the non-federating Chat feature to Shout --- CHANGELOG.md | 1 + config/config.exs | 4 ++-- config/description.exs | 8 ++++---- lib/pleroma/config/transfer_task.ex | 2 +- lib/pleroma/web/channels/user_socket.ex | 4 ++-- .../web/mastodon_api/views/instance_view.ex | 6 +++--- .../web/{chat_channel.ex => shout_channel.ex} | 14 +++++++------- test/pleroma/config/transfer_task_test.exs | 8 ++++---- .../controllers/config_controller_test.exs | 12 ++++++------ .../controllers/instance_controller_test.exs | 2 +- ...chat_channel_test.exs => shout_channel_test.ex} | 10 +++++----- 11 files changed, 36 insertions(+), 35 deletions(-) rename lib/pleroma/web/{chat_channel.ex => shout_channel.ex} (78%) rename test/pleroma/web/{chat_channel_test.exs => shout_channel_test.ex} (80%) diff --git a/CHANGELOG.md b/CHANGELOG.md index feac7b1c3..1c08710a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed +- **Breaking:** Configuration: `:chat, enabled` moved to `:shout, enabled` and `:instance, chat_limit` moved to `:instance, shout_limit` - The `application` metadata returned with statuses is no longer hardcoded. Apps that want to display these details will now have valid data for new posts after this change. - HTTPSecurityPlug now sends a response header to opt out of Google's FLoC (Federated Learning of Cohorts) targeted advertising. - Email address is now returned if requesting user is the owner of the user account so it can be exposed in client and FE user settings UIs. diff --git a/config/config.exs b/config/config.exs index d333c618e..8c8ed5224 100644 --- a/config/config.exs +++ b/config/config.exs @@ -190,7 +190,7 @@ instance_thumbnail: "/instance/thumbnail.jpeg", limit: 5_000, description_limit: 5_000, - chat_limit: 5_000, + shout_limit: 5_000, remote_limit: 100_000, upload_limit: 16_000_000, avatar_upload_limit: 2_000_000, @@ -457,7 +457,7 @@ image_quality: 85, min_content_length: 100 * 1024 -config :pleroma, :chat, enabled: true +config :pleroma, :shoutbox, enabled: true config :phoenix, :format_encoders, json: Jason diff --git a/config/description.exs b/config/description.exs index f00c53d28..040deab96 100644 --- a/config/description.exs +++ b/config/description.exs @@ -545,9 +545,9 @@ ] }, %{ - key: :chat_limit, + key: :shout_limit, type: :integer, - description: "Character limit of the instance chat messages", + description: "Character limit of the instance shout messages", suggestions: [ 5_000 ] @@ -2652,9 +2652,9 @@ }, %{ group: :pleroma, - key: :chat, + key: :shout, type: :group, - description: "Pleroma chat settings", + description: "Pleroma shout settings", children: [ %{ key: :enabled, diff --git a/lib/pleroma/config/transfer_task.ex b/lib/pleroma/config/transfer_task.ex index 1e3ae82d0..d5c6081a2 100644 --- a/lib/pleroma/config/transfer_task.ex +++ b/lib/pleroma/config/transfer_task.ex @@ -16,7 +16,7 @@ defmodule Pleroma.Config.TransferTask do defp reboot_time_keys, do: [ {:pleroma, :hackney_pools}, - {:pleroma, :chat}, + {:pleroma, :shout}, {:pleroma, Oban}, {:pleroma, :rate_limit}, {:pleroma, :markup}, diff --git a/lib/pleroma/web/channels/user_socket.ex b/lib/pleroma/web/channels/user_socket.ex index 1c09b6768..130809bb7 100644 --- a/lib/pleroma/web/channels/user_socket.ex +++ b/lib/pleroma/web/channels/user_socket.ex @@ -8,7 +8,7 @@ defmodule Pleroma.Web.UserSocket do ## Channels # channel "room:*", Pleroma.Web.RoomChannel - channel("chat:*", Pleroma.Web.ChatChannel) + channel("shout:*", Pleroma.Web.ShoutChannel) # Socket params are passed from the client and can # be used to verify and authenticate a user. After @@ -22,7 +22,7 @@ defmodule Pleroma.Web.UserSocket do # See `Phoenix.Token` documentation for examples in # performing token verification on connect. def connect(%{"token" => token}, socket) do - with true <- Pleroma.Config.get([:chat, :enabled]), + with true <- Pleroma.Config.get([:shout, :enabled]), {:ok, user_id} <- Phoenix.Token.verify(socket, "user socket", token, max_age: 84_600), %User{} = user <- Pleroma.User.get_cached_by_id(user_id) do {:ok, assign(socket, :user_name, user.nickname)} diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 005705d97..75964f176 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -37,7 +37,7 @@ def render("show.json", _) do background_upload_limit: Keyword.get(instance, :background_upload_limit), banner_upload_limit: Keyword.get(instance, :banner_upload_limit), background_image: Pleroma.Web.Endpoint.url() <> Keyword.get(instance, :background_image), - chat_limit: Keyword.get(instance, :chat_limit), + shout_limit: Keyword.get(instance, :shout_limit), description_limit: Keyword.get(instance, :description_limit), pleroma: %{ metadata: %{ @@ -69,8 +69,8 @@ def features do if Config.get([:gopher, :enabled]) do "gopher" end, - if Config.get([:chat, :enabled]) do - "chat" + if Config.get([:shout, :enabled]) do + "shout" end, if Config.get([:instance, :allow_relay]) do "relay" diff --git a/lib/pleroma/web/chat_channel.ex b/lib/pleroma/web/shout_channel.ex similarity index 78% rename from lib/pleroma/web/chat_channel.ex rename to lib/pleroma/web/shout_channel.ex index 4008129e9..1d97858d6 100644 --- a/lib/pleroma/web/chat_channel.ex +++ b/lib/pleroma/web/shout_channel.ex @@ -2,31 +2,31 @@ # Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.ChatChannel do +defmodule Pleroma.Web.ShoutChannel do use Phoenix.Channel alias Pleroma.User - alias Pleroma.Web.ChatChannel.ChatChannelState alias Pleroma.Web.MastodonAPI.AccountView + alias Pleroma.Web.ShoutChannel.ShoutChannelState - def join("chat:public", _message, socket) do + def join("shout:public", _message, socket) do send(self(), :after_join) {:ok, socket} end def handle_info(:after_join, socket) do - push(socket, "messages", %{messages: ChatChannelState.messages()}) + push(socket, "messages", %{messages: ShoutChannelState.messages()}) {:noreply, socket} end def handle_in("new_msg", %{"text" => text}, %{assigns: %{user_name: user_name}} = socket) do text = String.trim(text) - if String.length(text) in 1..Pleroma.Config.get([:instance, :chat_limit]) do + if String.length(text) in 1..Pleroma.Config.get([:instance, :shout_limit]) do author = User.get_cached_by_nickname(user_name) author_json = AccountView.render("show.json", user: author, skip_visibility_check: true) - message = ChatChannelState.add_message(%{text: text, author: author_json}) + message = ShoutChannelState.add_message(%{text: text, author: author_json}) broadcast!(socket, "new_msg", message) end @@ -35,7 +35,7 @@ def handle_in("new_msg", %{"text" => text}, %{assigns: %{user_name: user_name}} end end -defmodule Pleroma.Web.ChatChannel.ChatChannelState do +defmodule Pleroma.Web.ShoutChannel.ShoutChannelState do use Agent @max_messages 20 diff --git a/test/pleroma/config/transfer_task_test.exs b/test/pleroma/config/transfer_task_test.exs index 8ae5d3b81..7d51fd84c 100644 --- a/test/pleroma/config/transfer_task_test.exs +++ b/test/pleroma/config/transfer_task_test.exs @@ -93,8 +93,8 @@ test "don't restart if no reboot time settings were changed" do end test "on reboot time key" do - clear_config(:chat) - insert(:config, key: :chat, value: [enabled: false]) + clear_config(:shout) + insert(:config, key: :shout, value: [enabled: false]) assert capture_log(fn -> TransferTask.start_link([]) end) =~ "pleroma restarted" end @@ -105,10 +105,10 @@ test "on reboot time subkey" do end test "don't restart pleroma on reboot time key and subkey if there is false flag" do - clear_config(:chat) + clear_config(:shout) clear_config(Pleroma.Captcha) - insert(:config, key: :chat, value: [enabled: false]) + insert(:config, key: :shout, value: [enabled: false]) insert(:config, key: Pleroma.Captcha, value: [seconds_valid: 60]) refute String.contains?( diff --git a/test/pleroma/web/admin_api/controllers/config_controller_test.exs b/test/pleroma/web/admin_api/controllers/config_controller_test.exs index c39c1b1e1..d8ca07cd3 100644 --- a/test/pleroma/web/admin_api/controllers/config_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/config_controller_test.exs @@ -409,7 +409,7 @@ test "saving config with partial update", %{conn: conn} do end test "saving config which need pleroma reboot", %{conn: conn} do - clear_config([:chat, :enabled], true) + clear_config([:shout, :enabled], true) assert conn |> put_req_header("content-type", "application/json") @@ -417,7 +417,7 @@ test "saving config which need pleroma reboot", %{conn: conn} do "/api/pleroma/admin/config", %{ configs: [ - %{group: ":pleroma", key: ":chat", value: [%{"tuple" => [":enabled", true]}]} + %{group: ":pleroma", key: ":shout", value: [%{"tuple" => [":enabled", true]}]} ] } ) @@ -426,7 +426,7 @@ test "saving config which need pleroma reboot", %{conn: conn} do %{ "db" => [":enabled"], "group" => ":pleroma", - "key" => ":chat", + "key" => ":shout", "value" => [%{"tuple" => [":enabled", true]}] } ], @@ -454,7 +454,7 @@ test "saving config which need pleroma reboot", %{conn: conn} do end test "update setting which need reboot, don't change reboot flag until reboot", %{conn: conn} do - clear_config([:chat, :enabled], true) + clear_config([:shout, :enabled], true) assert conn |> put_req_header("content-type", "application/json") @@ -462,7 +462,7 @@ test "update setting which need reboot, don't change reboot flag until reboot", "/api/pleroma/admin/config", %{ configs: [ - %{group: ":pleroma", key: ":chat", value: [%{"tuple" => [":enabled", true]}]} + %{group: ":pleroma", key: ":shout", value: [%{"tuple" => [":enabled", true]}]} ] } ) @@ -471,7 +471,7 @@ test "update setting which need reboot, don't change reboot flag until reboot", %{ "db" => [":enabled"], "group" => ":pleroma", - "key" => ":chat", + "key" => ":shout", "value" => [%{"tuple" => [":enabled", true]}] } ], diff --git a/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs index f137743be..e76cbc75b 100644 --- a/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/instance_controller_test.exs @@ -38,7 +38,7 @@ test "get instance information", %{conn: conn} do "background_upload_limit" => _, "banner_upload_limit" => _, "background_image" => from_config_background, - "chat_limit" => _, + "shout_limit" => _, "description_limit" => _ } = result diff --git a/test/pleroma/web/chat_channel_test.exs b/test/pleroma/web/shout_channel_test.ex similarity index 80% rename from test/pleroma/web/chat_channel_test.exs rename to test/pleroma/web/shout_channel_test.ex index 29999701c..ba6730ceb 100644 --- a/test/pleroma/web/chat_channel_test.exs +++ b/test/pleroma/web/shout_channel_test.ex @@ -2,9 +2,9 @@ # Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.ChatChannelTest do +defmodule Pleroma.Web.ShoutChannelTest do use Pleroma.Web.ChannelCase - alias Pleroma.Web.ChatChannel + alias Pleroma.Web.ShoutChannel alias Pleroma.Web.UserSocket import Pleroma.Factory @@ -14,7 +14,7 @@ defmodule Pleroma.Web.ChatChannelTest do {:ok, _, socket} = socket(UserSocket, "", %{user_name: user.nickname}) - |> subscribe_and_join(ChatChannel, "chat:public") + |> subscribe_and_join(ShoutChannel, "shout:public") {:ok, socket: socket} end @@ -25,7 +25,7 @@ test "it broadcasts a message", %{socket: socket} do end describe "message lengths" do - setup do: clear_config([:instance, :chat_limit]) + setup do: clear_config([:instance, :shout_limit]) test "it ignores messages of length zero", %{socket: socket} do push(socket, "new_msg", %{"text" => ""}) @@ -33,7 +33,7 @@ test "it ignores messages of length zero", %{socket: socket} do end test "it ignores messages above a certain length", %{socket: socket} do - clear_config([:instance, :chat_limit], 2) + Pleroma.Config.put([:instance, :shout_limit], 2) push(socket, "new_msg", %{"text" => "123"}) refute_broadcast("new_msg", %{text: "123"}) end From 68aa56b9e4fbf4697d5ff0c41cf2fe7230738fe6 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Mon, 3 Aug 2020 17:58:27 -0500 Subject: [PATCH 236/339] Just call it shout --- config/config.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/config.exs b/config/config.exs index 8c8ed5224..e2bf0cfc1 100644 --- a/config/config.exs +++ b/config/config.exs @@ -457,7 +457,7 @@ image_quality: 85, min_content_length: 100 * 1024 -config :pleroma, :shoutbox, enabled: true +config :pleroma, :shout, enabled: true config :phoenix, :format_encoders, json: Jason From 36fe8950f78b32e4ae8b845c099498a9acda7183 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Mon, 3 Aug 2020 18:00:16 -0500 Subject: [PATCH 237/339] Update PleromaFE settings for the old chat box --- config/description.exs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/config/description.exs b/config/description.exs index 040deab96..a17d222ce 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1182,7 +1182,7 @@ alwaysShowSubjectInput: true, background: "/static/aurora_borealis.jpg", collapseMessageWithSubject: false, - disableChat: false, + disableShout: false, greentext: false, hideFilteredStatuses: false, hideMutedPosts: false, @@ -1230,10 +1230,10 @@ "When a message has a subject (aka Content Warning), collapse it by default" }, %{ - key: :disableChat, - label: "PleromaFE Chat", + key: :disableShout, + label: "PleromaFE Shout", type: :boolean, - description: "Disables PleromaFE Chat component" + description: "Disables PleromaFE Shout component" }, %{ key: :greentext, From a3cff596592ae70701afae2e293eab03fe5408df Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Mon, 3 Aug 2020 18:34:58 -0500 Subject: [PATCH 238/339] Ensure we actually start ShoutChannel --- lib/pleroma/application.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index f4d22373a..afb8cfb8a 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -239,7 +239,7 @@ defp background_migrators do defp chat_child(true) do [ - Pleroma.Web.ChatChannel.ChatChannelState, + Pleroma.Web.ShoutChannel.ShoutChannelState, {Phoenix.PubSub, [name: Pleroma.PubSub, adapter: Phoenix.PubSub.PG2]} ] end From 4a181982c34c774c9ed4b76ce1d95f6c33fce9d5 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Mon, 3 Aug 2020 18:41:49 -0500 Subject: [PATCH 239/339] More confusingly named legacy chat code renamed to shout --- lib/pleroma/application.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index afb8cfb8a..9824e0a4a 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -102,7 +102,7 @@ def start(_type, _args) do ] ++ task_children(@mix_env) ++ dont_run_in_test(@mix_env) ++ - chat_child(chat_enabled?()) ++ + shout_child(shout_enabled?()) ++ [Pleroma.Gopher.Server] # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html @@ -216,7 +216,7 @@ def build_cachex(type, opts), type: :worker } - defp chat_enabled?, do: Config.get([:chat, :enabled]) + defp shout_enabled?, do: Config.get([:shout, :enabled]) defp dont_run_in_test(env) when env in [:test, :benchmark], do: [] @@ -237,14 +237,14 @@ defp background_migrators do ] end - defp chat_child(true) do + defp shout_child(true) do [ Pleroma.Web.ShoutChannel.ShoutChannelState, {Phoenix.PubSub, [name: Pleroma.PubSub, adapter: Phoenix.PubSub.PG2]} ] end - defp chat_child(_), do: [] + defp shout_child(_), do: [] defp task_children(:test) do [ From d6432a65da7ad11f1383d465370c11de5a2d7ddc Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Tue, 4 Aug 2020 10:42:52 -0500 Subject: [PATCH 240/339] Move shout configuration from :instance, update docs and changelog --- CHANGELOG.md | 2 +- config/config.exs | 5 +++-- docs/configuration/cheatsheet.md | 6 +++--- lib/pleroma/web/mastodon_api/views/instance_view.ex | 2 +- lib/pleroma/web/shout_channel.ex | 2 +- test/pleroma/web/shout_channel_test.ex | 4 ++-- 6 files changed, 11 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c08710a3..6e27c4561 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed -- **Breaking:** Configuration: `:chat, enabled` moved to `:shout, enabled` and `:instance, chat_limit` moved to `:instance, shout_limit` +- **Breaking:** Configuration: `:chat, enabled` moved to `:shout, enabled` and `:instance, chat_limit` moved to `:shout, limit` - The `application` metadata returned with statuses is no longer hardcoded. Apps that want to display these details will now have valid data for new posts after this change. - HTTPSecurityPlug now sends a response header to opt out of Google's FLoC (Federated Learning of Cohorts) targeted advertising. - Email address is now returned if requesting user is the owner of the user account so it can be exposed in client and FE user settings UIs. diff --git a/config/config.exs b/config/config.exs index e2bf0cfc1..2f8a18788 100644 --- a/config/config.exs +++ b/config/config.exs @@ -190,7 +190,6 @@ instance_thumbnail: "/instance/thumbnail.jpeg", limit: 5_000, description_limit: 5_000, - shout_limit: 5_000, remote_limit: 100_000, upload_limit: 16_000_000, avatar_upload_limit: 2_000_000, @@ -457,7 +456,9 @@ image_quality: 85, min_content_length: 100 * 1024 -config :pleroma, :shout, enabled: true +config :pleroma, :shout, + enabled: true, + limit: 5_000 config :phoenix, :format_encoders, json: Jason diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 069421722..4e20309a1 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -8,9 +8,10 @@ For from source installations Pleroma configuration works by first importing the To add configuration to your config file, you can copy it from the base config. The latest version of it can be viewed [here](https://git.pleroma.social/pleroma/pleroma/blob/develop/config/config.exs). You can also use this file if you don't know how an option is supposed to be formatted. -## :chat +## :shout -* `enabled` - Enables the backend chat. Defaults to `true`. +* `enabled` - Enables the backend Shoutbox chat feature. Defaults to `true`. +* `limit` - Shout character limit. Defaults to `5_000` ## :instance * `name`: The instance’s name. @@ -19,7 +20,6 @@ To add configuration to your config file, you can copy it from the base config. * `description`: The instance’s description, can be seen in nodeinfo and ``/api/v1/instance``. * `limit`: Posts character limit (CW/Subject included in the counter). * `description_limit`: The character limit for image descriptions. -* `chat_limit`: Character limit of the instance chat messages. * `remote_limit`: Hard character limit beyond which remote posts will be dropped. * `upload_limit`: File size limit of uploads (except for avatar, background, banner). * `avatar_upload_limit`: File size limit of user’s profile avatars. diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 75964f176..fcb4e2466 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -37,7 +37,7 @@ def render("show.json", _) do background_upload_limit: Keyword.get(instance, :background_upload_limit), banner_upload_limit: Keyword.get(instance, :banner_upload_limit), background_image: Pleroma.Web.Endpoint.url() <> Keyword.get(instance, :background_image), - shout_limit: Keyword.get(instance, :shout_limit), + shout_limit: Config.get([:shout, :limit]), description_limit: Keyword.get(instance, :description_limit), pleroma: %{ metadata: %{ diff --git a/lib/pleroma/web/shout_channel.ex b/lib/pleroma/web/shout_channel.ex index 1d97858d6..dc342fdfb 100644 --- a/lib/pleroma/web/shout_channel.ex +++ b/lib/pleroma/web/shout_channel.ex @@ -22,7 +22,7 @@ def handle_info(:after_join, socket) do def handle_in("new_msg", %{"text" => text}, %{assigns: %{user_name: user_name}} = socket) do text = String.trim(text) - if String.length(text) in 1..Pleroma.Config.get([:instance, :shout_limit]) do + if String.length(text) in 1..Pleroma.Config.get([:shout, :limit]) do author = User.get_cached_by_nickname(user_name) author_json = AccountView.render("show.json", user: author, skip_visibility_check: true) diff --git a/test/pleroma/web/shout_channel_test.ex b/test/pleroma/web/shout_channel_test.ex index ba6730ceb..b4d661689 100644 --- a/test/pleroma/web/shout_channel_test.ex +++ b/test/pleroma/web/shout_channel_test.ex @@ -25,7 +25,7 @@ test "it broadcasts a message", %{socket: socket} do end describe "message lengths" do - setup do: clear_config([:instance, :shout_limit]) + setup do: clear_config([:shout, :limit]) test "it ignores messages of length zero", %{socket: socket} do push(socket, "new_msg", %{"text" => ""}) @@ -33,7 +33,7 @@ test "it ignores messages of length zero", %{socket: socket} do end test "it ignores messages above a certain length", %{socket: socket} do - Pleroma.Config.put([:instance, :shout_limit], 2) + Pleroma.Config.put([:shout, :limit], 2) push(socket, "new_msg", %{"text" => "123"}) refute_broadcast("new_msg", %{text: "123"}) end From 8ff2d8d17d057d5a2e0f5df603812155d6985df0 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Tue, 4 Aug 2020 10:45:28 -0500 Subject: [PATCH 241/339] Update description file for new shout config setting location --- config/description.exs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/config/description.exs b/config/description.exs index a17d222ce..7eecddaf5 100644 --- a/config/description.exs +++ b/config/description.exs @@ -544,14 +544,6 @@ 5_000 ] }, - %{ - key: :shout_limit, - type: :integer, - description: "Character limit of the instance shout messages", - suggestions: [ - 5_000 - ] - }, %{ key: :remote_limit, type: :integer, @@ -2658,7 +2650,16 @@ children: [ %{ key: :enabled, - type: :boolean + type: :boolean, + description: "Enables the backend Shoutbox chat feature." + }, + %{ + key: :limit, + type: :integer, + description: "Shout message character limit.", + suggestions: [ + 5_000 + ] } ] }, From 01f796f8bbe98b2f004941d0a449035be6555c26 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@FreeBSD.org> Date: Thu, 6 Aug 2020 16:37:17 -0500 Subject: [PATCH 242/339] Add a test for the migration --- ...200806175913_rename_instance_chat_test.exs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 test/migrations/20200806175913_rename_instance_chat_test.exs diff --git a/test/migrations/20200806175913_rename_instance_chat_test.exs b/test/migrations/20200806175913_rename_instance_chat_test.exs new file mode 100644 index 000000000..66341bd84 --- /dev/null +++ b/test/migrations/20200806175913_rename_instance_chat_test.exs @@ -0,0 +1,21 @@ +defmodule Pleroma.Repo.Migrations.RenameInstanceChatTest do + use Pleroma.DataCase + import Pleroma.Factory + import Pleroma.Tests.Helpers + alias Pleroma.ConfigDB + + setup do: clear_config([:instance]) + setup do: clear_config([:chat]) + setup_all do: require_migration("20200806175913_rename_instance_chat") + + test "up/0 migrates chat settings to shout", %{migration: migration} do + insert(:config, group: :pleroma, key: :instance, value: ["chat_limit: 6000"]) + insert(:config, group: :pleroma, key: :chat, value: ["enabled: true"]) + + migration.up() + + assert nil == ConfigDB.get_by_params(%{group: :pleroma, key: :chat}) + + assert %{value: [limit: 6000, enabled: true]} == ConfigDB.get_by_params(%{group: :pleroma, key: :shout}) + end +end From e0bb6557739b9662bc7da785d060e302c4c61d61 Mon Sep 17 00:00:00 2001 From: Roman Chvanikov <chvanikoff@pm.me> Date: Fri, 7 Aug 2020 12:18:36 +0300 Subject: [PATCH 243/339] Add RenameInstanceChat migration --- .../20200806175913_rename_instance_chat.exs | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 priv/repo/migrations/20200806175913_rename_instance_chat.exs diff --git a/priv/repo/migrations/20200806175913_rename_instance_chat.exs b/priv/repo/migrations/20200806175913_rename_instance_chat.exs new file mode 100644 index 000000000..31585efe8 --- /dev/null +++ b/priv/repo/migrations/20200806175913_rename_instance_chat.exs @@ -0,0 +1,77 @@ +defmodule Pleroma.Repo.Migrations.RenameInstanceChat do + use Ecto.Migration + + alias Pleroma.ConfigDB + + @instance_params %{group: :pleroma, key: :instance} + @shout_params %{group: :pleroma, key: :shout} + @chat_params %{group: :pleroma, key: :chat} + + def up do + instance_updated? = maybe_update_instance_key(:up) != :noop + chat_updated? = maybe_update_chat_key(:up) != :noop + + case Enum.any?([instance_updated?, chat_updated?]) do + true -> :ok + false -> :noop + end + end + + def down do + instance_updated? = maybe_update_instance_key(:down) != :noop + chat_updated? = maybe_update_chat_key(:down) != :noop + + case Enum.any?([instance_updated?, chat_updated?]) do + true -> :ok + false -> :noop + end + end + + # pleroma.instance.chat_limit -> pleroma.shout.limit + defp maybe_update_instance_key(:up) do + with %ConfigDB{value: values} <- ConfigDB.get_by_params(@instance_params), + limit when is_integer(limit) <- values[:chat_limit] do + @shout_params |> Map.put(:value, limit: limit) |> ConfigDB.update_or_create() + @instance_params |> Map.put(:subkeys, [":chat_limit"]) |> ConfigDB.delete() + else + _ -> + :noop + end + end + + # pleroma.shout.limit -> pleroma.instance.chat_limit + defp maybe_update_instance_key(:down) do + with %ConfigDB{value: values} <- ConfigDB.get_by_params(@shout_params), + limit when is_integer(limit) <- values[:limit] do + @instance_params |> Map.put(:value, chat_limit: limit) |> ConfigDB.update_or_create() + @shout_params |> Map.put(:subkeys, [":limit"]) |> ConfigDB.delete() + else + _ -> + :noop + end + end + + # pleroma.chat.enabled -> pleroma.shout.enabled + defp maybe_update_chat_key(:up) do + with %ConfigDB{value: values} <- ConfigDB.get_by_params(@chat_params), + enabled? when is_boolean(enabled?) <- values[:enabled] do + @shout_params |> Map.put(:value, enabled: enabled?) |> ConfigDB.update_or_create() + @chat_params |> Map.put(:subkeys, [":enabled"]) |> ConfigDB.delete() + else + _ -> + :noop + end + end + + # pleroma.shout.enabled -> pleroma.chat.enabled + defp maybe_update_chat_key(:down) do + with %ConfigDB{value: values} <- ConfigDB.get_by_params(@shout_params), + enabled? when is_boolean(enabled?) <- values[:enabled] do + @chat_params |> Map.put(:value, enabled: enabled?) |> ConfigDB.update_or_create() + @shout_params |> Map.put(:subkeys, [":enabled"]) |> ConfigDB.delete() + else + _ -> + :noop + end + end +end From d7dfa6d27cf140b9d0faed32c3a7a659a0f2e20e Mon Sep 17 00:00:00 2001 From: Roman Chvanikov <chvanikoff@pm.me> Date: Fri, 7 Aug 2020 12:18:55 +0300 Subject: [PATCH 244/339] Update test for RenameInstanceChat migration --- ...200806175913_rename_instance_chat_test.exs | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/test/migrations/20200806175913_rename_instance_chat_test.exs b/test/migrations/20200806175913_rename_instance_chat_test.exs index 66341bd84..acd45600c 100644 --- a/test/migrations/20200806175913_rename_instance_chat_test.exs +++ b/test/migrations/20200806175913_rename_instance_chat_test.exs @@ -8,14 +8,45 @@ defmodule Pleroma.Repo.Migrations.RenameInstanceChatTest do setup do: clear_config([:chat]) setup_all do: require_migration("20200806175913_rename_instance_chat") - test "up/0 migrates chat settings to shout", %{migration: migration} do - insert(:config, group: :pleroma, key: :instance, value: ["chat_limit: 6000"]) - insert(:config, group: :pleroma, key: :chat, value: ["enabled: true"]) + describe "up/0" do + test "migrates chat settings to shout", %{migration: migration} do + insert(:config, group: :pleroma, key: :instance, value: [chat_limit: 6000]) + insert(:config, group: :pleroma, key: :chat, value: [enabled: true]) - migration.up() + assert migration.up() == :ok - assert nil == ConfigDB.get_by_params(%{group: :pleroma, key: :chat}) + assert ConfigDB.get_by_params(%{group: :pleroma, key: :chat}) == nil + assert ConfigDB.get_by_params(%{group: :pleroma, key: :instance}) == nil - assert %{value: [limit: 6000, enabled: true]} == ConfigDB.get_by_params(%{group: :pleroma, key: :shout}) + assert ConfigDB.get_by_params(%{group: :pleroma, key: :shout}).value == [ + limit: 6000, + enabled: true + ] + end + + test "does nothing when chat settings are not set", %{migration: migration} do + assert migration.up() == :noop + assert ConfigDB.get_by_params(%{group: :pleroma, key: :chat}) == nil + assert ConfigDB.get_by_params(%{group: :pleroma, key: :shout}) == nil + end + end + + describe "down/0" do + test "migrates shout settings back to instance and chat", %{migration: migration} do + insert(:config, group: :pleroma, key: :shout, value: [limit: 42, enabled: true]) + + assert migration.down() == :ok + + assert ConfigDB.get_by_params(%{group: :pleroma, key: :chat}).value == [enabled: true] + assert ConfigDB.get_by_params(%{group: :pleroma, key: :instance}).value == [chat_limit: 42] + assert ConfigDB.get_by_params(%{group: :pleroma, key: :shout}) == nil + end + + test "does nothing when shout settings are not set", %{migration: migration} do + assert migration.down() == :noop + assert ConfigDB.get_by_params(%{group: :pleroma, key: :chat}) == nil + assert ConfigDB.get_by_params(%{group: :pleroma, key: :instance}) == nil + assert ConfigDB.get_by_params(%{group: :pleroma, key: :shout}) == nil + end end end From 9ce2c017c0782c9ea4a0ca6009e82d034ac7915c Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Thu, 20 May 2021 14:30:29 -0500 Subject: [PATCH 245/339] We want clear_config/2 in all tests now --- test/pleroma/web/shout_channel_test.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/pleroma/web/shout_channel_test.ex b/test/pleroma/web/shout_channel_test.ex index b4d661689..a266543d2 100644 --- a/test/pleroma/web/shout_channel_test.ex +++ b/test/pleroma/web/shout_channel_test.ex @@ -33,7 +33,7 @@ test "it ignores messages of length zero", %{socket: socket} do end test "it ignores messages above a certain length", %{socket: socket} do - Pleroma.Config.put([:shout, :limit], 2) + clear_config([:shout, :limit], 2) push(socket, "new_msg", %{"text" => "123"}) refute_broadcast("new_msg", %{text: "123"}) end From d9513b11d3c18c4d30cdcab700bb5ed39b3356ea Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Fri, 28 May 2021 14:10:37 -0500 Subject: [PATCH 246/339] Forgot to move migration test when rebasing --- .../repo/migrations/rename_instance_chat_test.exs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/{migrations/20200806175913_rename_instance_chat_test.exs => pleroma/repo/migrations/rename_instance_chat_test.exs} (100%) diff --git a/test/migrations/20200806175913_rename_instance_chat_test.exs b/test/pleroma/repo/migrations/rename_instance_chat_test.exs similarity index 100% rename from test/migrations/20200806175913_rename_instance_chat_test.exs rename to test/pleroma/repo/migrations/rename_instance_chat_test.exs From 48a0ea2fc342c9a757222b0755a0cad9b725bdc7 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 1 Jun 2021 11:55:51 -0500 Subject: [PATCH 247/339] Wire up join requests to the old "chat:public" channel into the new "shout:public" channel --- lib/pleroma/web/shout_channel.ex | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/pleroma/web/shout_channel.ex b/lib/pleroma/web/shout_channel.ex index dc342fdfb..70d9cc1e1 100644 --- a/lib/pleroma/web/shout_channel.ex +++ b/lib/pleroma/web/shout_channel.ex @@ -9,6 +9,9 @@ defmodule Pleroma.Web.ShoutChannel do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.ShoutChannel.ShoutChannelState + # Backwards compatibility + def join("chat:public", message, socket), do: join("shout:public", message, socket) + def join("shout:public", _message, socket) do send(self(), :after_join) {:ok, socket} From 2743c6669311ecb9a985a959dfd28b7aeed8783a Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 1 Jun 2021 12:57:18 -0500 Subject: [PATCH 248/339] Add "chat" back as a feature for backwards compat. Legacy PleromaFE uses this to identify if ShoutBox is available. --- lib/pleroma/web/mastodon_api/views/instance_view.ex | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index fcb4e2466..3528185d5 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -69,6 +69,10 @@ def features do if Config.get([:gopher, :enabled]) do "gopher" end, + # backwards compat + if Config.get([:shout, :enabled]) do + "chat" + end, if Config.get([:shout, :enabled]) do "shout" end, From a744c47e9a43a751438973a66b7201b006c6b944 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Tue, 1 Jun 2021 13:55:07 -0500 Subject: [PATCH 249/339] Remove deps from Streaming/Persisting behaviors Speeds up recompilation by limiting compile-time deps --- lib/pleroma/web/activity_pub/activity_pub/persisting.ex | 2 +- lib/pleroma/web/activity_pub/activity_pub/streaming.ex | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub/persisting.ex b/lib/pleroma/web/activity_pub/activity_pub/persisting.ex index 5ec8b7bab..f39cd000a 100644 --- a/lib/pleroma/web/activity_pub/activity_pub/persisting.ex +++ b/lib/pleroma/web/activity_pub/activity_pub/persisting.ex @@ -3,5 +3,5 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ActivityPub.Persisting do - @callback persist(map(), keyword()) :: {:ok, Activity.t() | Object.t()} + @callback persist(map(), keyword()) :: {:ok, struct()} end diff --git a/lib/pleroma/web/activity_pub/activity_pub/streaming.ex b/lib/pleroma/web/activity_pub/activity_pub/streaming.ex index 983168bff..33c7bf2bc 100644 --- a/lib/pleroma/web/activity_pub/activity_pub/streaming.ex +++ b/lib/pleroma/web/activity_pub/activity_pub/streaming.ex @@ -3,10 +3,6 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.ActivityPub.ActivityPub.Streaming do - alias Pleroma.Activity - alias Pleroma.Object - alias Pleroma.User - - @callback stream_out(Activity.t()) :: any() - @callback stream_out_participations(Object.t(), User.t()) :: any() + @callback stream_out(struct()) :: any() + @callback stream_out_participations(struct(), struct()) :: any() end From 8a5ceb7e53f1817f83a72b997f6b9daa7070972b Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Sat, 22 May 2021 15:30:14 -0500 Subject: [PATCH 250/339] Remove deps from Uploader behaviour Speeds up recompilation by limiting compile-time deps --- lib/pleroma/uploaders/uploader.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/uploaders/uploader.ex b/lib/pleroma/uploaders/uploader.ex index 0be878ca2..deba548b7 100644 --- a/lib/pleroma/uploaders/uploader.ex +++ b/lib/pleroma/uploaders/uploader.ex @@ -35,7 +35,7 @@ defmodule Pleroma.Uploaders.Uploader do """ @type file_spec :: {:file | :url, String.t()} - @callback put_file(Pleroma.Upload.t()) :: + @callback put_file(upload :: struct()) :: :ok | {:ok, file_spec()} | {:error, String.t()} | :wait_callback @callback delete_file(file :: String.t()) :: :ok | {:error, String.t()} @@ -46,7 +46,7 @@ defmodule Pleroma.Uploaders.Uploader do | {:error, Plug.Conn.t(), String.t()} @optional_callbacks http_callback: 2 - @spec put_file(module(), Pleroma.Upload.t()) :: {:ok, file_spec()} | {:error, String.t()} + @spec put_file(module(), upload :: struct()) :: {:ok, file_spec()} | {:error, String.t()} def put_file(uploader, upload) do case uploader.put_file(upload) do :ok -> {:ok, {:file, upload.path}} From 0be7eada92d862277c3bf349ca5a3079ebacb700 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 1 Jun 2021 14:34:13 -0500 Subject: [PATCH 251/339] Keep original Shoutbox channel name as chat:public There is no sane / high level workaround for merging users who join shout:public and chat:public. --- lib/pleroma/web/channels/user_socket.ex | 2 +- lib/pleroma/web/shout_channel.ex | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/channels/user_socket.ex b/lib/pleroma/web/channels/user_socket.ex index 130809bb7..043206835 100644 --- a/lib/pleroma/web/channels/user_socket.ex +++ b/lib/pleroma/web/channels/user_socket.ex @@ -8,7 +8,7 @@ defmodule Pleroma.Web.UserSocket do ## Channels # channel "room:*", Pleroma.Web.RoomChannel - channel("shout:*", Pleroma.Web.ShoutChannel) + channel("chat:*", Pleroma.Web.ShoutChannel) # Socket params are passed from the client and can # be used to verify and authenticate a user. After diff --git a/lib/pleroma/web/shout_channel.ex b/lib/pleroma/web/shout_channel.ex index 70d9cc1e1..17caecb1a 100644 --- a/lib/pleroma/web/shout_channel.ex +++ b/lib/pleroma/web/shout_channel.ex @@ -9,10 +9,7 @@ defmodule Pleroma.Web.ShoutChannel do alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.ShoutChannel.ShoutChannelState - # Backwards compatibility - def join("chat:public", message, socket), do: join("shout:public", message, socket) - - def join("shout:public", _message, socket) do + def join("chat:public", _message, socket) do send(self(), :after_join) {:ok, socket} end From 9879c18548c1b9f37df724259f65d5cd098f44c5 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Sat, 22 May 2021 14:48:13 -0500 Subject: [PATCH 252/339] Avoid `use Phoenix.Swoosh` to prevent recompiling the Endpoint Speeds up recompilation by fixing cycles in UserEmail --- lib/pleroma/emails/user_email.ex | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/emails/user_email.ex b/lib/pleroma/emails/user_email.ex index 52f3d419d..e38c681ba 100644 --- a/lib/pleroma/emails/user_email.ex +++ b/lib/pleroma/emails/user_email.ex @@ -5,15 +5,22 @@ defmodule Pleroma.Emails.UserEmail do @moduledoc "User emails" - 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 + import Swoosh.Email + import Phoenix.Swoosh, except: [render_body: 3] import Pleroma.Config.Helpers, only: [instance_name: 0, sender: 0] + def render_body(email, template, assigns \\ %{}) do + email + |> put_new_layout({Pleroma.Web.LayoutView, :email}) + |> put_new_view(Pleroma.Web.EmailView) + |> Phoenix.Swoosh.render_body(template, assigns) + end + defp recipient(email, nil), do: email defp recipient(email, name), do: {name, email} defp recipient(%User{} = user), do: recipient(user.email, user.name) From dcf84ac12e830ebc17f63e2fea0bd47c75e9f981 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 1 Jun 2021 16:53:32 -0500 Subject: [PATCH 253/339] disableChat / disableShout didn't actually do anything for PleromaFE --- config/description.exs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/config/description.exs b/config/description.exs index 7eecddaf5..c93a00782 100644 --- a/config/description.exs +++ b/config/description.exs @@ -1174,7 +1174,6 @@ alwaysShowSubjectInput: true, background: "/static/aurora_borealis.jpg", collapseMessageWithSubject: false, - disableShout: false, greentext: false, hideFilteredStatuses: false, hideMutedPosts: false, @@ -1221,12 +1220,6 @@ description: "When a message has a subject (aka Content Warning), collapse it by default" }, - %{ - key: :disableShout, - label: "PleromaFE Shout", - type: :boolean, - description: "Disables PleromaFE Shout component" - }, %{ key: :greentext, label: "Greentext", From 297feb73f4ea2de59cf3fc8f4beabe18e72a6311 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Wed, 2 Jun 2021 11:21:04 -0500 Subject: [PATCH 254/339] Formatting --- config/description.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/description.exs b/config/description.exs index 1c3e3f900..abd802ac0 100644 --- a/config/description.exs +++ b/config/description.exs @@ -682,7 +682,8 @@ %{ key: :allow_relay, type: :boolean, - description: "Enable Pleroma's Relay, which makes it possible to follow a whole instance. (Important!) This will increase the visibility of your instance." + description: + "Enable Pleroma's Relay, which makes it possible to follow a whole instance. (Important!) This will increase the visibility of your instance." }, %{ key: :public, From 679d4c23e93f86f3e3fec70e0ac9bf2c9cdf8819 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Wed, 2 Jun 2021 11:26:26 -0500 Subject: [PATCH 255/339] Update wording for relays in docs and config description --- config/description.exs | 2 +- docs/configuration/cheatsheet.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/description.exs b/config/description.exs index abd802ac0..bdde22f98 100644 --- a/config/description.exs +++ b/config/description.exs @@ -683,7 +683,7 @@ key: :allow_relay, type: :boolean, description: - "Enable Pleroma's Relay, which makes it possible to follow a whole instance. (Important!) This will increase the visibility of your instance." + "Permits remote instances to subscribe to all public posts of your instance. (Important!) This may increase the visibility of your instance." }, %{ key: :public, diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 069421722..366c60c73 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -37,7 +37,7 @@ To add configuration to your config file, you can copy it from the base config. * `federating`: Enable federation with other instances. * `federation_incoming_replies_max_depth`: Max. depth of reply-to activities fetching on incoming federation, to prevent out-of-memory situations while fetching very long threads. If set to `nil`, threads of any depth will be fetched. Lower this value if you experience out-of-memory crashes. * `federation_reachability_timeout_days`: Timeout (in days) of each external federation target being unreachable prior to pausing federating to it. -* `allow_relay`: Enable Pleroma’s Relay, which makes it possible to follow a whole instance. +* `allow_relay`: Permits remote instances to subscribe to all public posts of your instance. This may increase the visibility of your instance. * `public`: Makes the client API in authenticated mode-only except for user-profiles. Useful for disabling the Local Timeline and The Whole Known Network. Note that there is a dependent setting restricting or allowing unauthenticated access to specific resources, see `restrict_unauthenticated` for more details. * `quarantined_instances`: List of ActivityPub instances where private (DMs, followers-only) activities will not be send. * `allowed_post_formats`: MIME-type list of formats allowed to be posted (transformed into HTML). From e06466a5327ca2fa3cb7abd5f130c0a8a6b6fe27 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Wed, 2 Jun 2021 12:00:45 -0500 Subject: [PATCH 256/339] Skip build, test, analysis/lint when we don't make code changes --- .gitlab-ci.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 78e715d47..8b2f11153 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -37,6 +37,11 @@ after_script: build: stage: build + only: + changes: + - "**/*.ex" + - "**/*.exs" + - "mix.lock" script: - mix compile --force @@ -64,6 +69,11 @@ benchmark: unit-testing: stage: test + only: + changes: + - "**/*.ex" + - "**/*.exs" + - "mix.lock" retry: 2 cache: &testing_cache_policy <<: *global_cache_policy @@ -97,6 +107,11 @@ unit-testing: unit-testing-rum: stage: test + only: + changes: + - "**/*.ex" + - "**/*.exs" + - "mix.lock" retry: 2 cache: *testing_cache_policy services: @@ -115,12 +130,22 @@ unit-testing-rum: lint: stage: test + only: + changes: + - "**/*.ex" + - "**/*.exs" + - "mix.lock" cache: *testing_cache_policy script: - mix format --check-formatted analysis: stage: test + only: + changes: + - "**/*.ex" + - "**/*.exs" + - "mix.lock" cache: *testing_cache_policy script: - mix credo --strict --only=warnings,todo,fixme,consistency,readability From 9f391da73d496cfd381b1bd55070512e5c462a0a Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Wed, 2 Jun 2021 12:09:41 -0500 Subject: [PATCH 257/339] Don't generate new specs unless they've changed. --- .gitlab-ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8b2f11153..b155c81bd 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -47,6 +47,10 @@ build: spec-build: stage: test + only: + changes: + - "lib/pleroma/web/api_spec/**/*.ex" + - "lib/pleroma/web/api_spec.ex" artifacts: paths: - spec.json From 59af07f14908808d8e016f03854bcfd1eb8c8f0a Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Sat, 15 May 2021 12:40:37 +0200 Subject: [PATCH 258/339] Update all dependencies --- mix.lock | 78 +++++++++++++++++++++++++++++--------------------------- 1 file changed, 40 insertions(+), 38 deletions(-) diff --git a/mix.lock b/mix.lock index 55f73ad00..0b53c7aaf 100644 --- a/mix.lock +++ b/mix.lock @@ -1,30 +1,30 @@ %{ "accept": {:hex, :accept, "0.3.5", "b33b127abca7cc948bbe6caa4c263369abf1347cfa9d8e699c6d214660f10cd1", [:rebar3], [], "hexpm", "11b18c220bcc2eab63b5470c038ef10eb6783bcb1fcdb11aa4137defa5ac1bb8"}, - "base62": {:hex, :base62, "1.2.1", "4866763e08555a7b3917064e9eef9194c41667276c51b59de2bc42c6ea65f806", [:mix], [{:custom_base, "~> 0.2.1", [hex: :custom_base, repo: "hexpm", optional: false]}], "hexpm", "3b29948de2013d3f93aa898c884a9dff847e7aec75d9d6d8c1dc4c61c2716c42"}, + "base62": {:hex, :base62, "1.2.2", "85c6627eb609317b70f555294045895ffaaeb1758666ab9ef9ca38865b11e629", [:mix], [{:custom_base, "~> 0.2.1", [hex: :custom_base, repo: "hexpm", optional: false]}], "hexpm", "d41336bda8eaa5be197f1e4592400513ee60518e5b9f4dcf38f4b4dae6f377bb"}, "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"}, "bbcode": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/bbcode.git", "f2d267675e9a7e1ad1ea9beb4cc23382762b66c2", [ref: "v0.2.0"]}, "bbcode_pleroma": {:hex, :bbcode_pleroma, "0.2.0", "d36f5bca6e2f62261c45be30fa9b92725c0655ad45c99025cb1c3e28e25803ef", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "19851074419a5fedb4ef49e1f01b30df504bb5dbb6d6adfc135238063bebd1c3"}, - "bcrypt_elixir": {:hex, :bcrypt_elixir, "2.2.0", "3df902b81ce7fa8867a2ae30d20a1da6877a2c056bfb116fd0bc8a5f0190cea4", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "762be3fcb779f08207531bc6612cca480a338e4b4357abb49f5ce00240a77d1e"}, + "bcrypt_elixir": {:hex, :bcrypt_elixir, "2.3.0", "6cb662d5c1b0a8858801cf20997bd006e7016aa8c52959c9ef80e0f34fb60b7a", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2c81d61d4f6ed0e5cf7bf27a9109b791ff216a1034b3d541327484f46dd43769"}, "benchee": {:hex, :benchee, "1.0.1", "66b211f9bfd84bd97e6d1beaddf8fc2312aaabe192f776e8931cb0c16f53a521", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm", "3ad58ae787e9c7c94dd7ceda3b587ec2c64604563e049b2a0e8baafae832addb"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, - "cachex": {:hex, :cachex, "3.2.0", "a596476c781b0646e6cb5cd9751af2e2974c3e0d5498a8cab71807618b74fe2f", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "aef93694067a43697ae0531727e097754a9e992a1e7946296f5969d6dd9ac986"}, + "cachex": {:hex, :cachex, "3.3.0", "6f2ebb8f27491fe39121bd207c78badc499214d76c695658b19d6079beeca5c2", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "d90e5ee1dde14cef33f6b187af4335b88748b72b30c038969176cd4e6ccc31a1"}, "calendar": {:hex, :calendar, "1.0.0", "f52073a708528482ec33d0a171954ca610fe2bd28f1e871f247dc7f1565fa807", [:mix], [{:tzdata, "~> 0.5.20 or ~> 0.1.201603 or ~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "990e9581920c82912a5ee50e62ff5ef96da6b15949a2ee4734f935fdef0f0a6f"}, "captcha": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/elixir-captcha.git", "e0f16822d578866e186a0974d65ad58cddc1e2ab", [ref: "e0f16822d578866e186a0974d65ad58cddc1e2ab"]}, - "castore": {:hex, :castore, "0.1.7", "1ca19eee705cde48c9e809e37fdd0730510752cc397745e550f6065a56a701e9", [:mix], [], "hexpm", "a2ae2c13d40e9c308387f1aceb14786dca019ebc2a11484fb2a9f797ea0aa0d8"}, + "castore": {:hex, :castore, "0.1.10", "b01a007416a0ae4188e70b3b306236021b16c11474038ead7aff79dd75538c23", [:mix], [], "hexpm", "a48314e0cb45682db2ea27b8ebfa11bd6fa0a6e21a65e5772ad83ca136ff2665"}, "certifi": {:git, "https://github.com/certifi/erlang-certifi", "e08b12e8993502240c25b78563993776f87ecd2a", [tag: "2.5.1"]}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, - "comeonin": {:hex, :comeonin, "5.3.1", "7fe612b739c78c9c1a75186ef2d322ce4d25032d119823269d0aa1e2f1e20025", [:mix], [], "hexpm", "d6222483060c17f0977fad1b7401ef0c5863c985a64352755f366aee3799c245"}, + "comeonin": {:hex, :comeonin, "5.3.2", "5c2f893d05c56ae3f5e24c1b983c2d5dfb88c6d979c9287a76a7feb1e1d8d646", [:mix], [], "hexpm", "d0993402844c49539aeadb3fe46a3c9bd190f1ecf86b6f9ebd71957534c95f04"}, "concurrent_limiter": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/concurrent_limiter.git", "d81be41024569330f296fc472e24198d7499ba78", [ref: "d81be41024569330f296fc472e24198d7499ba78"]}, "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, - "cors_plug": {:hex, :cors_plug, "2.0.2", "2b46083af45e4bc79632bd951550509395935d3e7973275b2b743bd63cc942ce", [:mix], [{:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f0d0e13f71c51fd4ef8b2c7e051388e4dfb267522a83a22392c856de7e46465f"}, - "cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"}, - "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.0", "69fdb5cf92df6373e15675eb4018cf629f5d8e35e74841bb637d6596cb797bbc", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "42868c229d9a2900a1501c5d0355bfd46e24c862c322b0b4f5a6f14fe0216753"}, - "cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"}, - "credo": {:hex, :credo, "1.4.1", "16392f1edd2cdb1de9fe4004f5ab0ae612c92e230433968eab00aafd976282fc", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "155f8a2989ad77504de5d8291fa0d41320fdcaa6a1030472e9967f285f8c7692"}, + "cors_plug": {:hex, :cors_plug, "2.0.3", "316f806d10316e6d10f09473f19052d20ba0a0ce2a1d910ddf57d663dac402ae", [:mix], [{:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ee4ae1418e6ce117fc42c2ba3e6cbdca4e95ecd2fe59a05ec6884ca16d469aea"}, + "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.1", "ebd1a1d7aff97f27c66654e78ece187abdc646992714164380d8a041eda16754", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a6efd3366130eab84ca372cbd4a7d3c3a97bdfcfb4911233b035d117063f0af"}, + "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, + "credo": {:hex, :credo, "1.5.5", "e8f422026f553bc3bebb81c8e8bf1932f498ca03339856c7fec63d3faac8424b", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "dd8623ab7091956a855dc9f3062486add9c52d310dfd62748779c4315d8247de"}, "crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, "crypt": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/crypt.git", "cf2aa3f11632e8b0634810a15b3e612c7526f6a3", [ref: "cf2aa3f11632e8b0634810a15b3e612c7526f6a3"]}, "custom_base": {:hex, :custom_base, "0.2.1", "4a832a42ea0552299d81652aa0b1f775d462175293e99dfbe4d7dbaab785a706", [:mix], [], "hexpm", "8df019facc5ec9603e94f7270f1ac73ddf339f56ade76a721eaa57c1493ba463"}, - "db_connection": {:hex, :db_connection, "2.3.1", "4c9f3ed1ef37471cbdd2762d6655be11e38193904d9c5c1c9389f1b891a3088e", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "abaab61780dde30301d840417890bd9f74131041afd02174cf4e10635b3a63f5"}, + "db_connection": {:hex, :db_connection, "2.4.0", "d04b1b73795dae60cead94189f1b8a51cc9e1f911c234cc23074017c43c031e5", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad416c21ad9f61b3103d254a71b63696ecadb6a917b36f563921e0de00d7d7c8"}, "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "earmark": {:hex, :earmark, "1.4.15", "2c7f924bf495ec1f65bd144b355d0949a05a254d0ec561740308a54946a67888", [:mix], [{:earmark_parser, ">= 1.4.13", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "3b1209b85bc9f3586f370f7c363f6533788fb4e51db23aa79565875e7f9999ee"}, @@ -37,50 +37,52 @@ "eimp": {:hex, :eimp, "1.0.14", "fc297f0c7e2700457a95a60c7010a5f1dcb768a083b6d53f49cd94ab95a28f22", [:rebar3], [{:p1_utils, "1.0.18", [hex: :p1_utils, repo: "hexpm", optional: false]}], "hexpm", "501133f3112079b92d9e22da8b88bf4f0e13d4d67ae9c15c42c30bd25ceb83b6"}, "elixir_make": {:hex, :elixir_make, "0.6.2", "7dffacd77dec4c37b39af867cedaabb0b59f6a871f89722c25b28fcd4bd70530", [:mix], [], "hexpm", "03e49eadda22526a7e5279d53321d1cced6552f344ba4e03e619063de75348d9"}, "esshd": {:hex, :esshd, "0.1.1", "d4dd4c46698093a40a56afecce8a46e246eb35463c457c246dacba2e056f31b5", [:mix], [], "hexpm", "d73e341e3009d390aa36387dc8862860bf9f874c94d9fd92ade2926376f49981"}, - "eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm", "b14f1dc204321429479c569cfbe8fb287541184ed040956c8862cb7a677b8406"}, + "eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"}, "ex2ms": {:hex, :ex2ms, "1.5.0", "19e27f9212be9a96093fed8cdfbef0a2b56c21237196d26760f11dfcfae58e97", [:mix], [], "hexpm"}, - "ex_aws": {:hex, :ex_aws, "2.1.6", "41ab8b4caa48035c96d07faa035d2d9de6df480e7e084c054e662ac888dcd4d4", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8", [hex: :jsx, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "a541bd042c1ee26412bb1e749ddf2a1c327e4fb7e382b1cd227e1b00eed3d469"}, - "ex_aws_s3": {:hex, :ex_aws_s3, "2.0.2", "c0258bbdfea55de4f98f0b2f0ca61fe402cc696f573815134beb1866e778f47b", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "0569f5b211b1a3b12b705fe2a9d0e237eb1360b9d76298028df2346cad13097a"}, + "ex_aws": {:hex, :ex_aws, "2.1.9", "dc4865ecc20a05190a34a0ac5213e3e5e2b0a75a0c2835e923ae7bfeac5e3c31", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "3e6c776703c9076001fbe1f7c049535f042cb2afa0d2cbd3b47cbc4e92ac0d10"}, + "ex_aws_s3": {:hex, :ex_aws_s3, "2.2.0", "07a09de557070320e264893c0acc8a1d2e7ddf80155736e0aed966486d1988e6", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "15175c613371e29e1f88b78ec8a4327389ca1ec5b34489744b175727496b21bd"}, "ex_const": {:hex, :ex_const, "0.2.4", "d06e540c9d834865b012a17407761455efa71d0ce91e5831e86881b9c9d82448", [:mix], [], "hexpm", "96fd346610cc992b8f896ed26a98be82ac4efb065a0578f334a32d60a3ba9767"}, - "ex_doc": {:hex, :ex_doc, "0.22.2", "03a2a58bdd2ba0d83d004507c4ee113b9c521956938298eba16e55cc4aba4a6c", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "cf60e1b3e2efe317095b6bb79651f83a2c1b3edcb4d319c421d7fcda8b3aff26"}, - "ex_machina": {:hex, :ex_machina, "2.4.0", "09a34c5d371bfb5f78399029194a8ff67aff340ebe8ba19040181af35315eabb", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "a20bc9ddc721b33ea913b93666c5d0bdca5cbad7a67540784ae277228832d72c"}, + "ex_doc": {:hex, :ex_doc, "0.24.2", "e4c26603830c1a2286dae45f4412a4d1980e1e89dc779fcd0181ed1d5a05c8d9", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "e134e1d9e821b8d9e4244687fb2ace58d479b67b282de5158333b0d57c6fb7da"}, + "ex_machina": {:hex, :ex_machina, "2.7.0", "b792cc3127fd0680fecdb6299235b4727a4944a09ff0fa904cc639272cd92dc7", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "419aa7a39bde11894c87a615c4ecaa52d8f107bbdd81d810465186f783245bf8"}, "ex_syslogger": {:hex, :ex_syslogger, "1.5.2", "72b6aa2d47a236e999171f2e1ec18698740f40af0bd02c8c650bf5f1fd1bac79", [:mix], [{:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:syslog, "~> 1.1.0", [hex: :syslog, repo: "hexpm", optional: false]}], "hexpm", "ab9fab4136dbc62651ec6f16fa4842f10cf02ab4433fa3d0976c01be99398399"}, "excoveralls": {:hex, :excoveralls, "0.12.3", "2142be7cb978a3ae78385487edda6d1aff0e482ffc6123877bb7270a8ffbcfe0", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "568a3e616c264283f5dea5b020783ae40eef3f7ee2163f7a67cbd7b35bcadada"}, "fast_html": {:hex, :fast_html, "2.0.4", "4910ee49f2f6b19692e3bf30bf97f1b6b7dac489cd6b0f34cd0fe3042c56ba30", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}], "hexpm", "3bb49d541dfc02ad5e425904f53376d758c09f89e521afc7d2b174b3227761ea"}, "fast_sanitize": {:hex, :fast_sanitize, "0.2.2", "3cbbaebaea6043865dfb5b4ecb0f1af066ad410a51470e353714b10c42007b81", [:mix], [{:fast_html, "~> 2.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "69f204db9250afa94a0d559d9110139850f57de2b081719fbafa1e9a89e94466"}, + "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "flake_id": {:hex, :flake_id, "0.1.0", "7716b086d2e405d09b647121a166498a0d93d1a623bead243e1f74216079ccb3", [:mix], [{:base62, "~> 1.2", [hex: :base62, repo: "hexpm", optional: false]}, {:ecto, ">= 2.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "31fc8090fde1acd267c07c36ea7365b8604055f897d3a53dd967658c691bd827"}, - "floki": {:hex, :floki, "0.27.0", "6b29a14283f1e2e8fad824bc930eaa9477c462022075df6bea8f0ad811c13599", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "583b8c13697c37179f1f82443bcc7ad2f76fbc0bf4c186606eebd658f7f2631b"}, + "floki": {:hex, :floki, "0.30.1", "75d35526d3a1459920b6e87fdbc2e0b8a3670f965dd0903708d2b267e0904c55", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "e9c03524447d1c4cbfccd672d739b8c18453eee377846b119d4fd71b1a176bb8"}, "gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm", "29bd14a88030980849c7ed2447b8db6d6c9278a28b11a44cafe41b791205440f"}, "gen_stage": {:hex, :gen_stage, "0.14.3", "d0c66f1c87faa301c1a85a809a3ee9097a4264b2edf7644bf5c123237ef732bf", [:mix], [], "hexpm"}, "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"}, "gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"}, "gun": {:git, "https://github.com/ninenines/gun.git", "921c47146b2d9567eac7e9a4d2ccc60fffd4f327", [ref: "921c47146b2d9567eac7e9a4d2ccc60fffd4f327"]}, "hackney": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/hackney.git", "7d7119f0651515d6d7669c78393fd90950a3ec6e", [ref: "7d7119f0651515d6d7669c78393fd90950a3ec6e"]}, - "html_entities": {:hex, :html_entities, "0.5.1", "1c9715058b42c35a2ab65edc5b36d0ea66dd083767bef6e3edb57870ef556549", [:mix], [], "hexpm", "30efab070904eb897ff05cd52fa61c1025d7f8ef3a9ca250bc4e6513d16c32de"}, + "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.3.0", "f005ad692b717691203f940c686208aa3d8ffd9dd4bb3699240096a51fa9564e", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"}, "http_signatures": {:hex, :http_signatures, "0.1.0", "4e4b501a936dbf4cb5222597038a89ea10781776770d2e185849fa829686b34c", [:mix], [], "hexpm", "f8a7b3731e3fd17d38fa6e343fcad7b03d6874a3b0a108c8568a71ed9c2cf824"}, - "httpoison": {:hex, :httpoison, "1.6.2", "ace7c8d3a361cebccbed19c283c349b3d26991eff73a1eaaa8abae2e3c8089b6", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "aa2c74bd271af34239a3948779612f87df2422c2fdcfdbcec28d9c105f0773fe"}, + "httpoison": {:hex, :httpoison, "1.8.0", "6b85dea15820b7804ef607ff78406ab449dd78bed923a49c7160e1886e987a3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "28089eaa98cf90c66265b6b5ad87c59a3729bea2e74e9d08f9b51eb9729b3c3a"}, "idna": {:git, "https://github.com/benoitc/erlang-idna", "6cff72747821110169ecfac871b0c69e5064afff", [tag: "6.0.0"]}, "inet_cidr": {:hex, :inet_cidr, "1.0.4", "a05744ab7c221ca8e395c926c3919a821eb512e8f36547c062f62c4ca0cf3d6e", [:mix], [], "hexpm", "64a2d30189704ae41ca7dbdd587f5291db5d1dda1414e0774c29ffc81088c1bc"}, "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, - "joken": {:hex, :joken, "2.2.0", "2daa1b12be05184aff7b5ace1d43ca1f81345962285fff3f88db74927c954d3a", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "b4f92e30388206f869dd25d1af628a1d99d7586e5cf0672f64d4df84c4d2f5e9"}, - "jose": {:hex, :jose, "1.10.1", "16d8e460dae7203c6d1efa3f277e25b5af8b659febfc2f2eb4bacf87f128b80a", [:mix, :rebar3], [], "hexpm", "3c7ddc8a9394b92891db7c2771da94bf819834a1a4c92e30857b7d582e2f8257"}, + "joken": {:hex, :joken, "2.3.0", "62a979c46f2c81dcb8ddc9150453b60d3757d1ac393c72bb20fc50a7b0827dc6", [:mix], [{:jose, "~> 1.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "57b263a79c0ec5d536ac02d569c01e6b4de91bd1cb825625fe90eab4feb7bc1e"}, + "jose": {:hex, :jose, "1.11.1", "59da64010c69aad6cde2f5b9248b896b84472e99bd18f246085b7b9fe435dcdb", [:mix, :rebar3], [], "hexpm", "078f6c9fb3cd2f4cfafc972c814261a7d1e8d2b3685c0a76eb87e158efff1ac5"}, "jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"}, "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"}, "linkify": {:hex, :linkify, "0.5.0", "e0ea8de73ff44742d6a889721221f4c4eccaad5284957ee9832ffeb347602d54", [:mix], [], "hexpm", "4ccd958350aee7c51c89e21f05b15d30596ebbba707e051d21766be1809df2d7"}, "majic": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/majic.git", "289cda1b6d0d70ccb2ba508a2b0bd24638db2880", [ref: "289cda1b6d0d70ccb2ba508a2b0bd24638db2880"]}, - "makeup": {:hex, :makeup, "1.0.3", "e339e2f766d12e7260e6672dd4047405963c5ec99661abdc432e6ec67d29ef95", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "2e9b4996d11832947731f7608fed7ad2f9443011b3b479ae288011265cdd3dad"}, + "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, "makeup_elixir": {:hex, :makeup_elixir, "0.14.1", "4f0e96847c63c17841d42c08107405a005a2680eb9c7ccadfd757bd31dabccfb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f2438b1a80eaec9ede832b5c41cd4f373b38fd7aa33e3b22d9db79e640cbde11"}, + "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"}, "metrics": {:git, "https://github.com/benoitc/erlang-metrics", "c6eb4dcf29f9e907539915e2ab996f40c2ec7e8e", [tag: "1.0.1"]}, - "mime": {:hex, :mime, "1.4.0", "5066f14944b470286146047d2f73518cf5cca82f8e4815cf35d196b58cf07c47", [:mix], [], "hexpm", "75fa42c4228ea9a23f70f123c74ba7cece6a03b1fd474fe13f6a7a85c6ea4ff6"}, + "mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"}, "mimerl": {:git, "https://github.com/benoitc/mimerl", "5a1b22a8fada5b3b40438da00a6923cb87a42bbc", [tag: "1.2.0"]}, "mochiweb": {:hex, :mochiweb, "2.18.0", "eb55f1db3e6e960fac4e6db4e2db9ec3602cc9f30b86cd1481d56545c3145d2e", [:rebar3], [], "hexpm"}, - "mock": {:hex, :mock, "0.3.5", "feb81f52b8dcf0a0d65001d2fec459f6b6a8c22562d94a965862f6cc066b5431", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "6fae404799408300f863550392635d8f7e3da6b71abdd5c393faf41b131c8728"}, + "mock": {:hex, :mock, "0.3.6", "e810a91fabc7adf63ab5fdbec5d9d3b492413b8cda5131a2a8aa34b4185eb9b4", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "bcf1d0a6826fb5aee01bae3d74474669a3fa8b2df274d094af54a25266a1ebd2"}, "mogrify": {:hex, :mogrify, "0.7.4", "9b2496dde44b1ce12676f85d7dc531900939e6367bc537c7243a1b089435b32d", [:mix], [], "hexpm", "50d79e337fba6bc95bfbef918058c90f50b17eed9537771e61d4619488f099c3"}, "mox": {:hex, :mox, "1.0.0", "4b3c7005173f47ff30641ba044eb0fe67287743eec9bd9545e37f3002b0a9f8b", [:mix], [], "hexpm", "201b0a20b7abdaaab083e9cf97884950f8a30a1350a1da403b3145e213c6f4df"}, "myhtmlex": {:git, "https://git.pleroma.social/pleroma/myhtmlex.git", "ad0097e2f61d4953bfef20fb6abddf23b87111e6", [ref: "ad0097e2f61d4953bfef20fb6abddf23b87111e6", submodules: true]}, - "nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"}, + "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm", "5c040b8469c1ff1b10093d3186e2e10dbe483cd73d79ec017993fb3985b8a9b3"}, "nimble_pool": {:hex, :nimble_pool, "0.1.0", "ffa9d5be27eee2b00b0c634eb649aa27f97b39186fec3c493716c2a33e784ec6", [:mix], [], "hexpm", "343a1eaa620ddcf3430a83f39f2af499fe2370390d4f785cd475b4df5acaf3f9"}, "nodex": {:git, "https://git.pleroma.social/pleroma/nodex", "cb6730f943cfc6aad674c92161be23a8411f15d1", [ref: "cb6730f943cfc6aad674c92161be23a8411f15d1"]}, "oban": {:hex, :oban, "2.3.4", "ec7509b9af2524d55f529cb7aee93d36131ae0bf0f37706f65d2fe707f4d9fd8", [:mix], [{:ecto_sql, ">= 3.4.3", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c70ca0434758fd1805422ea4446af5e910ddc697c0c861549c8f0eb0cfbd2fdf"}, @@ -88,37 +90,37 @@ "p1_utils": {:hex, :p1_utils, "1.0.18", "3fe224de5b2e190d730a3c5da9d6e8540c96484cf4b4692921d1e28f0c32b01c", [:rebar3], [], "hexpm", "1fc8773a71a15553b179c986b22fbeead19b28fe486c332d4929700ffeb71f88"}, "parse_trans": {:git, "https://github.com/uwiger/parse_trans.git", "76abb347c3c1d00fb0ccf9e4b43e22b3d2288484", [tag: "3.3.0"]}, "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "1.2.1", "9cbe354b58121075bd20eb83076900a3832324b7dd171a6895fab57b6bb2752c", [:mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}], "hexpm", "d3b40a4a4630f0b442f19eca891fcfeeee4c40871936fed2f68e1c4faa30481f"}, - "phoenix": {:hex, :phoenix, "1.5.6", "8298cdb4e0f943242ba8410780a6a69cbbe972fef199b341a36898dd751bdd66", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0dc4d39af1306b6aa5122729b0a95ca779e42c708c6fe7abbb3d336d5379e956"}, + "phoenix": {:hex, :phoenix, "1.5.9", "a6368d36cfd59d917b37c44386e01315bc89f7609a10a45a22f47c007edf2597", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7e4bce20a67c012f1fbb0af90e5da49fa7bf0d34e3a067795703b74aef75427d"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.2.1", "13f124cf0a3ce0f1948cf24654c7b9f2347169ff75c1123f44674afee6af3b03", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "478a1bae899cac0a6e02be1deec7e2944b7754c04e7d4107fc5a517f877743c0"}, - "phoenix_html": {:hex, :phoenix_html, "2.14.2", "b8a3899a72050f3f48a36430da507dd99caf0ac2d06c77529b1646964f3d563e", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "58061c8dfd25da5df1ea0ca47c972f161beb6c875cd293917045b92ffe1bf617"}, + "phoenix_html": {:hex, :phoenix_html, "2.14.3", "51f720d0d543e4e157ff06b65de38e13303d5778a7919bcc696599e5934271b8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "efd697a7fff35a13eeeb6b43db884705cba353a1a41d127d118fda5f90c8e80f"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, - "phoenix_swoosh": {:hex, :phoenix_swoosh, "0.3.2", "43d3518349a22b8b1910ea28b4dd5119926d5017b3187db3fbd1a1e05769a851", [:mix], [{:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.0", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "3e2ac4e883db7af0702d75ba00c19901760e8342b91f8f66e13941de552e777f"}, - "plug": {:hex, :plug, "1.10.4", "41eba7d1a2d671faaf531fa867645bd5a3dce0957d8e2a3f398ccff7d2ef017f", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad1e233fe73d2eec56616568d260777b67f53148a999dc2d048f4eb9778fe4a0"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.4.0", "e936ef151751f386804c51f87f7300f5aaae6893cdad726559c3930c6c032948", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e25ddcfc06b1b76e55af79d078b03cbc86bbcb99ce4e5e0a5e4a8114ee039be6"}, - "plug_crypto": {:hex, :plug_crypto, "1.2.0", "1cb20793aa63a6c619dd18bb33d7a3aa94818e5fd39ad357051a67f26dfa2df6", [:mix], [], "hexpm", "a48b538ae8bf381ffac344520755f3007cc10bd8e90b240af98ea29b69683fc2"}, + "phoenix_swoosh": {:hex, :phoenix_swoosh, "0.3.3", "039435dd975f7e55953525b88f1d596f26c6141412584c16f4db109708a8ee68", [:mix], [{:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.0", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "4a540cea32e05356541737033d666ee7fea7700eb2101bf76783adbfe06601cd"}, + "plug": {:hex, :plug, "1.11.1", "f2992bac66fdae679453c9e86134a4201f6f43a687d8ff1cd1b2862d53c80259", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "23524e4fefbb587c11f0833b3910bfb414bf2e2534d61928e920f54e3a1b881f"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.5.0", "51c998f788c4e68fc9f947a5eba8c215fbb1d63a520f7604134cab0270ea6513", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5b2c8925a5e2587446f33810a58c01e66b3c345652eeec809b76ba007acde71a"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, "plug_static_index_html": {:hex, :plug_static_index_html, "1.0.0", "840123d4d3975585133485ea86af73cb2600afd7f2a976f9f5fd8b3808e636a0", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "79fd4fcf34d110605c26560cbae8f23c603ec4158c08298bd4360fdea90bb5cf"}, "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, - "postgrex": {:hex, :postgrex, "0.15.7", "724410acd48abac529d0faa6c2a379fb8ae2088e31247687b16cacc0e0883372", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "88310c010ff047cecd73d5ceca1d99205e4b1ab1b9abfdab7e00f5c9d20ef8f9"}, + "postgrex": {:hex, :postgrex, "0.15.9", "46f8fe6f25711aeb861c4d0ae09780facfdf3adbd2fb5594ead61504dd489bda", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "610719103e4cb2223d4ab78f9f0f3e720320eeca6011415ab4137ddef730adee"}, "pot": {:hex, :pot, "0.11.0", "61bad869a94534739dd4614a25a619bc5c47b9970e9a0ea5bef4628036fc7a16", [:rebar3], [], "hexpm", "57ee6ee6bdeb639661ffafb9acefe3c8f966e45394de6a766813bb9e1be4e54b"}, - "prometheus": {:hex, :prometheus, "4.6.0", "20510f381db1ccab818b4cf2fac5fa6ab5cc91bc364a154399901c001465f46f", [:mix, :rebar3], [], "hexpm", "4905fd2992f8038eccd7aa0cd22f40637ed618c0bed1f75c05aacec15b7545de"}, + "prometheus": {:hex, :prometheus, "4.8.0", "1ce1e1002b173c336d61f186b56263346536e76814edd9a142e12aeb2d6c1ad2", [:mix, :rebar3], [], "hexpm", "0fc2e17103073edb3758a46a5d44b006191bf25b73cbaa2b779109de396afcb5"}, "prometheus_ecto": {:hex, :prometheus_ecto, "1.4.3", "3dd4da1812b8e0dbee81ea58bb3b62ed7588f2eae0c9e97e434c46807ff82311", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "8d66289f77f913b37eda81fd287340c17e61a447549deb28efc254532b2bed82"}, "prometheus_ex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/prometheus.ex.git", "a4e9beb3c1c479d14b352fd9d6dd7b1f6d7deee5", [ref: "a4e9beb3c1c479d14b352fd9d6dd7b1f6d7deee5"]}, "prometheus_phoenix": {:hex, :prometheus_phoenix, "1.3.0", "c4b527e0b3a9ef1af26bdcfbfad3998f37795b9185d475ca610fe4388fdd3bb5", [:mix], [{:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.3 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "c4d1404ac4e9d3d963da601db2a7d8ea31194f0017057fabf0cfb9bf5a6c8c75"}, "prometheus_phx": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/prometheus-phx.git", "9cd8f248c9381ffedc799905050abce194a97514", [branch: "no-logging"]}, "prometheus_plugs": {:hex, :prometheus_plugs, "1.1.5", "25933d48f8af3a5941dd7b621c889749894d8a1082a6ff7c67cc99dec26377c5", [:mix], [{:accept, "~> 0.1", [hex: :accept, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}, {:prometheus_process_collector, "~> 1.1", [hex: :prometheus_process_collector, repo: "hexpm", optional: true]}], "hexpm", "0273a6483ccb936d79ca19b0ab629aef0dba958697c94782bb728b920dfc6a79"}, "quack": {:hex, :quack, "0.1.1", "cca7b4da1a233757fdb44b3334fce80c94785b3ad5a602053b7a002b5a8967bf", [:mix], [{:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: false]}, {:tesla, "~> 1.2.0", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "d736bfa7444112eb840027bb887832a0e403a4a3437f48028c3b29a2dbbd2543"}, - "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, + "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "recon": {:hex, :recon, "2.5.1", "430ffa60685ac1efdfb1fe4c97b8767c92d0d92e6e7c3e8621559ba77598678a", [:mix, :rebar3], [], "hexpm", "5721c6b6d50122d8f68cccac712caa1231f97894bab779eff5ff0f886cb44648"}, "remote_ip": {:git, "https://git.pleroma.social/pleroma/remote_ip.git", "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8", [ref: "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8"]}, "sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm", "84ee37aeff4d0d92b290fff986d6a95ac5eedf9b383fadfd1d88e9b84a1c02e1"}, "ssl_verify_fun": {:git, "https://github.com/deadtrickster/ssl_verify_fun.erl", "c5718226b0b9f3d1a38ef6ca3c3b4c75f53dda92", [tag: "1.1.4"]}, "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm", "2e1ec458f892ffa81f9f8386e3f35a1af6db7a7a37748a64478f13163a1f3573"}, - "swoosh": {:hex, :swoosh, "1.0.6", "6765e334c67dacabe721f0d701c7e5a6f06e4595c90df6f91e73ebd54d555833", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "7c50ef78e4acfd1cbd4907dc1fa87b5540675a6be9dc979d04890f49d7ec1830"}, + "swoosh": {:hex, :swoosh, "1.3.8", "026d95852f21b20ac255a7f8ee2bf62f49ddccbd0ef00c96e10e117c4dc19c5a", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "c76137424e285e1bb66354ef1d983d0ef55ce9676c1ded208a941b3b33897b78"}, "syslog": {:hex, :syslog, "1.1.0", "6419a232bea84f07b56dc575225007ffe34d9fdc91abe6f1b2f254fd71d8efc2", [:rebar3], [], "hexpm", "4c6a41373c7e20587be33ef841d3de6f3beba08519809329ecc4d27b15b659e1"}, - "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, - "tesla": {:hex, :tesla, "1.4.0", "1081bef0124b8bdec1c3d330bbe91956648fb008cf0d3950a369cda466a31a87", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "bf1374a5569f5fca8e641363b63f7347d680d91388880979a33bc12a6eb3e0aa"}, - "timex": {:hex, :timex, "3.7.3", "df8a2ea814749d700d6878ab9eacac9fdb498ecee2f507cb0002ec172bc24d0f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8691c1d86ca3a7bc14a156e2199dc8927be95d1a8f0e3b69e4bb2d6262c53ac6"}, + "telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"}, + "tesla": {:hex, :tesla, "1.4.1", "ff855f1cac121e0d16281b49e8f066c4a0d89965f98864515713878cca849ac8", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "95f5de35922c8c4b3945bee7406f66eb680b0955232f78f5fb7e853aa1ce201a"}, + "timex": {:hex, :timex, "3.7.5", "3eca56e23bfa4e0848f0b0a29a92fa20af251a975116c6d504966e8a90516dfd", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "a15608dca680f2ef663d71c95842c67f0af08a0f3b1d00e17bbd22872e2874e4"}, "trailing_format_plug": {:hex, :trailing_format_plug, "0.0.7", "64b877f912cf7273bed03379936df39894149e35137ac9509117e59866e10e45", [:mix], [{:plug, "> 0.12.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bd4fde4c15f3e993a999e019d64347489b91b7a9096af68b2bdadd192afa693f"}, "tzdata": {:hex, :tzdata, "1.0.5", "69f1ee029a49afa04ad77801febaf69385f3d3e3d1e4b56b9469025677b89a28", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "55519aa2a99e5d2095c1e61cc74c9be69688f8ab75c27da724eb8279ff402a5a"}, "ueberauth": {:hex, :ueberauth, "0.6.3", "d42ace28b870e8072cf30e32e385579c57b9cc96ec74fa1f30f30da9c14f3cc0", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "afc293d8a1140d6591b53e3eaf415ca92842cb1d32fad3c450c6f045f7f91b60"}, From 166455c88441b22455d996ed528ed4804514a3c0 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Sat, 15 May 2021 13:08:00 +0200 Subject: [PATCH 259/339] mix: Switch hackney & gun to releases --- mix.exs | 8 ++------ mix.lock | 18 +++++++++--------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/mix.exs b/mix.exs index a4bacba8e..4ba698620 100644 --- a/mix.exs +++ b/mix.exs @@ -137,8 +137,7 @@ defp deps do {:tesla, "~> 1.4.0", override: true}, {:castore, "~> 0.1"}, {:cowlib, "~> 2.9", override: true}, - {:gun, - github: "ninenines/gun", ref: "921c47146b2d9567eac7e9a4d2ccc60fffd4f327", override: true}, + {:gun, "~> 2.0.0-rc.1", override: true}, {:jason, "~> 1.2"}, {:mogrify, "~> 0.7.4"}, {:ex_aws, "~> 2.1.6"}, @@ -208,10 +207,7 @@ defp deps do {:mock, "~> 0.3.5", only: :test}, # temporary downgrade for excoveralls, hackney until hackney max_connections bug will be fixed {:excoveralls, "0.12.3", only: :test}, - {:hackney, - git: "https://git.pleroma.social/pleroma/elixir-libraries/hackney.git", - ref: "7d7119f0651515d6d7669c78393fd90950a3ec6e", - override: true}, + {:hackney, "~> 1.17.0", override: true}, {:mox, "~> 1.0", only: :test}, {:websocket_client, git: "https://github.com/jeremyong/websocket_client.git", only: :test} ] ++ oauth_deps() diff --git a/mix.lock b/mix.lock index 0b53c7aaf..b838d6f80 100644 --- a/mix.lock +++ b/mix.lock @@ -11,7 +11,7 @@ "calendar": {:hex, :calendar, "1.0.0", "f52073a708528482ec33d0a171954ca610fe2bd28f1e871f247dc7f1565fa807", [:mix], [{:tzdata, "~> 0.5.20 or ~> 0.1.201603 or ~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "990e9581920c82912a5ee50e62ff5ef96da6b15949a2ee4734f935fdef0f0a6f"}, "captcha": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/elixir-captcha.git", "e0f16822d578866e186a0974d65ad58cddc1e2ab", [ref: "e0f16822d578866e186a0974d65ad58cddc1e2ab"]}, "castore": {:hex, :castore, "0.1.10", "b01a007416a0ae4188e70b3b306236021b16c11474038ead7aff79dd75538c23", [:mix], [], "hexpm", "a48314e0cb45682db2ea27b8ebfa11bd6fa0a6e21a65e5772ad83ca136ff2665"}, - "certifi": {:git, "https://github.com/certifi/erlang-certifi", "e08b12e8993502240c25b78563993776f87ecd2a", [tag: "2.5.1"]}, + "certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "5.3.2", "5c2f893d05c56ae3f5e24c1b983c2d5dfb88c6d979c9287a76a7feb1e1d8d646", [:mix], [], "hexpm", "d0993402844c49539aeadb3fe46a3c9bd190f1ecf86b6f9ebd71957534c95f04"}, "concurrent_limiter": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/concurrent_limiter.git", "d81be41024569330f296fc472e24198d7499ba78", [ref: "d81be41024569330f296fc472e24198d7499ba78"]}, @@ -55,13 +55,13 @@ "gen_stage": {:hex, :gen_stage, "0.14.3", "d0c66f1c87faa301c1a85a809a3ee9097a4264b2edf7644bf5c123237ef732bf", [:mix], [], "hexpm"}, "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"}, "gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"}, - "gun": {:git, "https://github.com/ninenines/gun.git", "921c47146b2d9567eac7e9a4d2ccc60fffd4f327", [ref: "921c47146b2d9567eac7e9a4d2ccc60fffd4f327"]}, - "hackney": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/hackney.git", "7d7119f0651515d6d7669c78393fd90950a3ec6e", [ref: "7d7119f0651515d6d7669c78393fd90950a3ec6e"]}, + "gun": {:hex, :gun, "2.0.0-rc.1", "b87d81dad83f41fa3f2cbf1a923eae44c5ce559a7006728d47888c3e7eb7a6ce", [:make, :rebar3], [{:cowlib, "2.10.1", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "459e7c843c894f69878df60378e7fa4a4b5504a00066c02138d084435c2c7968"}, + "hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"}, "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.3.0", "f005ad692b717691203f940c686208aa3d8ffd9dd4bb3699240096a51fa9564e", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"}, "http_signatures": {:hex, :http_signatures, "0.1.0", "4e4b501a936dbf4cb5222597038a89ea10781776770d2e185849fa829686b34c", [:mix], [], "hexpm", "f8a7b3731e3fd17d38fa6e343fcad7b03d6874a3b0a108c8568a71ed9c2cf824"}, "httpoison": {:hex, :httpoison, "1.8.0", "6b85dea15820b7804ef607ff78406ab449dd78bed923a49c7160e1886e987a3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "28089eaa98cf90c66265b6b5ad87c59a3729bea2e74e9d08f9b51eb9729b3c3a"}, - "idna": {:git, "https://github.com/benoitc/erlang-idna", "6cff72747821110169ecfac871b0c69e5064afff", [tag: "6.0.0"]}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "inet_cidr": {:hex, :inet_cidr, "1.0.4", "a05744ab7c221ca8e395c926c3919a821eb512e8f36547c062f62c4ca0cf3d6e", [:mix], [], "hexpm", "64a2d30189704ae41ca7dbdd587f5291db5d1dda1414e0774c29ffc81088c1bc"}, "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, "joken": {:hex, :joken, "2.3.0", "62a979c46f2c81dcb8ddc9150453b60d3757d1ac393c72bb20fc50a7b0827dc6", [:mix], [{:jose, "~> 1.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "57b263a79c0ec5d536ac02d569c01e6b4de91bd1cb825625fe90eab4feb7bc1e"}, @@ -74,9 +74,9 @@ "makeup_elixir": {:hex, :makeup_elixir, "0.14.1", "4f0e96847c63c17841d42c08107405a005a2680eb9c7ccadfd757bd31dabccfb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f2438b1a80eaec9ede832b5c41cd4f373b38fd7aa33e3b22d9db79e640cbde11"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"}, - "metrics": {:git, "https://github.com/benoitc/erlang-metrics", "c6eb4dcf29f9e907539915e2ab996f40c2ec7e8e", [tag: "1.0.1"]}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"}, - "mimerl": {:git, "https://github.com/benoitc/mimerl", "5a1b22a8fada5b3b40438da00a6923cb87a42bbc", [tag: "1.2.0"]}, + "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "mochiweb": {:hex, :mochiweb, "2.18.0", "eb55f1db3e6e960fac4e6db4e2db9ec3602cc9f30b86cd1481d56545c3145d2e", [:rebar3], [], "hexpm"}, "mock": {:hex, :mock, "0.3.6", "e810a91fabc7adf63ab5fdbec5d9d3b492413b8cda5131a2a8aa34b4185eb9b4", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "bcf1d0a6826fb5aee01bae3d74474669a3fa8b2df274d094af54a25266a1ebd2"}, "mogrify": {:hex, :mogrify, "0.7.4", "9b2496dde44b1ce12676f85d7dc531900939e6367bc537c7243a1b089435b32d", [:mix], [], "hexpm", "50d79e337fba6bc95bfbef918058c90f50b17eed9537771e61d4619488f099c3"}, @@ -88,7 +88,7 @@ "oban": {:hex, :oban, "2.3.4", "ec7509b9af2524d55f529cb7aee93d36131ae0bf0f37706f65d2fe707f4d9fd8", [:mix], [{:ecto_sql, ">= 3.4.3", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c70ca0434758fd1805422ea4446af5e910ddc697c0c861549c8f0eb0cfbd2fdf"}, "open_api_spex": {:hex, :open_api_spex, "3.10.0", "94e9521ad525b3fcf6dc77da7c45f87fdac24756d4de588cb0816b413e7c1844", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.1", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "2dbb2bde3d2b821f06936e8dfaf3284331186556291946d84eeba3750ac28765"}, "p1_utils": {:hex, :p1_utils, "1.0.18", "3fe224de5b2e190d730a3c5da9d6e8540c96484cf4b4692921d1e28f0c32b01c", [:rebar3], [], "hexpm", "1fc8773a71a15553b179c986b22fbeead19b28fe486c332d4929700ffeb71f88"}, - "parse_trans": {:git, "https://github.com/uwiger/parse_trans.git", "76abb347c3c1d00fb0ccf9e4b43e22b3d2288484", [tag: "3.3.0"]}, + "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, "pbkdf2_elixir": {:hex, :pbkdf2_elixir, "1.2.1", "9cbe354b58121075bd20eb83076900a3832324b7dd171a6895fab57b6bb2752c", [:mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}], "hexpm", "d3b40a4a4630f0b442f19eca891fcfeeee4c40871936fed2f68e1c4faa30481f"}, "phoenix": {:hex, :phoenix, "1.5.9", "a6368d36cfd59d917b37c44386e01315bc89f7609a10a45a22f47c007edf2597", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7e4bce20a67c012f1fbb0af90e5da49fa7bf0d34e3a067795703b74aef75427d"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.2.1", "13f124cf0a3ce0f1948cf24654c7b9f2347169ff75c1123f44674afee6af3b03", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "478a1bae899cac0a6e02be1deec7e2944b7754c04e7d4107fc5a517f877743c0"}, @@ -114,7 +114,7 @@ "recon": {:hex, :recon, "2.5.1", "430ffa60685ac1efdfb1fe4c97b8767c92d0d92e6e7c3e8621559ba77598678a", [:mix, :rebar3], [], "hexpm", "5721c6b6d50122d8f68cccac712caa1231f97894bab779eff5ff0f886cb44648"}, "remote_ip": {:git, "https://git.pleroma.social/pleroma/remote_ip.git", "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8", [ref: "b647d0deecaa3acb140854fe4bda5b7e1dc6d1c8"]}, "sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm", "84ee37aeff4d0d92b290fff986d6a95ac5eedf9b383fadfd1d88e9b84a1c02e1"}, - "ssl_verify_fun": {:git, "https://github.com/deadtrickster/ssl_verify_fun.erl", "c5718226b0b9f3d1a38ef6ca3c3b4c75f53dda92", [tag: "1.1.4"]}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm", "2e1ec458f892ffa81f9f8386e3f35a1af6db7a7a37748a64478f13163a1f3573"}, "swoosh": {:hex, :swoosh, "1.3.8", "026d95852f21b20ac255a7f8ee2bf62f49ddccbd0ef00c96e10e117c4dc19c5a", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "c76137424e285e1bb66354ef1d983d0ef55ce9676c1ded208a941b3b33897b78"}, "syslog": {:hex, :syslog, "1.1.0", "6419a232bea84f07b56dc575225007ffe34d9fdc91abe6f1b2f254fd71d8efc2", [:rebar3], [], "hexpm", "4c6a41373c7e20587be33ef841d3de6f3beba08519809329ecc4d27b15b659e1"}, @@ -124,7 +124,7 @@ "trailing_format_plug": {:hex, :trailing_format_plug, "0.0.7", "64b877f912cf7273bed03379936df39894149e35137ac9509117e59866e10e45", [:mix], [{:plug, "> 0.12.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bd4fde4c15f3e993a999e019d64347489b91b7a9096af68b2bdadd192afa693f"}, "tzdata": {:hex, :tzdata, "1.0.5", "69f1ee029a49afa04ad77801febaf69385f3d3e3d1e4b56b9469025677b89a28", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "55519aa2a99e5d2095c1e61cc74c9be69688f8ab75c27da724eb8279ff402a5a"}, "ueberauth": {:hex, :ueberauth, "0.6.3", "d42ace28b870e8072cf30e32e385579c57b9cc96ec74fa1f30f30da9c14f3cc0", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "afc293d8a1140d6591b53e3eaf415ca92842cb1d32fad3c450c6f045f7f91b60"}, - "unicode_util_compat": {:git, "https://github.com/benoitc/unicode_util_compat.git", "38d7bc105f51159e8ea3279c40121db9db1e652f", [tag: "0.3.1"]}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm", "6c7729a2d214806450d29766abc2afaa7a2cbecf415be64f36a6691afebb50e5"}, "web_push_encryption": {:hex, :web_push_encryption, "0.3.0", "598b5135e696fd1404dc8d0d7c0fa2c027244a4e5d5e5a98ba267f14fdeaabc8", [:mix], [{:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "f10bdd1afe527ede694749fb77a2f22f146a51b054c7fa541c9fd920fba7c875"}, "websocket_client": {:git, "https://github.com/jeremyong/websocket_client.git", "9a6f65d05ebf2725d62fb19262b21f1805a59fbf", []}, From 168687eef2d289703f9ec7fc8453d4beb6adf0e7 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Sat, 15 May 2021 13:15:02 +0200 Subject: [PATCH 260/339] media_proxy: switch from :crypto.hmac to :crypto.mac --- lib/pleroma/web/media_proxy.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/media_proxy.ex b/lib/pleroma/web/media_proxy.ex index 5c32078aa..0b232f14b 100644 --- a/lib/pleroma/web/media_proxy.ex +++ b/lib/pleroma/web/media_proxy.ex @@ -127,7 +127,7 @@ def decode_url(encoded) do end defp signed_url(url) do - :crypto.hmac(:sha, Config.get([Endpoint, :secret_key_base]), url) + :crypto.mac(:hmac, :sha, Config.get([Endpoint, :secret_key_base]), url) end def filename(url_or_path) do From 2768063387fcfb310eaacf517ad6fc3521e9eee6 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Sat, 22 May 2021 17:48:35 +0200 Subject: [PATCH 261/339] mix: Update dependencies --- mix.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mix.lock b/mix.lock index b838d6f80..fa4249d43 100644 --- a/mix.lock +++ b/mix.lock @@ -55,7 +55,7 @@ "gen_stage": {:hex, :gen_stage, "0.14.3", "d0c66f1c87faa301c1a85a809a3ee9097a4264b2edf7644bf5c123237ef732bf", [:mix], [], "hexpm"}, "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"}, "gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"}, - "gun": {:hex, :gun, "2.0.0-rc.1", "b87d81dad83f41fa3f2cbf1a923eae44c5ce559a7006728d47888c3e7eb7a6ce", [:make, :rebar3], [{:cowlib, "2.10.1", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "459e7c843c894f69878df60378e7fa4a4b5504a00066c02138d084435c2c7968"}, + "gun": {:hex, :gun, "2.0.0-rc.2", "7c489a32dedccb77b6e82d1f3c5a7dadfbfa004ec14e322cdb5e579c438632d2", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "6b9d1eae146410d727140dbf8b404b9631302ecc2066d1d12f22097ad7d254fc"}, "hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"}, "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.3.0", "f005ad692b717691203f940c686208aa3d8ffd9dd4bb3699240096a51fa9564e", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"}, @@ -73,12 +73,12 @@ "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, "makeup_elixir": {:hex, :makeup_elixir, "0.14.1", "4f0e96847c63c17841d42c08107405a005a2680eb9c7ccadfd757bd31dabccfb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f2438b1a80eaec9ede832b5c41cd4f373b38fd7aa33e3b22d9db79e640cbde11"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, - "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"}, + "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "mochiweb": {:hex, :mochiweb, "2.18.0", "eb55f1db3e6e960fac4e6db4e2db9ec3602cc9f30b86cd1481d56545c3145d2e", [:rebar3], [], "hexpm"}, - "mock": {:hex, :mock, "0.3.6", "e810a91fabc7adf63ab5fdbec5d9d3b492413b8cda5131a2a8aa34b4185eb9b4", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "bcf1d0a6826fb5aee01bae3d74474669a3fa8b2df274d094af54a25266a1ebd2"}, + "mock": {:hex, :mock, "0.3.7", "75b3bbf1466d7e486ea2052a73c6e062c6256fb429d6797999ab02fa32f29e03", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "4da49a4609e41fd99b7836945c26f373623ea968cfb6282742bcb94440cf7e5c"}, "mogrify": {:hex, :mogrify, "0.7.4", "9b2496dde44b1ce12676f85d7dc531900939e6367bc537c7243a1b089435b32d", [:mix], [], "hexpm", "50d79e337fba6bc95bfbef918058c90f50b17eed9537771e61d4619488f099c3"}, "mox": {:hex, :mox, "1.0.0", "4b3c7005173f47ff30641ba044eb0fe67287743eec9bd9545e37f3002b0a9f8b", [:mix], [], "hexpm", "201b0a20b7abdaaab083e9cf97884950f8a30a1350a1da403b3145e213c6f4df"}, "myhtmlex": {:git, "https://git.pleroma.social/pleroma/myhtmlex.git", "ad0097e2f61d4953bfef20fb6abddf23b87111e6", [ref: "ad0097e2f61d4953bfef20fb6abddf23b87111e6", submodules: true]}, @@ -116,7 +116,7 @@ "sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm", "84ee37aeff4d0d92b290fff986d6a95ac5eedf9b383fadfd1d88e9b84a1c02e1"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm", "2e1ec458f892ffa81f9f8386e3f35a1af6db7a7a37748a64478f13163a1f3573"}, - "swoosh": {:hex, :swoosh, "1.3.8", "026d95852f21b20ac255a7f8ee2bf62f49ddccbd0ef00c96e10e117c4dc19c5a", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "c76137424e285e1bb66354ef1d983d0ef55ce9676c1ded208a941b3b33897b78"}, + "swoosh": {:hex, :swoosh, "1.3.11", "34f79c57f19892b43bd2168de9ff5de478a721a26328ef59567aad4243e7a77b", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "f1e2a048db454f9982b9cf840f75e7399dd48be31ecc2a7dc10012a803b913af"}, "syslog": {:hex, :syslog, "1.1.0", "6419a232bea84f07b56dc575225007ffe34d9fdc91abe6f1b2f254fd71d8efc2", [:rebar3], [], "hexpm", "4c6a41373c7e20587be33ef841d3de6f3beba08519809329ecc4d27b15b659e1"}, "telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"}, "tesla": {:hex, :tesla, "1.4.1", "ff855f1cac121e0d16281b49e8f066c4a0d89965f98864515713878cca849ac8", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "95f5de35922c8c4b3945bee7406f66eb680b0955232f78f5fb7e853aa1ce201a"}, From ab32ea44f0af54f9dd87f9a53378b1358f7ac1f8 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Sat, 22 May 2021 17:55:40 +0200 Subject: [PATCH 262/339] mix.exs: Apply OTP24 fixes to web_push_encryption --- lib/pleroma/http/web_push.ex | 4 ++-- mix.exs | 3 ++- mix.lock | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/http/web_push.ex b/lib/pleroma/http/web_push.ex index 51f72fbf8..16bbe6e8c 100644 --- a/lib/pleroma/http/web_push.ex +++ b/lib/pleroma/http/web_push.ex @@ -5,8 +5,8 @@ defmodule Pleroma.HTTP.WebPush do @moduledoc false - def post(url, payload, headers) do + def post(url, payload, headers, options \\ []) do list_headers = Map.to_list(headers) - Pleroma.HTTP.post(url, payload, list_headers) + Pleroma.HTTP.post(url, payload, list_headers, options) end end diff --git a/mix.exs b/mix.exs index 4ba698620..7ab8387f9 100644 --- a/mix.exs +++ b/mix.exs @@ -149,7 +149,8 @@ defp deps do git: "https://git.pleroma.social/pleroma/elixir-libraries/crypt.git", ref: "cf2aa3f11632e8b0634810a15b3e612c7526f6a3"}, {:cors_plug, "~> 2.0"}, - {:web_push_encryption, "~> 0.3"}, + {:web_push_encryption, + git: "https://github.com/lanodan/elixir-web-push-encryption.git", branch: "bugfix/otp-24"}, {:swoosh, "~> 1.0"}, {:phoenix_swoosh, "~> 0.3"}, {:gen_smtp, "~> 0.13"}, diff --git a/mix.lock b/mix.lock index fa4249d43..aa09843f9 100644 --- a/mix.lock +++ b/mix.lock @@ -126,6 +126,6 @@ "ueberauth": {:hex, :ueberauth, "0.6.3", "d42ace28b870e8072cf30e32e385579c57b9cc96ec74fa1f30f30da9c14f3cc0", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "afc293d8a1140d6591b53e3eaf415ca92842cb1d32fad3c450c6f045f7f91b60"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm", "6c7729a2d214806450d29766abc2afaa7a2cbecf415be64f36a6691afebb50e5"}, - "web_push_encryption": {:hex, :web_push_encryption, "0.3.0", "598b5135e696fd1404dc8d0d7c0fa2c027244a4e5d5e5a98ba267f14fdeaabc8", [:mix], [{:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "f10bdd1afe527ede694749fb77a2f22f146a51b054c7fa541c9fd920fba7c875"}, + "web_push_encryption": {:git, "https://github.com/lanodan/elixir-web-push-encryption.git", "026a043037a89db4da8f07560bc8f9c68bcf0cc0", [branch: "bugfix/otp-24"]}, "websocket_client": {:git, "https://github.com/jeremyong/websocket_client.git", "9a6f65d05ebf2725d62fb19262b21f1805a59fbf", []}, } From 7c5e007b9c73a52a4c46674bd00ce32640c07cc3 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Sat, 22 May 2021 18:04:13 +0200 Subject: [PATCH 263/339] mix: Update pot to ~> 1.0 --- mix.exs | 2 +- mix.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index 7ab8387f9..5d945bf5f 100644 --- a/mix.exs +++ b/mix.exs @@ -178,7 +178,7 @@ defp deps do {:quack, "~> 0.1.1"}, {:joken, "~> 2.0"}, {:benchee, "~> 1.0"}, - {:pot, "~> 0.11"}, + {:pot, "~> 1.0"}, {:esshd, "~> 0.1.0", runtime: Application.get_env(:esshd, :enabled, false)}, {:ex_const, "~> 0.2"}, {:plug_static_index_html, "~> 1.0.0"}, diff --git a/mix.lock b/mix.lock index aa09843f9..1a0cae3ee 100644 --- a/mix.lock +++ b/mix.lock @@ -102,7 +102,7 @@ "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, "postgrex": {:hex, :postgrex, "0.15.9", "46f8fe6f25711aeb861c4d0ae09780facfdf3adbd2fb5594ead61504dd489bda", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "610719103e4cb2223d4ab78f9f0f3e720320eeca6011415ab4137ddef730adee"}, - "pot": {:hex, :pot, "0.11.0", "61bad869a94534739dd4614a25a619bc5c47b9970e9a0ea5bef4628036fc7a16", [:rebar3], [], "hexpm", "57ee6ee6bdeb639661ffafb9acefe3c8f966e45394de6a766813bb9e1be4e54b"}, + "pot": {:hex, :pot, "1.0.1", "81b511b1fa7c3123171c265cb7065a1528cebd7277b0cbc94257c50a8b2e4c17", [:rebar3], [], "hexpm", "ed87f5976531d91528452faa1138a5328db7f9f20d8feaae15f5051f79bcfb6d"}, "prometheus": {:hex, :prometheus, "4.8.0", "1ce1e1002b173c336d61f186b56263346536e76814edd9a142e12aeb2d6c1ad2", [:mix, :rebar3], [], "hexpm", "0fc2e17103073edb3758a46a5d44b006191bf25b73cbaa2b779109de396afcb5"}, "prometheus_ecto": {:hex, :prometheus_ecto, "1.4.3", "3dd4da1812b8e0dbee81ea58bb3b62ed7588f2eae0c9e97e434c46807ff82311", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "8d66289f77f913b37eda81fd287340c17e61a447549deb28efc254532b2bed82"}, "prometheus_ex": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/prometheus.ex.git", "a4e9beb3c1c479d14b352fd9d6dd7b1f6d7deee5", [ref: "a4e9beb3c1c479d14b352fd9d6dd7b1f6d7deee5"]}, From 5c3a0dd26e8ba818388ca6965e71600fd2ec07a1 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Mon, 31 May 2021 10:06:06 +0200 Subject: [PATCH 264/339] factory: Fix article_factory --- test/support/factory.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/support/factory.ex b/test/support/factory.ex index 5c4e65c81..c267dba4e 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -191,8 +191,8 @@ def direct_note_factory do end def article_factory do - note_factory() - |> Map.put("type", "Article") + %Pleroma.Object{data: data} = note_factory() + %Pleroma.Object{data: Map.merge(data, %{"type" => "Article"})} end def tombstone_factory do From 24d66b60a0272ef4e78f2f9802682964059c44ce Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Mon, 31 May 2021 10:14:12 +0200 Subject: [PATCH 265/339] request_builder_test: mode :read got removed --- test/pleroma/http/request_builder_test.exs | 44 +++++++++++++--------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/test/pleroma/http/request_builder_test.exs b/test/pleroma/http/request_builder_test.exs index e9b0c4a8a..433beaac1 100644 --- a/test/pleroma/http/request_builder_test.exs +++ b/test/pleroma/http/request_builder_test.exs @@ -34,24 +34,32 @@ test "send custom user agent" do describe "add_param/4" do test "add file parameter" do - %Request{ - body: %Tesla.Multipart{ - boundary: _, - content_type_params: [], - parts: [ - %Tesla.Multipart.Part{ - body: %File.Stream{ - line_or_bytes: 2048, - modes: [:raw, :read_ahead, :read, :binary], - path: "some-path/filename.png", - raw: true - }, - dispositions: [name: "filename.png", filename: "filename.png"], - headers: [] - } - ] - } - } = RequestBuilder.add_param(%Request{}, :file, "filename.png", "some-path/filename.png") + assert match?( + %Request{ + body: %Tesla.Multipart{ + boundary: _, + content_type_params: [], + parts: [ + %Tesla.Multipart.Part{ + body: %File.Stream{ + line_or_bytes: 2048, + modes: [:raw, :read_ahead, :binary], + path: "some-path/filename.png", + raw: true + }, + dispositions: [name: "filename.png", filename: "filename.png"], + headers: [] + } + ] + } + }, + RequestBuilder.add_param( + %Request{}, + :file, + "filename.png", + "some-path/filename.png" + ) + ) end test "add key to body" do From 11844084d0fce0bd94df66561c47ef21b7b38e1d Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Mon, 31 May 2021 10:41:31 +0200 Subject: [PATCH 266/339] =?UTF-8?q?MIME.valid=3F(type)=20=E2=86=92=20is=5F?= =?UTF-8?q?bitstring(type)=20&&=20MIME.extensions(type)=20!=3D=20[]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since mime 1.6.0: warning: MIME.valid?/1 is deprecated. Use MIME.extensions(type) != [] instead As for the bitstring(type) part it's because MIME.extensions only expects a string. https://github.com/elixir-plug/mime/issues/43 --- .../object_validators/attachment_validator.ex | 2 +- lib/pleroma/web/activity_pub/transmogrifier.ex | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex index bba2f5eb0..837787b9f 100644 --- a/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/attachment_validator.ex @@ -61,7 +61,7 @@ def url_changeset(struct, data) do def fix_media_type(data) do data = Map.put_new(data, "mediaType", data["mimeType"]) - if MIME.valid?(data["mediaType"]) do + if is_bitstring(data["mediaType"]) && MIME.extensions(data["mediaType"]) != [] do data else Map.put(data, "mediaType", "application/octet-stream") diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 7da29b197..51c0cc860 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -203,10 +203,17 @@ def fix_attachments(%{"attachment" => attachment} = object) when is_list(attachm media_type = cond do - is_map(url) && MIME.valid?(url["mediaType"]) -> url["mediaType"] - MIME.valid?(data["mediaType"]) -> data["mediaType"] - MIME.valid?(data["mimeType"]) -> data["mimeType"] - true -> nil + is_map(url) && MIME.extensions(url["mediaType"]) != [] -> + url["mediaType"] + + is_bitstring(data["mediaType"]) && MIME.extensions(data["mediaType"]) != [] -> + data["mediaType"] + + is_bitstring(data["mimeType"]) && MIME.extensions(data["mimeType"]) != [] -> + data["mimeType"] + + true -> + nil end href = From 2c401dafa1105b73f4b4141f96e8414612625420 Mon Sep 17 00:00:00 2001 From: io <eiy7rongai0g@paperboats.net> Date: Fri, 4 Jun 2021 04:15:54 +0000 Subject: [PATCH 267/339] Improve opengraph embeds This brings them more in line with Mastodon. - Deduplicates display name from the title and content - Removes arbitrary limits on the size of the embedded image - Removes angled double quotes from embed descriptions. These would normally just indicate that the content is a quote, but that is already implied by the content being in an embed. --- .../web/metadata/providers/open_graph.ex | 20 +++---------------- .../web/metadata/providers/twitter_card.ex | 13 ++++-------- .../metadata/providers/twitter_card_test.exs | 10 ++++++---- 3 files changed, 13 insertions(+), 30 deletions(-) diff --git a/lib/pleroma/web/metadata/providers/open_graph.ex b/lib/pleroma/web/metadata/providers/open_graph.ex index 1687b2634..18ddde84b 100644 --- a/lib/pleroma/web/metadata/providers/open_graph.ex +++ b/lib/pleroma/web/metadata/providers/open_graph.ex @@ -19,31 +19,18 @@ def build_tags(%{ }) do attachments = build_attachments(object) scrubbed_content = Utils.scrub_html_and_truncate(object) - # Zero width space - content = - if scrubbed_content != "" and scrubbed_content != "\u200B" do - ": “" <> scrubbed_content <> "”" - else - "" - end - # Most previews only show og:title which is inconvenient. Instagram - # hacks this by putting the description in the title and making the - # description longer prefixed by how many likes and shares the post - # has. Here we use the descriptive nickname in the title, and expand - # the full account & nickname in the description. We also use the cute^Wevil - # smart quotes around the status text like Instagram, too. [ {:meta, [ property: "og:title", - content: "#{user.name}" <> content + content: Utils.user_name_string(user) ], []}, {:meta, [property: "og:url", content: url], []}, {:meta, [ property: "og:description", - content: "#{Utils.user_name_string(user)}" <> content + content: scrubbed_content ], []}, {:meta, [property: "og:type", content: "website"], []} ] ++ @@ -95,8 +82,7 @@ defp build_attachments(%{data: %{"attachment" => attachments}}) do "image" -> [ {:meta, [property: "og:image", content: Utils.attachment_url(url["href"])], []}, - {:meta, [property: "og:image:width", content: 150], []}, - {:meta, [property: "og:image:height", content: 150], []} + {:meta, [property: "og:image:alt", content: attachment["name"]], []} | acc ] diff --git a/lib/pleroma/web/metadata/providers/twitter_card.ex b/lib/pleroma/web/metadata/providers/twitter_card.ex index 58fc05cf9..589989a9d 100644 --- a/lib/pleroma/web/metadata/providers/twitter_card.ex +++ b/lib/pleroma/web/metadata/providers/twitter_card.ex @@ -16,17 +16,10 @@ defmodule Pleroma.Web.Metadata.Providers.TwitterCard do def build_tags(%{activity_id: id, object: object, user: user}) do attachments = build_attachments(id, object) scrubbed_content = Utils.scrub_html_and_truncate(object) - # Zero width space - content = - if scrubbed_content != "" and scrubbed_content != "\u200B" do - "“" <> scrubbed_content <> "”" - else - "" - end [ title_tag(user), - {:meta, [property: "twitter:description", content: content], []} + {:meta, [property: "twitter:description", content: scrubbed_content], []} ] ++ if attachments == [] or Metadata.activity_nsfw?(object) do [ @@ -91,7 +84,9 @@ defp build_attachments(id, %{data: %{"attachment" => attachments}}) do {:meta, [property: "twitter:card", content: "player"], []}, {:meta, [property: "twitter:player", content: player_url(id)], []}, {:meta, [property: "twitter:player:width", content: "480"], []}, - {:meta, [property: "twitter:player:height", content: "480"], []} + {:meta, [property: "twitter:player:height", content: "480"], []}, + {:meta, [property: "twitter:player:stream", content: url["href"]], []}, + {:meta, [property: "twitter:player:stream:content_type", content: url["mediaType"]], []} | acc ] diff --git a/test/pleroma/web/metadata/providers/twitter_card_test.exs b/test/pleroma/web/metadata/providers/twitter_card_test.exs index a35e44356..3a2f7ca31 100644 --- a/test/pleroma/web/metadata/providers/twitter_card_test.exs +++ b/test/pleroma/web/metadata/providers/twitter_card_test.exs @@ -46,7 +46,7 @@ test "it uses summary twittercard if post has no attachment" do assert [ {:meta, [property: "twitter:title", content: Utils.user_name_string(user)], []}, - {:meta, [property: "twitter:description", content: "“pleroma in a nutshell”"], []}, + {:meta, [property: "twitter:description", content: "pleroma in a nutshell"], []}, {:meta, [property: "twitter:image", content: "http://localhost:4001/images/avi.png"], []}, {:meta, [property: "twitter:card", content: "summary"], []} @@ -91,7 +91,7 @@ test "it renders avatar not attachment if post is nsfw and unfurl_nsfw is disabl assert [ {:meta, [property: "twitter:title", content: Utils.user_name_string(user)], []}, - {:meta, [property: "twitter:description", content: "“pleroma in a nutshell”"], []}, + {:meta, [property: "twitter:description", content: "pleroma in a nutshell"], []}, {:meta, [property: "twitter:image", content: "http://localhost:4001/images/avi.png"], []}, {:meta, [property: "twitter:card", content: "summary"], []} @@ -134,7 +134,7 @@ test "it renders supported types of attachments and skips unknown types" do assert [ {:meta, [property: "twitter:title", content: Utils.user_name_string(user)], []}, - {:meta, [property: "twitter:description", content: "“pleroma in a nutshell”"], []}, + {:meta, [property: "twitter:description", content: "pleroma in a nutshell"], []}, {:meta, [property: "twitter:card", content: "summary_large_image"], []}, {:meta, [property: "twitter:player", content: "https://pleroma.gov/tenshi.png"], []}, {:meta, [property: "twitter:card", content: "player"], []}, @@ -144,7 +144,9 @@ test "it renders supported types of attachments and skips unknown types" do content: Router.Helpers.o_status_url(Endpoint, :notice_player, activity.id) ], []}, {:meta, [property: "twitter:player:width", content: "480"], []}, - {:meta, [property: "twitter:player:height", content: "480"], []} + {:meta, [property: "twitter:player:height", content: "480"], []}, + {:meta, [property: "twitter:player:stream", content: "https://pleroma.gov/about/juche.webm"], []}, + {:meta, [property: "twitter:player:stream:content_type", content: "video/webm"], []} ] == result end end From f58928cf1c69adf9f16837e0ca86167b38375f94 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Fri, 4 Jun 2021 12:30:10 -0500 Subject: [PATCH 268/339] Add missing deprecation warning left out of !2842 --- lib/pleroma/config/deprecation_warnings.ex | 26 ++++++++++++++++++- .../config/deprecation_warnings_test.exs | 10 +++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/config/deprecation_warnings.ex b/lib/pleroma/config/deprecation_warnings.ex index 24aa5993b..fedd58a7e 100644 --- a/lib/pleroma/config/deprecation_warnings.ex +++ b/lib/pleroma/config/deprecation_warnings.ex @@ -41,7 +41,8 @@ def warn do :ok <- check_gun_pool_options(), :ok <- check_activity_expiration_config(), :ok <- check_remote_ip_plug_name(), - :ok <- check_uploders_s3_public_endpoint() do + :ok <- check_uploders_s3_public_endpoint(), + :ok <- check_old_chat_shoutbox() do :ok else _ -> @@ -215,4 +216,27 @@ def check_uploders_s3_public_endpoint do :ok end end + + @spec check_old_chat_shoutbox() :: :ok | nil + def check_old_chat_shoutbox do + instance_config = Pleroma.Config.get([:instance]) + chat_config = Pleroma.Config.get([:chat]) || [] + + use_old_config = + Keyword.has_key?(instance_config, :chat_limit) or + Keyword.has_key?(chat_config, :enabled) + + if use_old_config do + Logger.error(""" + !!!DEPRECATION WARNING!!! + Your config is using the old namespace for the Shoutbox configuration. You need to convert to the new namespace. e.g., + \n* `config :pleroma, :chat, enabled` and `config :pleroma, :instance, chat_limit` are now equal to: + \n* `config :pleroma, :shout, enabled` and `config :pleroma, :shout, limit` + """) + + :error + else + :ok + end + end end diff --git a/test/pleroma/config/deprecation_warnings_test.exs b/test/pleroma/config/deprecation_warnings_test.exs index 15f4982ea..ccf86634f 100644 --- a/test/pleroma/config/deprecation_warnings_test.exs +++ b/test/pleroma/config/deprecation_warnings_test.exs @@ -146,4 +146,14 @@ test "pool timeout" do "Your config is using old setting name `timeout` instead of `recv_timeout` in pool settings" end end + + test "check_old_chat_shoutbox/0" do + clear_config([:instance, :chat_limit], 1_000) + clear_config([:chat, :enabled], true) + + assert capture_log(fn -> + DeprecationWarnings.check_old_chat_shoutbox() + end) =~ + "Your config is using the old namespace for the Shoutbox configuration." + end end From 7d350b73f58664eb822efaa5f522fcf2bd38f669 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Fri, 4 Jun 2021 19:57:48 +0200 Subject: [PATCH 269/339] web endpoint: Use Config.get directly instead of a tuple Fixes a lot of warnings like the following while running the testsuite: warning: passing a {module, function, args} tuple to Plug.Parsers.MULTIPART is deprecated. Please see Plug.Parsers.MULTIPART module docs for better approaches to configuration This might mean no more dynamic configuration but there seems to be the same limitation two lines underneath anyway. --- lib/pleroma/web/endpoint.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex index 8e274de88..7591d0ae5 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -102,7 +102,7 @@ defmodule Pleroma.Web.Endpoint do plug(Plug.Parsers, parsers: [ :urlencoded, - {:multipart, length: {Config, :get, [[:instance, :upload_limit]]}}, + {:multipart, length: Config.get([:instance, :upload_limit])}, :json ], pass: ["*/*"], From eb7313b0d364ce6a0298d43fc86403d2e7dfc739 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Wed, 21 Oct 2020 10:23:10 +0200 Subject: [PATCH 270/339] Pipeline Ingestion: Page --- lib/pleroma/web/activity_pub/activity_pub.ex | 2 +- .../web/activity_pub/object_validator.ex | 15 ++--- ...ator.ex => article_note_page_validator.ex} | 4 +- lib/pleroma/web/activity_pub/side_effects.ex | 2 +- .../web/activity_pub/transmogrifier.ex | 62 +------------------ test/fixtures/tesla_mock/lemmy-page.json | 17 +++++ test/fixtures/tesla_mock/lemmy-user.json | 27 ++++++++ ...s => article_note_page_validator_test.exs} | 6 +- .../transmogrifier/page_handling_test.exs | 36 +++++++++++ 9 files changed, 96 insertions(+), 75 deletions(-) rename lib/pleroma/web/activity_pub/object_validators/{article_note_validator.ex => article_note_page_validator.ex} (96%) create mode 100644 test/fixtures/tesla_mock/lemmy-page.json create mode 100644 test/fixtures/tesla_mock/lemmy-user.json rename test/pleroma/web/activity_pub/object_validators/{article_note_validator_test.exs => article_note_page_validator_test.exs} (76%) create mode 100644 test/pleroma/web/activity_pub/transmogrifier/page_handling_test.exs diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 18368943d..30b4f65d3 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -88,7 +88,7 @@ defp increase_replies_count_if_reply(%{ defp increase_replies_count_if_reply(_create_data), do: :noop - @object_types ~w[ChatMessage Question Answer Audio Video Event Article Note] + @object_types ~w[ChatMessage Question Answer Audio Video Event Article Note Page] @impl true def persist(%{"type" => type} = object, meta) when type in @object_types do with {:ok, object} <- Object.create(object) do diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 248a12a36..e642916d8 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -20,7 +20,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do alias Pleroma.Web.ActivityPub.ObjectValidators.AddRemoveValidator alias Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator alias Pleroma.Web.ActivityPub.ObjectValidators.AnswerValidator - alias Pleroma.Web.ActivityPub.ObjectValidators.ArticleNoteValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator alias Pleroma.Web.ActivityPub.ObjectValidators.AudioVideoValidator alias Pleroma.Web.ActivityPub.ObjectValidators.BlockValidator alias Pleroma.Web.ActivityPub.ObjectValidators.ChatMessageValidator @@ -102,7 +102,7 @@ def validate( %{"type" => "Create", "object" => %{"type" => objtype} = object} = create_activity, meta ) - when objtype in ~w[Question Answer Audio Video Event Article Note] do + when objtype in ~w[Question Answer Audio Video Event Article Note Page] do with {:ok, object_data} <- cast_and_apply(object), meta = Keyword.put(meta, :object_data, object_data |> stringify_keys), {:ok, create_activity} <- @@ -115,15 +115,16 @@ def validate( end def validate(%{"type" => type} = object, meta) - when type in ~w[Event Question Audio Video Article Note] do + when type in ~w[Event Question Audio Video Article Note Page] do validator = case type do "Event" -> EventValidator "Question" -> QuestionValidator "Audio" -> AudioVideoValidator "Video" -> AudioVideoValidator - "Article" -> ArticleNoteValidator - "Note" -> ArticleNoteValidator + "Article" -> ArticleNotePageValidator + "Note" -> ArticleNotePageValidator + "Page" -> ArticleNotePageValidator end with {:ok, object} <- @@ -195,8 +196,8 @@ def cast_and_apply(%{"type" => "Event"} = object) do EventValidator.cast_and_apply(object) end - def cast_and_apply(%{"type" => type} = object) when type in ~w[Article Note] do - ArticleNoteValidator.cast_and_apply(object) + def cast_and_apply(%{"type" => type} = object) when type in ~w[Article Note Page] do + ArticleNotePageValidator.cast_and_apply(object) end def cast_and_apply(o), do: {:error, {:validator_not_set, o}} diff --git a/lib/pleroma/web/activity_pub/object_validators/article_note_validator.ex b/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex similarity index 96% rename from lib/pleroma/web/activity_pub/object_validators/article_note_validator.ex rename to lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex index 193f85f49..0d987116c 100644 --- a/lib/pleroma/web/activity_pub/object_validators/article_note_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex @@ -2,7 +2,7 @@ # Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNoteValidator do +defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do use Ecto.Schema alias Pleroma.EctoType.ActivityPub.ObjectValidators @@ -113,7 +113,7 @@ def changeset(struct, data) do defp validate_data(data_cng) do data_cng - |> validate_inclusion(:type, ["Article", "Note"]) + |> validate_inclusion(:type, ["Article", "Note", "Page"]) |> validate_required([:id, :actor, :attributedTo, :type, :context, :context_id]) |> CommonValidations.validate_any_presence([:cc, :to]) |> CommonValidations.validate_fields_match([:actor, :attributedTo]) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 674356d9a..3670de45c 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -436,7 +436,7 @@ def handle_object_creation(%{"type" => "Answer"} = object_map, meta) do end def handle_object_creation(%{"type" => objtype} = object, meta) - when objtype in ~w[Audio Video Question Event Article Note] do + when objtype in ~w[Audio Video Question Event Article Note Page] do with {:ok, object, meta} <- Pipeline.common_pipeline(object, meta) do {:ok, object, meta} end diff --git a/lib/pleroma/web/activity_pub/transmogrifier.ex b/lib/pleroma/web/activity_pub/transmogrifier.ex index 51c0cc860..142af1a13 100644 --- a/lib/pleroma/web/activity_pub/transmogrifier.ex +++ b/lib/pleroma/web/activity_pub/transmogrifier.ex @@ -353,29 +353,6 @@ defp get_reported(objects) do end) end - # Compatibility wrapper for Mastodon votes - defp handle_create(%{"object" => %{"type" => "Answer"}} = data, _user) do - handle_incoming(data) - end - - defp handle_create(%{"object" => object} = data, user) do - %{ - to: data["to"], - object: object, - actor: user, - context: object["context"], - local: false, - published: data["published"], - additional: - Map.take(data, [ - "cc", - "directMessage", - "id" - ]) - } - |> ActivityPub.create() - end - def handle_incoming(data, options \\ []) # Flag objects are placed ahead of the ID check because Mastodon 2.8 and earlier send them @@ -407,43 +384,6 @@ def handle_incoming(%{"id" => ""}, _options), do: :error def handle_incoming(%{"id" => id}, _options) when is_binary(id) and byte_size(id) < 8, do: :error - # TODO: validate those with a Ecto scheme - # - tags - # - emoji - def handle_incoming( - %{"type" => "Create", "object" => %{"type" => "Page"} = object} = data, - options - ) do - actor = Containment.get_actor(data) - - with nil <- Activity.get_create_by_object_ap_id(object["id"]), - {:ok, %User{} = user} <- User.get_or_fetch_by_ap_id(actor) do - data = - data - |> Map.put("object", fix_object(object, options)) - |> Map.put("actor", actor) - |> fix_addressing() - - with {:ok, created_activity} <- handle_create(data, user) do - reply_depth = (options[:depth] || 0) + 1 - - if Federator.allowed_thread_distance?(reply_depth) do - for reply_id <- replies(object) do - Pleroma.Workers.RemoteFetcherWorker.enqueue("fetch_remote", %{ - "id" => reply_id, - "depth" => reply_depth - }) - end - end - - {:ok, created_activity} - end - else - %Activity{} = activity -> {:ok, activity} - _e -> :error - end - end - def handle_incoming( %{"type" => "Listen", "object" => %{"type" => "Audio"} = object} = data, options @@ -507,7 +447,7 @@ def handle_incoming( %{"type" => "Create", "object" => %{"type" => objtype, "id" => obj_id}} = data, options ) - when objtype in ~w{Question Answer ChatMessage Audio Video Event Article Note} do + when objtype in ~w{Question Answer ChatMessage Audio Video Event Article Note Page} do fetch_options = Keyword.put(options, :depth, (options[:depth] || 0) + 1) object = diff --git a/test/fixtures/tesla_mock/lemmy-page.json b/test/fixtures/tesla_mock/lemmy-page.json new file mode 100644 index 000000000..f07097a0e --- /dev/null +++ b/test/fixtures/tesla_mock/lemmy-page.json @@ -0,0 +1,17 @@ +{ + "commentsEnabled": true, + "sensitive": false, + "stickied": false, + "attributedTo": "https://enterprise.lemmy.ml/u/nutomic", + "summary": "Hello Federation!", + "url": "https://enterprise.lemmy.ml/pictrs/image/US52d9DPvf.jpg", + "image": { + "type": "Image", + "url": "https://enterprise.lemmy.ml/pictrs/image/lwFAcXHUjS.jpg" + }, + "published": "2020-09-14T15:03:11.909105+00:00", + "to": "https://enterprise.lemmy.ml/c/main", + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://enterprise.lemmy.ml/post/3", + "type": "Page" +} diff --git a/test/fixtures/tesla_mock/lemmy-user.json b/test/fixtures/tesla_mock/lemmy-user.json new file mode 100644 index 000000000..d0e9066ac --- /dev/null +++ b/test/fixtures/tesla_mock/lemmy-user.json @@ -0,0 +1,27 @@ +{ + "publicKey": { + "id": "https://enterprise.lemmy.ml/u/nutomic#main-key", + "owner": "https://enterprise.lemmy.ml/u/nutomic", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvfwAYPxp1gOk2HcCRoUd\nupoecvmnpzRc5Gu6/N3YQyOyRsrYuiYLNQq2cgM3kcU80ZeEetkwkYgXkRJOKu/b\nBWb7i1zt2tdr5k6lUdW8dfCyjht8ooFPQdov8J3QYHfgBHyUYxuCNfSujryxx2wu\nLQcdjRQa5NIWcomSO8OXmCF5/Yhg2XWCbtnlxEq6Y+AFddr1mAlTOy5pBr5d+xZz\njLw/U3CioNJ79yGi/sJhgp6IyJqtUSoN3b4BgRIEts2QVvn44W1rQy9wCbRYQrO1\nBcB9Wel4k3rJJK8uHg+LpHVMaZppkNaWGkMBhMbzr8qmIlcNWNi7cbMK/p5vyviy\nSwIDAQAB\n-----END PUBLIC KEY-----\n" + }, + "inbox": "https://enterprise.lemmy.ml/u/nutomic/inbox", + "preferredUsername": "Nutomic", + "endpoints": { + "sharedInbox": "https://enterprise.lemmy.ml/inbox" + }, + "summary": "some bio", + "icon": { + "type": "Image", + "url": "https://enterprise.lemmy.ml/pictrs/image/F6Z7QcWZRJ.jpg" + }, + "image": { + "type": "Image", + "url": "https://enterprise.lemmy.ml:/pictrs/image/Q79N9oCDEG.png" + }, + "published": "2020-09-14T14:54:53.080949+00:00", + "updated": "2020-10-14T10:58:28.139178+00:00", + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://enterprise.lemmy.ml/u/nutomic", + "type": "Person", + "name": "nutomic" +} diff --git a/test/pleroma/web/activity_pub/object_validators/article_note_validator_test.exs b/test/pleroma/web/activity_pub/object_validators/article_note_page_validator_test.exs similarity index 76% rename from test/pleroma/web/activity_pub/object_validators/article_note_validator_test.exs rename to test/pleroma/web/activity_pub/object_validators/article_note_page_validator_test.exs index e408c85c3..720c17d8d 100644 --- a/test/pleroma/web/activity_pub/object_validators/article_note_validator_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/article_note_page_validator_test.exs @@ -2,10 +2,10 @@ # Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNoteValidatorTest do +defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidatorTest do use Pleroma.DataCase, async: true - alias Pleroma.Web.ActivityPub.ObjectValidators.ArticleNoteValidator + alias Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator alias Pleroma.Web.ActivityPub.Utils import Pleroma.Factory @@ -29,7 +29,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNoteValidatorTest do end test "a basic note validates", %{note: note} do - %{valid?: true} = ArticleNoteValidator.cast_and_validate(note) + %{valid?: true} = ArticleNotePageValidator.cast_and_validate(note) end end end diff --git a/test/pleroma/web/activity_pub/transmogrifier/page_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/page_handling_test.exs new file mode 100644 index 000000000..4ac71e066 --- /dev/null +++ b/test/pleroma/web/activity_pub/transmogrifier/page_handling_test.exs @@ -0,0 +1,36 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.Transmogrifier.PageHandlingTest do + use Oban.Testing, repo: Pleroma.Repo + use Pleroma.DataCase + + alias Pleroma.Object.Fetcher + + test "Lemmy Page" do + Tesla.Mock.mock(fn + %{url: "https://enterprise.lemmy.ml/post/3"} -> + %Tesla.Env{ + status: 200, + headers: [{"content-type", "application/activity+json"}], + body: File.read!("test/fixtures/tesla_mock/lemmy-page.json") + } + + %{url: "https://enterprise.lemmy.ml/u/nutomic"} -> + %Tesla.Env{ + status: 200, + headers: [{"content-type", "application/activity+json"}], + body: File.read!("test/fixtures/tesla_mock/lemmy-user.json") + } + end) + + {:ok, object} = Fetcher.fetch_object_from_id("https://enterprise.lemmy.ml/post/3") + + assert object.data["summary"] == "Hello Federation!" + assert object.data["published"] == "2020-09-14T15:03:11.909105Z" + + # WAT + assert object.data["url"] == "https://enterprise.lemmy.ml/pictrs/image/US52d9DPvf.jpg" + end +end From d5daf59f8863e8762041becff0d0878edd15440e Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Fri, 4 Jun 2021 15:35:56 -0500 Subject: [PATCH 271/339] Fix warning for misuse of clear_config/2 The old warning message was producing an improperly formatted suggestion. --- test/support/helpers.ex | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/support/helpers.ex b/test/support/helpers.ex index 856a6a376..34f1505d0 100644 --- a/test/support/helpers.ex +++ b/test/support/helpers.ex @@ -42,8 +42,7 @@ defmacro clear_config(config_path, temp_setting) do # Displaying a warning to prevent unintentional clearing of all but one keys in section if Keyword.keyword?(temp_setting) and length(temp_setting) == 1 do Logger.warn( - "Please change to `clear_config([section]); clear_config([section, key], value)`: " <> - "#{inspect(config_path)}, #{inspect(temp_setting)}" + "Please change `clear_config([section], key: value)` to `clear_config([section, key], value)`" ) end From eb150e7d883b3bb01991462e409969375d6b77da Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Fri, 4 Jun 2021 15:50:10 -0500 Subject: [PATCH 272/339] Document OTP 24 support so we remember to add it to the official release notes / announcement --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d1ff5b7b..24c029bf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed - **Breaking:** Configuration: `:chat, enabled` moved to `:shout, enabled` and `:instance, chat_limit` moved to `:shout, limit` +- Support for Erlang/OTP 24 - The `application` metadata returned with statuses is no longer hardcoded. Apps that want to display these details will now have valid data for new posts after this change. - HTTPSecurityPlug now sends a response header to opt out of Google's FLoC (Federated Learning of Cohorts) targeted advertising. - Email address is now returned if requesting user is the owner of the user account so it can be exposed in client and FE user settings UIs. From 1c3fe43d231428fee392afd726363193fdcb8008 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Wed, 2 Jun 2021 16:34:32 -0500 Subject: [PATCH 273/339] ReverseProxy: create Client.Wrapper to call client from config Speeds up recompilation by reducing compile-time cycles --- lib/pleroma/reverse_proxy.ex | 2 +- lib/pleroma/reverse_proxy/client.ex | 18 ------------- lib/pleroma/reverse_proxy/client/wrapper.ex | 29 +++++++++++++++++++++ 3 files changed, 30 insertions(+), 19 deletions(-) create mode 100644 lib/pleroma/reverse_proxy/client/wrapper.ex diff --git a/lib/pleroma/reverse_proxy.ex b/lib/pleroma/reverse_proxy.ex index 406f7e2b8..ec69a1779 100644 --- a/lib/pleroma/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy.ex @@ -411,7 +411,7 @@ defp increase_read_duration(_) do {:ok, :no_duration_limit, :no_duration_limit} end - defp client, do: Pleroma.ReverseProxy.Client + defp client, do: Pleroma.ReverseProxy.Client.Wrapper defp track_failed_url(url, error, opts) do ttl = diff --git a/lib/pleroma/reverse_proxy/client.ex b/lib/pleroma/reverse_proxy/client.ex index 8698fa2e1..75243d2dc 100644 --- a/lib/pleroma/reverse_proxy/client.ex +++ b/lib/pleroma/reverse_proxy/client.ex @@ -17,22 +17,4 @@ defmodule Pleroma.ReverseProxy.Client do @callback stream_body(map()) :: {:ok, binary(), map()} | :done | {:error, atom() | String.t()} @callback close(reference() | pid() | map()) :: :ok - - def request(method, url, headers, body \\ "", opts \\ []) do - client().request(method, url, headers, body, opts) - end - - def stream_body(ref), do: client().stream_body(ref) - - def close(ref), do: client().close(ref) - - defp client do - :tesla - |> Application.get_env(:adapter) - |> client() - end - - defp client(Tesla.Adapter.Hackney), do: Pleroma.ReverseProxy.Client.Hackney - defp client(Tesla.Adapter.Gun), do: Pleroma.ReverseProxy.Client.Tesla - defp client(_), do: Pleroma.Config.get!(Pleroma.ReverseProxy.Client) end diff --git a/lib/pleroma/reverse_proxy/client/wrapper.ex b/lib/pleroma/reverse_proxy/client/wrapper.ex new file mode 100644 index 000000000..06dd29fea --- /dev/null +++ b/lib/pleroma/reverse_proxy/client/wrapper.ex @@ -0,0 +1,29 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ReverseProxy.Client.Wrapper do + @moduledoc "Meta-client that calls the appropriate client from the config." + @behaviour Pleroma.ReverseProxy.Client + + @impl true + def request(method, url, headers, body \\ "", opts \\ []) do + client().request(method, url, headers, body, opts) + end + + @impl true + def stream_body(ref), do: client().stream_body(ref) + + @impl true + def close(ref), do: client().close(ref) + + defp client do + :tesla + |> Application.get_env(:adapter) + |> client() + end + + defp client(Tesla.Adapter.Hackney), do: Pleroma.ReverseProxy.Client.Hackney + defp client(Tesla.Adapter.Gun), do: Pleroma.ReverseProxy.Client.Tesla + defp client(_), do: Pleroma.Config.get!(Pleroma.ReverseProxy.Client) +end From 879c2db0bdb875fc2b3139cf60b1fd03bb66a01b Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Mon, 7 Jun 2021 11:18:14 -0500 Subject: [PATCH 274/339] Docs: /api/v1/pleroma/notification_settings --> /api/pleroma/notification_settings --- docs/development/API/pleroma_api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/development/API/pleroma_api.md b/docs/development/API/pleroma_api.md index d896f0ce7..8f6422da0 100644 --- a/docs/development/API/pleroma_api.md +++ b/docs/development/API/pleroma_api.md @@ -300,7 +300,7 @@ See [Admin-API](admin_api.md) * Note: Behaves exactly the same as `POST /api/v1/upload`. Can only accept images - any attempt to upload non-image files will be met with `HTTP 415 Unsupported Media Type`. -## `/api/v1/pleroma/notification_settings` +## `/api/pleroma/notification_settings` ### Updates user notification settings * Method `PUT` * Authentication: required From fe4c4a7178ac4df76a9f4a83c05f8445c5ff9bf2 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Mon, 7 Jun 2021 14:22:08 -0500 Subject: [PATCH 275/339] MRF: create MRF.Policy behaviour separate from MRF module Speeds up recompilation by reducing compile-time deps --- docs/configuration/mrf.md | 2 +- lib/pleroma/web/activity_pub/mrf.ex | 11 ----------- .../mrf/activity_expiration_policy.ex | 2 +- .../activity_pub/mrf/anti_followbot_policy.ex | 2 +- .../activity_pub/mrf/anti_link_spam_policy.ex | 2 +- lib/pleroma/web/activity_pub/mrf/drop_policy.ex | 2 +- .../web/activity_pub/mrf/ensure_re_prepended.ex | 2 +- .../web/activity_pub/mrf/follow_bot_policy.ex | 2 +- .../mrf/force_bot_unlisted_policy.ex | 2 +- .../web/activity_pub/mrf/hashtag_policy.ex | 2 +- .../web/activity_pub/mrf/hellthread_policy.ex | 2 +- .../web/activity_pub/mrf/keyword_policy.ex | 2 +- .../mrf/media_proxy_warming_policy.ex | 2 +- .../web/activity_pub/mrf/mention_policy.ex | 2 +- .../web/activity_pub/mrf/no_empty_policy.ex | 2 +- lib/pleroma/web/activity_pub/mrf/no_op_policy.ex | 2 +- .../mrf/no_placeholder_text_policy.ex | 2 +- .../web/activity_pub/mrf/normalize_markup.ex | 2 +- .../web/activity_pub/mrf/object_age_policy.ex | 2 +- lib/pleroma/web/activity_pub/mrf/policy.ex | 16 ++++++++++++++++ .../web/activity_pub/mrf/reject_non_public.ex | 2 +- .../web/activity_pub/mrf/simple_policy.ex | 2 +- .../web/activity_pub/mrf/steal_emoji_policy.ex | 2 +- .../web/activity_pub/mrf/subchain_policy.ex | 2 +- lib/pleroma/web/activity_pub/mrf/tag_policy.ex | 2 +- .../activity_pub/mrf/user_allow_list_policy.ex | 2 +- .../web/activity_pub/mrf/vocabulary_policy.ex | 2 +- test/fixtures/modules/good_mrf.ex | 2 +- test/support/mrf_module_mock.ex | 2 +- 29 files changed, 43 insertions(+), 38 deletions(-) create mode 100644 lib/pleroma/web/activity_pub/mrf/policy.ex diff --git a/docs/configuration/mrf.md b/docs/configuration/mrf.md index 9e8c0a2d7..5618634a2 100644 --- a/docs/configuration/mrf.md +++ b/docs/configuration/mrf.md @@ -82,7 +82,7 @@ For example, here is a sample policy module which rewrites all messages to "new ```elixir defmodule Pleroma.Web.ActivityPub.MRF.RewritePolicy do @moduledoc "MRF policy which rewrites all Notes to have 'new message content'." - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy # Catch messages which contain Note objects with actual data to filter. # Capture the object as `object`, the message content as `content` and the diff --git a/lib/pleroma/web/activity_pub/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex index f2fec3ff6..250dac695 100644 --- a/lib/pleroma/web/activity_pub/mrf.ex +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -51,17 +51,6 @@ defmodule Pleroma.Web.ActivityPub.MRF do @required_description_keys [:key, :related_policy] - @callback filter(Map.t()) :: {:ok | :reject, Map.t()} - @callback describe() :: {:ok | :error, Map.t()} - @callback config_description() :: %{ - optional(:children) => [map()], - key: atom(), - related_policy: String.t(), - label: String.t(), - description: String.t() - } - @optional_callbacks config_description: 0 - def filter(policies, %{} = message) do policies |> Enum.reduce({:ok, message}, fn diff --git a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex index fc347236e..e78254280 100644 --- a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex @@ -4,7 +4,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy do @moduledoc "Adds expiration to all local Create activities" - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy @impl true def filter(activity) do diff --git a/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex b/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex index b8bfdc3ce..851e95d22 100644 --- a/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex @@ -7,7 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiFollowbotPolicy do @moduledoc "Prevent followbots from following with a bit of heuristic" - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy # XXX: this should become User.normalize_by_ap_id() or similar, really. defp normalize_by_ap_id(%{"id" => id}), do: User.get_cached_by_ap_id(id) diff --git a/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex b/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex index 40b19c3ab..cdf17fd28 100644 --- a/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicy do alias Pleroma.User - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy require Logger diff --git a/lib/pleroma/web/activity_pub/mrf/drop_policy.ex b/lib/pleroma/web/activity_pub/mrf/drop_policy.ex index 378175205..b3ff86eed 100644 --- a/lib/pleroma/web/activity_pub/mrf/drop_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/drop_policy.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.DropPolicy do require Logger @moduledoc "Drop and log everything received" - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy @impl true def filter(object) do diff --git a/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex b/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex index 2d3a10889..fad8d873b 100644 --- a/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex +++ b/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.EnsureRePrepended do alias Pleroma.Object @moduledoc "Ensure a re: is prepended on replies to a post with a Subject" - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy @reply_prefix Regex.compile!("^re:[[:space:]]*", [:caseless]) diff --git a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex index 7307c9c14..7cf7de068 100644 --- a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex @@ -1,5 +1,5 @@ defmodule Pleroma.Web.ActivityPub.MRF.FollowBotPolicy do - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy alias Pleroma.Config alias Pleroma.User alias Pleroma.Web.CommonAPI diff --git a/lib/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy.ex b/lib/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy.ex index 51dbb1ad4..11871375e 100644 --- a/lib/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy.ex @@ -4,7 +4,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.ForceBotUnlistedPolicy do alias Pleroma.User - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy @moduledoc "Remove bot posts from federated timeline" require Pleroma.Constants diff --git a/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex b/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex index def0c437c..b7db4fa3d 100644 --- a/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex @@ -14,7 +14,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.HashtagPolicy do Note: This MRF Policy is always enabled, if you want to disable it you have to set empty lists. """ - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy defp check_reject(message, hashtags) do if Enum.any?(Config.get([:mrf_hashtag, :reject]), fn match -> match in hashtags end) do diff --git a/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex b/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex index 768a669f3..504bd4d57 100644 --- a/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.HellthreadPolicy do @moduledoc "Block messages with too much mentions (configurable)" - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy defp delist_message(message, threshold) when threshold > 0 do follower_collection = User.get_cached_by_ap_id(message["actor"]).follower_address diff --git a/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex b/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex index f91b51bcf..646008dd9 100644 --- a/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex @@ -7,7 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicy do @moduledoc "Reject or Word-Replace messages with a keyword or regex" - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy defp string_matches?(string, _) when not is_binary(string) do false end diff --git a/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex index 8dbf44071..25289d3a4 100644 --- a/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex @@ -4,7 +4,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do @moduledoc "Preloads any attachments in the MediaProxy cache by prefetching them" - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy alias Pleroma.HTTP alias Pleroma.Web.MediaProxy diff --git a/lib/pleroma/web/activity_pub/mrf/mention_policy.ex b/lib/pleroma/web/activity_pub/mrf/mention_policy.ex index 877277d4f..05b28e4f5 100644 --- a/lib/pleroma/web/activity_pub/mrf/mention_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/mention_policy.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.MentionPolicy do @moduledoc "Block messages which mention a user" - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy @impl true def filter(%{"type" => "Create"} = message) do diff --git a/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex b/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex index f4c5db05c..80bef591e 100644 --- a/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex @@ -4,7 +4,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoEmptyPolicy do @moduledoc "Filter local activities which have no content" - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy alias Pleroma.Web.Endpoint diff --git a/lib/pleroma/web/activity_pub/mrf/no_op_policy.ex b/lib/pleroma/web/activity_pub/mrf/no_op_policy.ex index 2ebc0674d..25031946c 100644 --- a/lib/pleroma/web/activity_pub/mrf/no_op_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/no_op_policy.ex @@ -4,7 +4,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoOpPolicy do @moduledoc "Does nothing (lets the messages go through unmodified)" - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy @impl true def filter(object) do diff --git a/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex b/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex index b658d7d41..90272766c 100644 --- a/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex @@ -4,7 +4,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicy do @moduledoc "Ensure no content placeholder is present (such as the dot from mastodon)" - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy @impl true def filter( diff --git a/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex b/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex index 2ad3fde0b..0d7146738 100644 --- a/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex +++ b/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkup do @moduledoc "Scrub configured hypertext markup" alias Pleroma.HTML - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy @impl true def filter(%{"type" => "Create", "object" => child_object} = object) do diff --git a/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex index aac24c0ec..9a211fd44 100644 --- a/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex @@ -9,7 +9,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy do require Pleroma.Constants @moduledoc "Filter activities depending on their age" - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy defp check_date(%{"object" => %{"published" => published}} = message) do with %DateTime{} = now <- DateTime.utc_now(), diff --git a/lib/pleroma/web/activity_pub/mrf/policy.ex b/lib/pleroma/web/activity_pub/mrf/policy.ex new file mode 100644 index 000000000..a4a960c01 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/policy.ex @@ -0,0 +1,16 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.ActivityPub.MRF.Policy do + @callback filter(Map.t()) :: {:ok | :reject, Map.t()} + @callback describe() :: {:ok | :error, Map.t()} + @callback config_description() :: %{ + optional(:children) => [map()], + key: atom(), + related_policy: String.t(), + label: String.t(), + description: String.t() + } + @optional_callbacks config_description: 0 +end diff --git a/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex b/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex index 47a43c6a2..b9d3e52c7 100644 --- a/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex +++ b/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex @@ -8,7 +8,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.RejectNonPublic do alias Pleroma.Config alias Pleroma.User - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy require Pleroma.Constants diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index d40348cb1..30562ac08 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -4,7 +4,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do @moduledoc "Filter activities depending on their origin instance" - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy alias Pleroma.Config alias Pleroma.FollowingRelationship diff --git a/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex b/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex index 4c5e33619..c28f14a41 100644 --- a/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex @@ -8,7 +8,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.StealEmojiPolicy do alias Pleroma.Config @moduledoc "Detect new emojis by their shortcode and steals them" - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy defp accept_host?(host), do: host in Config.get([:mrf_steal_emoji, :hosts], []) diff --git a/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex b/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex index 86965d47b..f84d7cc71 100644 --- a/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex @@ -8,7 +8,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SubchainPolicy do require Logger - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy defp lookup_subchain(actor) do with matches <- Config.get([:mrf_subchain, :match_actor]), diff --git a/lib/pleroma/web/activity_pub/mrf/tag_policy.ex b/lib/pleroma/web/activity_pub/mrf/tag_policy.ex index 528093ac0..56ae654f2 100644 --- a/lib/pleroma/web/activity_pub/mrf/tag_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/tag_policy.ex @@ -4,7 +4,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.TagPolicy do alias Pleroma.User - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy @moduledoc """ Apply policies based on user tags diff --git a/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex index 65b371bf3..1bcb3688b 100644 --- a/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.UserAllowListPolicy do alias Pleroma.Config @moduledoc "Accept-list of users from specified instances" - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy defp filter_by_list(object, []), do: {:ok, object} diff --git a/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex b/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex index ce559a239..20f57f609 100644 --- a/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.VocabularyPolicy do @moduledoc "Filter messages which belong to certain activity vocabularies" - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy @impl true def filter(%{"type" => "Undo", "object" => child_message} = message) do diff --git a/test/fixtures/modules/good_mrf.ex b/test/fixtures/modules/good_mrf.ex index 39d0f14ec..5afa1c1d1 100644 --- a/test/fixtures/modules/good_mrf.ex +++ b/test/fixtures/modules/good_mrf.ex @@ -1,5 +1,5 @@ defmodule Fixtures.Modules.GoodMRF do - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy @impl true def filter(a), do: {:ok, a} diff --git a/test/support/mrf_module_mock.ex b/test/support/mrf_module_mock.ex index 4dfdeb3b4..4d21d7fe0 100644 --- a/test/support/mrf_module_mock.ex +++ b/test/support/mrf_module_mock.ex @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule MRFModuleMock do - @behaviour Pleroma.Web.ActivityPub.MRF + @behaviour Pleroma.Web.ActivityPub.MRF.Policy @impl true def filter(message), do: {:ok, message} From 6fcfa33e4ec39c66e07ca8187f618b9c6f5c25c3 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Mon, 7 Jun 2021 14:51:25 -0500 Subject: [PATCH 276/339] Fix MRF.config_descriptions/0 --- lib/pleroma/web/activity_pub/mrf.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/activity_pub/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex index 250dac695..ac00fa54b 100644 --- a/lib/pleroma/web/activity_pub/mrf.ex +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -131,7 +131,7 @@ def describe(policies) do def describe, do: get_policies() |> describe() def config_descriptions do - Pleroma.Web.ActivityPub.MRF + Pleroma.Web.ActivityPub.MRF.Policy |> Pleroma.Docs.Generator.list_behaviour_implementations() |> config_descriptions() end From bc51dea4257d4faaff70f8511dcd3702489ebb74 Mon Sep 17 00:00:00 2001 From: feld <feld@feld.me> Date: Mon, 7 Jun 2021 20:02:28 +0000 Subject: [PATCH 277/339] Update lib/mix/tasks/pleroma/database.ex --- lib/mix/tasks/pleroma/database.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/mix/tasks/pleroma/database.ex b/lib/mix/tasks/pleroma/database.ex index bcde07774..57f73d12b 100644 --- a/lib/mix/tasks/pleroma/database.ex +++ b/lib/mix/tasks/pleroma/database.ex @@ -97,10 +97,10 @@ def run(["prune_objects" | args]) do |> Repo.delete_all(timeout: :infinity) prune_hashtags_query = """ - delete from hashtags as ht - where not exists ( - select 1 from hashtags_objects hto - where ht.id = hto.hashtag_id) + DELETE FROM hashtags AS ht + WHERE NOT EXISTS ( + SELECT 1 FROM hashtags_objects hto + WHERE ht.id = hto.hashtag_id) """ Repo.query(prune_hashtags_query) From c31338abe6cc371c877d04a47f06ba5800653e50 Mon Sep 17 00:00:00 2001 From: feld <feld@feld.me> Date: Mon, 7 Jun 2021 20:04:27 +0000 Subject: [PATCH 278/339] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bb4b1e73..209432409 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Don't crash so hard when email settings are invalid. +- Mix task: pleroma.database prune_objects ## Unreleased (Patch) From 4ca380490f1e42ef6b12c4b12ba9efabb89472fd Mon Sep 17 00:00:00 2001 From: feld <feld@feld.me> Date: Mon, 7 Jun 2021 20:05:18 +0000 Subject: [PATCH 279/339] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 209432409..2c6f57691 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Don't crash so hard when email settings are invalid. -- Mix task: pleroma.database prune_objects +- Mix task `pleroma.database prune_objects` ## Unreleased (Patch) From 10abbf13ba9ba036e20e4018279c5a8f2faa19b9 Mon Sep 17 00:00:00 2001 From: feld <feld@feld.me> Date: Mon, 7 Jun 2021 20:07:27 +0000 Subject: [PATCH 280/339] Update CHANGELOG.md --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18879a6df..61796271a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Checking activated Upload Filters for required commands. - Mix task `pleroma.database prune_objects` +### Removed + +- **Breaking**: Remove deprecated `/api/qvitter/statuses/notifications/read` (replaced by `/api/v1/pleroma/notifications/read`) + ## Unreleased (Patch) ### Fixed From 9a357d63f0d8381492a0ffe0e507f233fc35fbf8 Mon Sep 17 00:00:00 2001 From: feld <feld@feld.me> Date: Mon, 7 Jun 2021 20:07:59 +0000 Subject: [PATCH 281/339] Update CHANGELOG.md --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61796271a..daa8f2ff6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Mix task `pleroma.database prune_objects` ### Removed - - **Breaking**: Remove deprecated `/api/qvitter/statuses/notifications/read` (replaced by `/api/v1/pleroma/notifications/read`) ## Unreleased (Patch) From 264458531ad1024134fc2f53eded1d1075394536 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Mon, 7 Jun 2021 15:47:50 -0500 Subject: [PATCH 282/339] Formatting --- lib/pleroma/web/metadata/providers/twitter_card.ex | 3 ++- test/pleroma/web/metadata/providers/twitter_card_test.exs | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/metadata/providers/twitter_card.ex b/lib/pleroma/web/metadata/providers/twitter_card.ex index 589989a9d..12c372d77 100644 --- a/lib/pleroma/web/metadata/providers/twitter_card.ex +++ b/lib/pleroma/web/metadata/providers/twitter_card.ex @@ -86,7 +86,8 @@ defp build_attachments(id, %{data: %{"attachment" => attachments}}) do {:meta, [property: "twitter:player:width", content: "480"], []}, {:meta, [property: "twitter:player:height", content: "480"], []}, {:meta, [property: "twitter:player:stream", content: url["href"]], []}, - {:meta, [property: "twitter:player:stream:content_type", content: url["mediaType"]], []} + {:meta, + [property: "twitter:player:stream:content_type", content: url["mediaType"]], []} | acc ] diff --git a/test/pleroma/web/metadata/providers/twitter_card_test.exs b/test/pleroma/web/metadata/providers/twitter_card_test.exs index 3a2f7ca31..196bca20a 100644 --- a/test/pleroma/web/metadata/providers/twitter_card_test.exs +++ b/test/pleroma/web/metadata/providers/twitter_card_test.exs @@ -145,7 +145,11 @@ test "it renders supported types of attachments and skips unknown types" do ], []}, {:meta, [property: "twitter:player:width", content: "480"], []}, {:meta, [property: "twitter:player:height", content: "480"], []}, - {:meta, [property: "twitter:player:stream", content: "https://pleroma.gov/about/juche.webm"], []}, + {:meta, + [ + property: "twitter:player:stream", + content: "https://pleroma.gov/about/juche.webm" + ], []}, {:meta, [property: "twitter:player:stream:content_type", content: "video/webm"], []} ] == result end From d87dfcb5f0f91ad6fa9fccd47996c2bc54701553 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Mon, 7 Jun 2021 15:51:52 -0500 Subject: [PATCH 283/339] Put custom guards in Web.Utils.Guards Speeds up recompilation by removing a compile-time cycle on AdminAPI.Search --- lib/pleroma/user/query.ex | 2 +- lib/pleroma/web/admin_api/search.ex | 6 ------ lib/pleroma/web/utils/guards.ex | 13 +++++++++++++ 3 files changed, 14 insertions(+), 7 deletions(-) create mode 100644 lib/pleroma/web/utils/guards.ex diff --git a/lib/pleroma/user/query.ex b/lib/pleroma/user/query.ex index fa46545da..ac807fc79 100644 --- a/lib/pleroma/user/query.ex +++ b/lib/pleroma/user/query.ex @@ -27,7 +27,7 @@ defmodule Pleroma.User.Query do - e.g. Pleroma.User.Query.build(%{ap_id: ["http://ap_id1", "http://ap_id2"]}) """ import Ecto.Query - import Pleroma.Web.AdminAPI.Search, only: [not_empty_string: 1] + import Pleroma.Web.Utils.Guards, only: [not_empty_string: 1] alias Pleroma.FollowingRelationship alias Pleroma.User diff --git a/lib/pleroma/web/admin_api/search.ex b/lib/pleroma/web/admin_api/search.ex index eeeebdf4e..01d974479 100644 --- a/lib/pleroma/web/admin_api/search.ex +++ b/lib/pleroma/web/admin_api/search.ex @@ -10,12 +10,6 @@ defmodule Pleroma.Web.AdminAPI.Search do @page_size 50 - defmacro not_empty_string(string) do - quote do - is_binary(unquote(string)) and unquote(string) != "" - end - end - @spec user(map()) :: {:ok, [User.t()], pos_integer()} def user(params \\ %{}) do query = diff --git a/lib/pleroma/web/utils/guards.ex b/lib/pleroma/web/utils/guards.ex new file mode 100644 index 000000000..aea7b6314 --- /dev/null +++ b/lib/pleroma/web/utils/guards.ex @@ -0,0 +1,13 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Utils.Guards do + @moduledoc """ + Project-wide custom guards. + See: https://hexdocs.pm/elixir/master/patterns-and-guards.html#custom-patterns-and-guards-expressions + """ + + @doc "Checks for non-empty string" + defguard not_empty_string(string) when is_binary(string) and string != "" +end From f5ef7fe43bb42d5cd641666194f1d780499e1e09 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Mon, 7 Jun 2021 16:06:53 -0500 Subject: [PATCH 284/339] Fix test warnings --- .../controllers/config_controller_test.exs | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/test/pleroma/web/admin_api/controllers/config_controller_test.exs b/test/pleroma/web/admin_api/controllers/config_controller_test.exs index d8ca07cd3..7c786c389 100644 --- a/test/pleroma/web/admin_api/controllers/config_controller_test.exs +++ b/test/pleroma/web/admin_api/controllers/config_controller_test.exs @@ -1427,30 +1427,27 @@ test "custom instance thumbnail", %{conn: conn} do ] } - res = - assert conn - |> put_req_header("content-type", "application/json") - |> post("/api/pleroma/admin/config", %{"configs" => [params]}) - |> json_response_and_validate_schema(200) + assert conn + |> put_req_header("content-type", "application/json") + |> post("/api/pleroma/admin/config", %{"configs" => [params]}) + |> json_response_and_validate_schema(200) == + %{ + "configs" => [ + %{ + "db" => [":instance_thumbnail"], + "group" => ":pleroma", + "key" => ":instance", + "value" => params["value"] + } + ], + "need_reboot" => false + } - assert res == %{ - "configs" => [ - %{ - "db" => [":instance_thumbnail"], - "group" => ":pleroma", - "key" => ":instance", - "value" => params["value"] - } - ], - "need_reboot" => false - } - - _res = - assert conn - |> get("/api/v1/instance") - |> json_response_and_validate_schema(200) - - assert res = %{"thumbnail" => "https://example.com/media/new_thumbnail.jpg"} + assert conn + |> get("/api/v1/instance") + |> json_response_and_validate_schema(200) + |> Map.take(["thumbnail"]) == + %{"thumbnail" => "https://example.com/media/new_thumbnail.jpg"} end test "Concurrent Limiter", %{conn: conn} do From a5ae0432ed2353c714a4c212e418e6cc0ea91eb6 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Mon, 7 Jun 2021 16:09:47 -0500 Subject: [PATCH 285/339] Test was named incorrectly and did not execute --- .../pleroma/web/{shout_channel_test.ex => shout_channel_test.exs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/pleroma/web/{shout_channel_test.ex => shout_channel_test.exs} (100%) diff --git a/test/pleroma/web/shout_channel_test.ex b/test/pleroma/web/shout_channel_test.exs similarity index 100% rename from test/pleroma/web/shout_channel_test.ex rename to test/pleroma/web/shout_channel_test.exs From 017f947fc111eb98c964cd984fdb073623407b0e Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Mon, 7 Jun 2021 16:10:24 -0500 Subject: [PATCH 286/339] Channel name was incorrect. We left it as chat:public for backwards compatibility. --- test/pleroma/web/shout_channel_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/pleroma/web/shout_channel_test.exs b/test/pleroma/web/shout_channel_test.exs index a266543d2..5c86efe9f 100644 --- a/test/pleroma/web/shout_channel_test.exs +++ b/test/pleroma/web/shout_channel_test.exs @@ -14,7 +14,7 @@ defmodule Pleroma.Web.ShoutChannelTest do {:ok, _, socket} = socket(UserSocket, "", %{user_name: user.nickname}) - |> subscribe_and_join(ShoutChannel, "shout:public") + |> subscribe_and_join(ShoutChannel, "chat:public") {:ok, socket: socket} end From bdaa1d45123ae8dd7f0138aa09b96d3104e1e58e Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Mon, 7 Jun 2021 17:07:40 -0500 Subject: [PATCH 287/339] Upload.Filter: use generic types in @spec Speeds up recompilation by reducing compile-time deps --- lib/pleroma/upload/filter.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/upload/filter.ex b/lib/pleroma/upload/filter.ex index c677d4b9f..e5db2fb20 100644 --- a/lib/pleroma/upload/filter.ex +++ b/lib/pleroma/upload/filter.ex @@ -15,13 +15,13 @@ defmodule Pleroma.Upload.Filter do require Logger - @callback filter(Pleroma.Upload.t()) :: + @callback filter(upload :: struct()) :: {:ok, :filtered} | {:ok, :noop} - | {:ok, :filtered, Pleroma.Upload.t()} + | {:ok, :filtered, upload :: struct()} | {:error, any()} - @spec filter([module()], Pleroma.Upload.t()) :: {:ok, Pleroma.Upload.t()} | {:error, any()} + @spec filter([module()], upload :: struct()) :: {:ok, upload :: struct()} | {:error, any()} def filter([], upload) do {:ok, upload} From 1399b82f7be708562848031944f6caab8f193bda Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Mon, 7 Jun 2021 18:07:54 -0500 Subject: [PATCH 288/339] Create WrapperAuthenticator and simplify Authenticator behaviour Speeds up recompilation by reducing compile-time cycles --- lib/pleroma/web/auth/authenticator.ex | 63 +------------------ lib/pleroma/web/auth/helpers.ex | 33 ++++++++++ lib/pleroma/web/auth/ldap_authenticator.ex | 3 +- lib/pleroma/web/auth/pleroma_authenticator.ex | 3 +- lib/pleroma/web/auth/wrapper_authenticator.ex | 42 +++++++++++++ lib/pleroma/web/o_auth/o_auth_controller.ex | 2 +- .../web/templates/o_auth/o_auth/show.html.eex | 2 +- .../controllers/remote_follow_controller.ex | 4 +- test/pleroma/web/auth/authenticator_test.exs | 14 ++--- 9 files changed, 91 insertions(+), 75 deletions(-) create mode 100644 lib/pleroma/web/auth/helpers.ex create mode 100644 lib/pleroma/web/auth/wrapper_authenticator.ex diff --git a/lib/pleroma/web/auth/authenticator.ex b/lib/pleroma/web/auth/authenticator.ex index 84741ee11..3fe9718c4 100644 --- a/lib/pleroma/web/auth/authenticator.ex +++ b/lib/pleroma/web/auth/authenticator.ex @@ -3,68 +3,11 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Auth.Authenticator do - alias Pleroma.Registration - alias Pleroma.User - - def implementation do - Pleroma.Config.get( - Pleroma.Web.Auth.Authenticator, - Pleroma.Web.Auth.PleromaAuthenticator - ) - end - - @callback get_user(Plug.Conn.t()) :: {:ok, User.t()} | {:error, any()} - def get_user(plug), do: implementation().get_user(plug) - - @callback create_from_registration(Plug.Conn.t(), Registration.t()) :: + @callback get_user(Plug.Conn.t()) :: {:ok, user :: struct()} | {:error, any()} + @callback create_from_registration(Plug.Conn.t(), registration :: struct()) :: {:ok, User.t()} | {:error, any()} - def create_from_registration(plug, registration), - do: implementation().create_from_registration(plug, registration) - - @callback get_registration(Plug.Conn.t()) :: {:ok, Registration.t()} | {:error, any()} - def get_registration(plug), do: implementation().get_registration(plug) - + @callback get_registration(Plug.Conn.t()) :: {:ok, registration :: struct()} | {:error, any()} @callback handle_error(Plug.Conn.t(), any()) :: any() - def handle_error(plug, error), - do: implementation().handle_error(plug, error) - @callback auth_template() :: String.t() | nil - def auth_template do - # Note: `config :pleroma, :auth_template, "..."` support is deprecated - implementation().auth_template() || - Pleroma.Config.get([:auth, :auth_template], Pleroma.Config.get(:auth_template)) || - "show.html" - end - @callback oauth_consumer_template() :: String.t() | nil - def oauth_consumer_template do - implementation().oauth_consumer_template() || - Pleroma.Config.get([:auth, :oauth_consumer_template], "consumer.html") - end - - @doc "Gets user by nickname or email for auth." - @spec fetch_user(String.t()) :: User.t() | nil - def fetch_user(name) do - User.get_by_nickname_or_email(name) - end - - # Gets name and password from conn - # - @spec fetch_credentials(Plug.Conn.t() | map()) :: - {:ok, {name :: any, password :: any}} | {:error, :invalid_credentials} - def fetch_credentials(%Plug.Conn{params: params} = _), - do: fetch_credentials(params) - - def fetch_credentials(params) do - case params do - %{"authorization" => %{"name" => name, "password" => password}} -> - {:ok, {name, password}} - - %{"grant_type" => "password", "username" => name, "password" => password} -> - {:ok, {name, password}} - - _ -> - {:error, :invalid_credentials} - end - end end diff --git a/lib/pleroma/web/auth/helpers.ex b/lib/pleroma/web/auth/helpers.ex new file mode 100644 index 000000000..c566de8d4 --- /dev/null +++ b/lib/pleroma/web/auth/helpers.ex @@ -0,0 +1,33 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Auth.Helpers do + alias Pleroma.User + + @doc "Gets user by nickname or email for auth." + @spec fetch_user(String.t()) :: User.t() | nil + def fetch_user(name) do + User.get_by_nickname_or_email(name) + end + + # Gets name and password from conn + # + @spec fetch_credentials(Plug.Conn.t() | map()) :: + {:ok, {name :: any, password :: any}} | {:error, :invalid_credentials} + def fetch_credentials(%Plug.Conn{params: params} = _), + do: fetch_credentials(params) + + def fetch_credentials(params) do + case params do + %{"authorization" => %{"name" => name, "password" => password}} -> + {:ok, {name, password}} + + %{"grant_type" => "password", "username" => name, "password" => password} -> + {:ok, {name, password}} + + _ -> + {:error, :invalid_credentials} + end + end +end diff --git a/lib/pleroma/web/auth/ldap_authenticator.ex b/lib/pleroma/web/auth/ldap_authenticator.ex index 17e08a2a6..f77e8d203 100644 --- a/lib/pleroma/web/auth/ldap_authenticator.ex +++ b/lib/pleroma/web/auth/ldap_authenticator.ex @@ -7,8 +7,7 @@ defmodule Pleroma.Web.Auth.LDAPAuthenticator do require Logger - import Pleroma.Web.Auth.Authenticator, - only: [fetch_credentials: 1, fetch_user: 1] + import Pleroma.Web.Auth.Helpers, only: [fetch_credentials: 1, fetch_user: 1] @behaviour Pleroma.Web.Auth.Authenticator @base Pleroma.Web.Auth.PleromaAuthenticator diff --git a/lib/pleroma/web/auth/pleroma_authenticator.ex b/lib/pleroma/web/auth/pleroma_authenticator.ex index 401f23c9f..68472e75f 100644 --- a/lib/pleroma/web/auth/pleroma_authenticator.ex +++ b/lib/pleroma/web/auth/pleroma_authenticator.ex @@ -8,8 +8,7 @@ defmodule Pleroma.Web.Auth.PleromaAuthenticator do alias Pleroma.User alias Pleroma.Web.Plugs.AuthenticationPlug - import Pleroma.Web.Auth.Authenticator, - only: [fetch_credentials: 1, fetch_user: 1] + import Pleroma.Web.Auth.Helpers, only: [fetch_credentials: 1, fetch_user: 1] @behaviour Pleroma.Web.Auth.Authenticator diff --git a/lib/pleroma/web/auth/wrapper_authenticator.ex b/lib/pleroma/web/auth/wrapper_authenticator.ex new file mode 100644 index 000000000..c67082f7b --- /dev/null +++ b/lib/pleroma/web/auth/wrapper_authenticator.ex @@ -0,0 +1,42 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Auth.WrapperAuthenticator do + @behaviour Pleroma.Web.Auth.Authenticator + + defp implementation do + Pleroma.Config.get( + Pleroma.Web.Auth.Authenticator, + Pleroma.Web.Auth.PleromaAuthenticator + ) + end + + @impl true + def get_user(plug), do: implementation().get_user(plug) + + @impl true + def create_from_registration(plug, registration), + do: implementation().create_from_registration(plug, registration) + + @impl true + def get_registration(plug), do: implementation().get_registration(plug) + + @impl true + def handle_error(plug, error), + do: implementation().handle_error(plug, error) + + @impl true + def auth_template do + # Note: `config :pleroma, :auth_template, "..."` support is deprecated + implementation().auth_template() || + Pleroma.Config.get([:auth, :auth_template], Pleroma.Config.get(:auth_template)) || + "show.html" + end + + @impl true + def oauth_consumer_template do + implementation().oauth_consumer_template() || + Pleroma.Config.get([:auth, :oauth_consumer_template], "consumer.html") + end +end diff --git a/lib/pleroma/web/o_auth/o_auth_controller.ex b/lib/pleroma/web/o_auth/o_auth_controller.ex index 42f4d768f..b9aadc6a4 100644 --- a/lib/pleroma/web/o_auth/o_auth_controller.ex +++ b/lib/pleroma/web/o_auth/o_auth_controller.ex @@ -12,7 +12,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do alias Pleroma.Registration alias Pleroma.Repo alias Pleroma.User - alias Pleroma.Web.Auth.Authenticator + alias Pleroma.Web.Auth.WrapperAuthenticator, as: Authenticator alias Pleroma.Web.ControllerHelper alias Pleroma.Web.OAuth.App alias Pleroma.Web.OAuth.Authorization diff --git a/lib/pleroma/web/templates/o_auth/o_auth/show.html.eex b/lib/pleroma/web/templates/o_auth/o_auth/show.html.eex index 2846ec7e7..181a9519a 100644 --- a/lib/pleroma/web/templates/o_auth/o_auth/show.html.eex +++ b/lib/pleroma/web/templates/o_auth/o_auth/show.html.eex @@ -61,5 +61,5 @@ <% end %> <%= if Pleroma.Config.oauth_consumer_enabled?() do %> - <%= render @view_module, Pleroma.Web.Auth.Authenticator.oauth_consumer_template(), assigns %> + <%= render @view_module, Pleroma.Web.Auth.WrapperAuthenticator.oauth_consumer_template(), assigns %> <% end %> diff --git a/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex b/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex index 9843cc362..42d7601ed 100644 --- a/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex @@ -11,8 +11,8 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowController do alias Pleroma.MFA alias Pleroma.Object.Fetcher alias Pleroma.User - alias Pleroma.Web.Auth.Authenticator alias Pleroma.Web.Auth.TOTPAuthenticator + alias Pleroma.Web.Auth.WrapperAuthenticator alias Pleroma.Web.CommonAPI @status_types ["Article", "Event", "Note", "Video", "Page", "Question"] @@ -88,7 +88,7 @@ def do_follow(%{assigns: %{user: %User{} = user}} = conn, %{"user" => %{"id" => # def do_follow(conn, %{"authorization" => %{"name" => _, "password" => _, "id" => id}}) do with {_, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)}, - {_, {:ok, user}, _} <- {:auth, Authenticator.get_user(conn), followee}, + {_, {:ok, user}, _} <- {:auth, WrapperAuthenticator.get_user(conn), followee}, {_, _, _, false} <- {:mfa_required, followee, user, MFA.require?(user)}, {:ok, _, _, _} <- CommonAPI.follow(user, followee) do redirect(conn, to: "/users/#{followee.id}") diff --git a/test/pleroma/web/auth/authenticator_test.exs b/test/pleroma/web/auth/authenticator_test.exs index e1f30e835..26779df03 100644 --- a/test/pleroma/web/auth/authenticator_test.exs +++ b/test/pleroma/web/auth/authenticator_test.exs @@ -5,38 +5,38 @@ defmodule Pleroma.Web.Auth.AuthenticatorTest do use Pleroma.Web.ConnCase, async: true - alias Pleroma.Web.Auth.Authenticator + alias Pleroma.Web.Auth.Helpers import Pleroma.Factory describe "fetch_user/1" do test "returns user by name" do user = insert(:user) - assert Authenticator.fetch_user(user.nickname) == user + assert Helpers.fetch_user(user.nickname) == user end test "returns user by email" do user = insert(:user) - assert Authenticator.fetch_user(user.email) == user + assert Helpers.fetch_user(user.email) == user end test "returns nil" do - assert Authenticator.fetch_user("email") == nil + assert Helpers.fetch_user("email") == nil end end describe "fetch_credentials/1" do test "returns name and password from authorization params" do params = %{"authorization" => %{"name" => "test", "password" => "test-pass"}} - assert Authenticator.fetch_credentials(params) == {:ok, {"test", "test-pass"}} + assert Helpers.fetch_credentials(params) == {:ok, {"test", "test-pass"}} end test "returns name and password with grant_type 'password'" do params = %{"grant_type" => "password", "username" => "test", "password" => "test-pass"} - assert Authenticator.fetch_credentials(params) == {:ok, {"test", "test-pass"}} + assert Helpers.fetch_credentials(params) == {:ok, {"test", "test-pass"}} end test "returns error" do - assert Authenticator.fetch_credentials(%{}) == {:error, :invalid_credentials} + assert Helpers.fetch_credentials(%{}) == {:error, :invalid_credentials} end end end From 0877b120c30a69788070de8990ac46ef0cdf23b3 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Sat, 22 May 2021 11:41:55 -0500 Subject: [PATCH 289/339] Pleroma.Web.ControllerHelper.truthy_param?/1 --> Pleroma.Web.Params.truthy_param?/1 Breaks cycle in lib/pleroma/web/api_spec/operations/status_operation.ex --- lib/pleroma/web/api_spec/schemas/boolean_like.ex | 2 +- lib/pleroma/web/common_api/activity_draft.ex | 2 +- lib/pleroma/web/common_api/utils.ex | 6 +++--- lib/pleroma/web/controller_helper.ex | 14 ++------------ .../controllers/account_controller.ex | 4 ++-- lib/pleroma/web/o_auth/o_auth_controller.ex | 4 ++-- lib/pleroma/web/params.ex | 16 ++++++++++++++++ 7 files changed, 27 insertions(+), 21 deletions(-) create mode 100644 lib/pleroma/web/params.ex diff --git a/lib/pleroma/web/api_spec/schemas/boolean_like.ex b/lib/pleroma/web/api_spec/schemas/boolean_like.ex index 778158f66..1feda3baa 100644 --- a/lib/pleroma/web/api_spec/schemas/boolean_like.ex +++ b/lib/pleroma/web/api_spec/schemas/boolean_like.ex @@ -34,7 +34,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.BooleanLike do def cast(%Cast{value: value} = context) do context - |> Map.put(:value, Pleroma.Web.ControllerHelper.truthy_param?(value)) + |> Map.put(:value, Pleroma.Web.Params.truthy_param?(value)) |> Cast.ok() end end diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex index 80a9fa7bb..d750c9de3 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -223,7 +223,7 @@ defp object(draft) do end defp preview?(draft) do - preview? = Pleroma.Web.ControllerHelper.truthy_param?(draft.params[:preview]) + preview? = Pleroma.Web.Params.truthy_param?(draft.params[:preview]) %__MODULE__{draft | preview?: preview?} end diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 4cc34002d..4ba31a8b8 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -4,7 +4,6 @@ defmodule Pleroma.Web.CommonAPI.Utils do import Pleroma.Web.Gettext - import Pleroma.Web.ControllerHelper, only: [truthy_param?: 1] alias Calendar.Strftime alias Pleroma.Activity @@ -18,6 +17,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.CommonAPI.ActivityDraft alias Pleroma.Web.MediaProxy + alias Pleroma.Web.Params alias Pleroma.Web.Plugs.AuthenticationPlug require Logger @@ -160,7 +160,7 @@ def make_poll_data(%{poll: %{options: options, expires_in: expires_in}} = data) |> DateTime.add(expires_in) |> DateTime.to_iso8601() - key = if truthy_param?(data.poll[:multiple]), do: "anyOf", else: "oneOf" + key = if Params.truthy_param?(data.poll[:multiple]), do: "anyOf", else: "oneOf" poll = %{"type" => "Question", key => option_notes, "closed" => end_time} {:ok, {poll, emoji}} @@ -203,7 +203,7 @@ def make_content_html(%ActivityDraft{} = draft) do attachment_links = draft.params |> Map.get("attachment_links", Config.get([:instance, :attachment_links])) - |> truthy_param?() + |> Params.truthy_param?() content_type = get_content_type(draft.params[:content_type]) diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index 61d65e7a3..afa152482 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -6,17 +6,7 @@ defmodule Pleroma.Web.ControllerHelper do use Pleroma.Web, :controller alias Pleroma.Pagination - - # As in Mastodon API, per https://api.rubyonrails.org/classes/ActiveModel/Type/Boolean.html - @falsy_param_values [false, 0, "0", "f", "F", "false", "False", "FALSE", "off", "OFF"] - - def explicitly_falsy_param?(value), do: value in @falsy_param_values - - # Note: `nil` and `""` are considered falsy values in Pleroma - def falsy_param?(value), - do: explicitly_falsy_param?(value) or value in [nil, ""] - - def truthy_param?(value), do: not falsy_param?(value) + alias Pleroma.Web.Params def json_response(conn, status, _) when status in [204, :no_content] do conn @@ -123,6 +113,6 @@ def embed_relationships?(params) do # To do once OpenAPI transition mess is over: just `truthy_param?(params[:with_relationships])` params |> Map.get(:with_relationships, params["with_relationships"]) - |> truthy_param?() + |> Params.truthy_param?() end end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 7a1e99044..d9bb6f95e 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -8,7 +8,6 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do import Pleroma.Web.ControllerHelper, only: [ add_link_headers: 2, - truthy_param?: 1, assign_account_by_id: 2, embed_relationships?: 1, json_response: 3 @@ -25,6 +24,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do alias Pleroma.Web.MastodonAPI.MastodonAPIController alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.OAuth.OAuthController + alias Pleroma.Web.Params alias Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Web.Plugs.OAuthScopesPlug alias Pleroma.Web.Plugs.RateLimiter @@ -188,7 +188,7 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p :accepts_chat_messages ] |> Enum.reduce(%{}, fn key, acc -> - Maps.put_if_present(acc, key, params[key], &{:ok, truthy_param?(&1)}) + Maps.put_if_present(acc, key, params[key], &{:ok, Params.truthy_param?(&1)}) end) |> Maps.put_if_present(:name, params[:display_name]) |> Maps.put_if_present(:bio, params[:note]) diff --git a/lib/pleroma/web/o_auth/o_auth_controller.ex b/lib/pleroma/web/o_auth/o_auth_controller.ex index b9aadc6a4..6201d6e00 100644 --- a/lib/pleroma/web/o_auth/o_auth_controller.ex +++ b/lib/pleroma/web/o_auth/o_auth_controller.ex @@ -13,7 +13,6 @@ defmodule Pleroma.Web.OAuth.OAuthController do alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.Auth.WrapperAuthenticator, as: Authenticator - alias Pleroma.Web.ControllerHelper alias Pleroma.Web.OAuth.App alias Pleroma.Web.OAuth.Authorization alias Pleroma.Web.OAuth.MFAController @@ -23,6 +22,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do alias Pleroma.Web.OAuth.Token alias Pleroma.Web.OAuth.Token.Strategy.RefreshToken alias Pleroma.Web.OAuth.Token.Strategy.Revoke, as: RevokeToken + alias Pleroma.Web.Params alias Pleroma.Web.Plugs.RateLimiter require Logger @@ -50,7 +50,7 @@ def authorize(%Plug.Conn{} = conn, %{"authorization" => _} = params) do end def authorize(%Plug.Conn{assigns: %{token: %Token{}}} = conn, %{"force_login" => _} = params) do - if ControllerHelper.truthy_param?(params["force_login"]) do + if Params.truthy_param?(params["force_login"]) do do_authorize(conn, params) else handle_existing_authorization(conn, params) diff --git a/lib/pleroma/web/params.ex b/lib/pleroma/web/params.ex new file mode 100644 index 000000000..dd7059c89 --- /dev/null +++ b/lib/pleroma/web/params.ex @@ -0,0 +1,16 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Params do + # As in Mastodon API, per https://api.rubyonrails.org/classes/ActiveModel/Type/Boolean.html + @falsy_param_values [false, 0, "0", "f", "F", "false", "False", "FALSE", "off", "OFF"] + + defp explicitly_falsy_param?(value), do: value in @falsy_param_values + + # Note: `nil` and `""` are considered falsy values in Pleroma + defp falsy_param?(value), + do: explicitly_falsy_param?(value) or value in [nil, ""] + + def truthy_param?(value), do: not falsy_param?(value) +end From ec65b7ae294eaf7f908960950ee573bf8d038715 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Mon, 7 Jun 2021 16:01:26 -0500 Subject: [PATCH 290/339] Pleroma.Web.Params --> Pleroma.Web.Utils.Params --- lib/pleroma/web/api_spec/schemas/boolean_like.ex | 2 +- lib/pleroma/web/common_api/activity_draft.ex | 2 +- lib/pleroma/web/common_api/utils.ex | 2 +- lib/pleroma/web/controller_helper.ex | 2 +- lib/pleroma/web/mastodon_api/controllers/account_controller.ex | 2 +- lib/pleroma/web/o_auth/o_auth_controller.ex | 2 +- lib/pleroma/web/{ => utils}/params.ex | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) rename lib/pleroma/web/{ => utils}/params.ex (94%) diff --git a/lib/pleroma/web/api_spec/schemas/boolean_like.ex b/lib/pleroma/web/api_spec/schemas/boolean_like.ex index 1feda3baa..94c5020ca 100644 --- a/lib/pleroma/web/api_spec/schemas/boolean_like.ex +++ b/lib/pleroma/web/api_spec/schemas/boolean_like.ex @@ -34,7 +34,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.BooleanLike do def cast(%Cast{value: value} = context) do context - |> Map.put(:value, Pleroma.Web.Params.truthy_param?(value)) + |> Map.put(:value, Pleroma.Web.Utils.Params.truthy_param?(value)) |> Cast.ok() end end diff --git a/lib/pleroma/web/common_api/activity_draft.ex b/lib/pleroma/web/common_api/activity_draft.ex index d750c9de3..c691d71d2 100644 --- a/lib/pleroma/web/common_api/activity_draft.ex +++ b/lib/pleroma/web/common_api/activity_draft.ex @@ -223,7 +223,7 @@ defp object(draft) do end defp preview?(draft) do - preview? = Pleroma.Web.Params.truthy_param?(draft.params[:preview]) + preview? = Pleroma.Web.Utils.Params.truthy_param?(draft.params[:preview]) %__MODULE__{draft | preview?: preview?} end diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 4ba31a8b8..256d95b95 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -17,7 +17,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.CommonAPI.ActivityDraft alias Pleroma.Web.MediaProxy - alias Pleroma.Web.Params + alias Pleroma.Web.Utils.Params alias Pleroma.Web.Plugs.AuthenticationPlug require Logger diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex index afa152482..7b84b43e4 100644 --- a/lib/pleroma/web/controller_helper.ex +++ b/lib/pleroma/web/controller_helper.ex @@ -6,7 +6,7 @@ defmodule Pleroma.Web.ControllerHelper do use Pleroma.Web, :controller alias Pleroma.Pagination - alias Pleroma.Web.Params + alias Pleroma.Web.Utils.Params def json_response(conn, status, _) when status in [204, :no_content] do conn diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index d9bb6f95e..b4ec66367 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -24,7 +24,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do alias Pleroma.Web.MastodonAPI.MastodonAPIController alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.OAuth.OAuthController - alias Pleroma.Web.Params + alias Pleroma.Web.Utils.Params alias Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Web.Plugs.OAuthScopesPlug alias Pleroma.Web.Plugs.RateLimiter diff --git a/lib/pleroma/web/o_auth/o_auth_controller.ex b/lib/pleroma/web/o_auth/o_auth_controller.ex index 6201d6e00..06c706f8e 100644 --- a/lib/pleroma/web/o_auth/o_auth_controller.ex +++ b/lib/pleroma/web/o_auth/o_auth_controller.ex @@ -22,7 +22,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do alias Pleroma.Web.OAuth.Token alias Pleroma.Web.OAuth.Token.Strategy.RefreshToken alias Pleroma.Web.OAuth.Token.Strategy.Revoke, as: RevokeToken - alias Pleroma.Web.Params + alias Pleroma.Web.Utils.Params alias Pleroma.Web.Plugs.RateLimiter require Logger diff --git a/lib/pleroma/web/params.ex b/lib/pleroma/web/utils/params.ex similarity index 94% rename from lib/pleroma/web/params.ex rename to lib/pleroma/web/utils/params.ex index dd7059c89..6e0900341 100644 --- a/lib/pleroma/web/params.ex +++ b/lib/pleroma/web/utils/params.ex @@ -2,7 +2,7 @@ # Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.Web.Params do +defmodule Pleroma.Web.Utils.Params do # As in Mastodon API, per https://api.rubyonrails.org/classes/ActiveModel/Type/Boolean.html @falsy_param_values [false, 0, "0", "f", "F", "false", "False", "FALSE", "off", "OFF"] From b99f60615cd145d97f50207797ddc569e34cc3c8 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Mon, 7 Jun 2021 16:45:33 -0500 Subject: [PATCH 291/339] Fix order of Pleroma.Web.Utils.Params aliases --- lib/pleroma/web/common_api/utils.ex | 2 +- lib/pleroma/web/mastodon_api/controllers/account_controller.ex | 2 +- lib/pleroma/web/o_auth/o_auth_controller.ex | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 256d95b95..33639e695 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -17,8 +17,8 @@ defmodule Pleroma.Web.CommonAPI.Utils do alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.CommonAPI.ActivityDraft alias Pleroma.Web.MediaProxy - alias Pleroma.Web.Utils.Params alias Pleroma.Web.Plugs.AuthenticationPlug + alias Pleroma.Web.Utils.Params require Logger require Pleroma.Constants diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index b4ec66367..4cc3645d4 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -24,11 +24,11 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do alias Pleroma.Web.MastodonAPI.MastodonAPIController alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.OAuth.OAuthController - alias Pleroma.Web.Utils.Params alias Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Web.Plugs.OAuthScopesPlug alias Pleroma.Web.Plugs.RateLimiter alias Pleroma.Web.TwitterAPI.TwitterAPI + alias Pleroma.Web.Utils.Params plug(Pleroma.Web.ApiSpec.CastAndValidate) diff --git a/lib/pleroma/web/o_auth/o_auth_controller.ex b/lib/pleroma/web/o_auth/o_auth_controller.ex index 06c706f8e..6951e0253 100644 --- a/lib/pleroma/web/o_auth/o_auth_controller.ex +++ b/lib/pleroma/web/o_auth/o_auth_controller.ex @@ -22,8 +22,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do alias Pleroma.Web.OAuth.Token alias Pleroma.Web.OAuth.Token.Strategy.RefreshToken alias Pleroma.Web.OAuth.Token.Strategy.Revoke, as: RevokeToken - alias Pleroma.Web.Utils.Params alias Pleroma.Web.Plugs.RateLimiter + alias Pleroma.Web.Utils.Params require Logger From 5c27578bce7882d9ecbb1729971589d6593d9984 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Thu, 3 Jun 2021 16:58:18 -0500 Subject: [PATCH 292/339] Support metadata for video files too --- lib/pleroma/application_requirements.ex | 3 +- lib/pleroma/upload/filter/analyze_metadata.ex | 38 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/application_requirements.ex b/lib/pleroma/application_requirements.ex index ee6ee9516..a56311a65 100644 --- a/lib/pleroma/application_requirements.ex +++ b/lib/pleroma/application_requirements.ex @@ -168,7 +168,8 @@ defp check_system_commands!(:ok) do check_filter(Pleroma.Upload.Filter.Mogrify, "mogrify"), check_filter(Pleroma.Upload.Filter.Mogrifun, "mogrify"), check_filter(Pleroma.Upload.Filter.AnalyzeMetadata, "mogrify"), - check_filter(Pleroma.Upload.Filter.AnalyzeMetadata, "convert") + check_filter(Pleroma.Upload.Filter.AnalyzeMetadata, "convert"), + check_filter(Pleroma.Upload.Filter.AnalyzeMetadata, "ffprobe") ] preview_proxy_commands_status = diff --git a/lib/pleroma/upload/filter/analyze_metadata.ex b/lib/pleroma/upload/filter/analyze_metadata.ex index 8c23076d4..c89c30fc1 100644 --- a/lib/pleroma/upload/filter/analyze_metadata.ex +++ b/lib/pleroma/upload/filter/analyze_metadata.ex @@ -33,6 +33,23 @@ def filter(%Pleroma.Upload{tempfile: file, content_type: "image" <> _} = upload) end end + def filter(%Pleroma.Upload{tempfile: file, content_type: "video" <> _} = upload) do + try do + result = media_dimensions(file) + + upload = + upload + |> Map.put(:width, result.width) + |> Map.put(:height, result.height) + + {:ok, :filtered, upload} + rescue + e in ErlangError -> + Logger.warn("#{__MODULE__}: #{inspect(e)}") + {:ok, :noop} + end + end + def filter(_), do: {:ok, :noop} defp get_blurhash(file) do @@ -42,4 +59,25 @@ defp get_blurhash(file) do _ -> nil end end + + defp media_dimensions(file) do + with executable when is_binary(executable) <- System.find_executable("ffprobe"), + args = [ + "-v", + "error", + "-show_entries", + "stream=width,height", + "-of", + "csv=p=0:s=x", + file + ], + {result, 0} <- System.cmd(executable, args), + [width, height] <- + String.split(String.trim(result), "x") |> Enum.map(&String.to_integer(&1)) do + %{width: width, height: height} + else + nil -> {:error, {:ffprobe, :command_not_found}} + {:error, _} = error -> error + end + end end From 8443f82247d8e0a76009c9b4f337d2aec5b8aa5c Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 8 Jun 2021 12:56:03 -0500 Subject: [PATCH 293/339] Update scope of AnalyzeMetadata features --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index daa8f2ff6..dcb462f07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - MRF (`FollowBotPolicy`): New MRF Policy which makes a designated local Bot account attempt to follow all users in public Notes received by your instance. Users who require approving follower requests or have #nobot in their profile are excluded. - Return OAuth token `id` (primary key) in POST `/oauth/token`. -- `AnalyzeMetadata` upload filter for extracting attachment dimensions and generating blurhashes. +- `AnalyzeMetadata` upload filter for extracting image/video attachment dimensions and generating blurhashes for images. Blurhashes for videos are not generated at this time. - Attachment dimensions and blurhashes are federated when available. - Pinned posts federation From 1c4c73c6a0c95ecb75d4048f52bedc511f0d4b66 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 8 Jun 2021 14:02:56 -0500 Subject: [PATCH 294/339] Add test for AnalyzeMetadata upload filter fetching dimensions from a video --- test/fixtures/video.mp4 | Bin 0 -> 522216 bytes .../upload/filter/analyze_metadata_test.exs | 11 +++++++++++ 2 files changed, 11 insertions(+) create mode 100644 test/fixtures/video.mp4 diff --git a/test/fixtures/video.mp4 b/test/fixtures/video.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..2021e3a5b29dd0520ca85df82f0f9830034d855e GIT binary patch literal 522216 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%0Do;<w&lO|+JEKE_=5dx`-jsdZ3 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�$2fE-m9jSIo#8l78hxAQKkO8&Tu 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	HF|edPz?(6A*O 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%0D;D`{4nSUPzDCB+CU-_jllkj`~Di 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 GR|T+J!d_7T literal 0 HcmV?d00001 diff --git a/test/pleroma/upload/filter/analyze_metadata_test.exs b/test/pleroma/upload/filter/analyze_metadata_test.exs index 6f0e432ef..488743952 100644 --- a/test/pleroma/upload/filter/analyze_metadata_test.exs +++ b/test/pleroma/upload/filter/analyze_metadata_test.exs @@ -16,4 +16,15 @@ test "adds the image dimensions" do assert {:ok, :filtered, %{width: 1024, height: 768}} = AnalyzeMetadata.filter(upload) end + + test "adds the video dimensions" do + upload = %Pleroma.Upload{ + name: "coolvideo.mp4", + content_type: "video/mp4", + path: Path.absname("test/fixtures/video.mp4"), + tempfile: Path.absname("test/fixtures/video.mp4") + } + + assert {:ok, :filtered, %{width: 480, height: 480}} = AnalyzeMetadata.filter(upload) + end end From f1abe39f6f5220eae6aad84a27a917b1d9bd4439 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 8 Jun 2021 14:05:13 -0500 Subject: [PATCH 295/339] Update test names and verify blurhash is correctly generated for images --- test/pleroma/upload/filter/analyze_metadata_test.exs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/pleroma/upload/filter/analyze_metadata_test.exs b/test/pleroma/upload/filter/analyze_metadata_test.exs index 488743952..97f5fe9b2 100644 --- a/test/pleroma/upload/filter/analyze_metadata_test.exs +++ b/test/pleroma/upload/filter/analyze_metadata_test.exs @@ -6,7 +6,7 @@ defmodule Pleroma.Upload.Filter.AnalyzeMetadataTest do use Pleroma.DataCase, async: true alias Pleroma.Upload.Filter.AnalyzeMetadata - test "adds the image dimensions" do + test "adds the dimensions and blurhash for images" do upload = %Pleroma.Upload{ name: "an… image.jpg", content_type: "image/jpeg", @@ -14,10 +14,12 @@ test "adds the image dimensions" do tempfile: Path.absname("test/fixtures/image.jpg") } - assert {:ok, :filtered, %{width: 1024, height: 768}} = AnalyzeMetadata.filter(upload) + assert {:ok, :filtered, + %{width: 1024, height: 768, blurhash: "V5DI,j_NIS%eI.RDI[RS%1WDr=IVD-RoV{?Ge-tiSKkR"}} = + AnalyzeMetadata.filter(upload) end - test "adds the video dimensions" do + test "adds the dimensions for videos" do upload = %Pleroma.Upload{ name: "coolvideo.mp4", content_type: "video/mp4", From 3121ed1325cceb8ec3f8d153d3c6fa18b2951714 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 8 Jun 2021 14:49:57 -0500 Subject: [PATCH 296/339] Blurhash varies slightly by computer generating it, so just validate it wasn't nil --- test/pleroma/upload/filter/analyze_metadata_test.exs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/pleroma/upload/filter/analyze_metadata_test.exs b/test/pleroma/upload/filter/analyze_metadata_test.exs index 97f5fe9b2..4b636a684 100644 --- a/test/pleroma/upload/filter/analyze_metadata_test.exs +++ b/test/pleroma/upload/filter/analyze_metadata_test.exs @@ -14,9 +14,10 @@ test "adds the dimensions and blurhash for images" do tempfile: Path.absname("test/fixtures/image.jpg") } - assert {:ok, :filtered, - %{width: 1024, height: 768, blurhash: "V5DI,j_NIS%eI.RDI[RS%1WDr=IVD-RoV{?Ge-tiSKkR"}} = - AnalyzeMetadata.filter(upload) + {:ok, :filtered, meta} = AnalyzeMetadata.filter(upload) + + assert %{width: 1024, height: 768} = meta + assert meta.blurhash end test "adds the dimensions for videos" do From 5de65ce3e89ba2f229170ed18933c99e5caa8dff Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 8 Jun 2021 15:59:55 -0500 Subject: [PATCH 297/339] Set the correct height/width if the data is available when generating twittercard metadata --- lib/pleroma/web/metadata/providers/twitter_card.ex | 7 +++++-- .../web/metadata/providers/twitter_card_test.exs | 11 ++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/metadata/providers/twitter_card.ex b/lib/pleroma/web/metadata/providers/twitter_card.ex index 12c372d77..e28f832d4 100644 --- a/lib/pleroma/web/metadata/providers/twitter_card.ex +++ b/lib/pleroma/web/metadata/providers/twitter_card.ex @@ -80,11 +80,14 @@ defp build_attachments(id, %{data: %{"attachment" => attachments}}) do # TODO: Need the true width and height values here or Twitter renders an iFrame with # a bad aspect ratio "video" -> + height = url["height"] || 480 + width = url["width"] || 480 + [ {:meta, [property: "twitter:card", content: "player"], []}, {:meta, [property: "twitter:player", content: player_url(id)], []}, - {:meta, [property: "twitter:player:width", content: "480"], []}, - {:meta, [property: "twitter:player:height", content: "480"], []}, + {:meta, [property: "twitter:player:width", content: "#{width}"], []}, + {:meta, [property: "twitter:player:height", content: "#{height}"], []}, {:meta, [property: "twitter:player:stream", content: url["href"]], []}, {:meta, [property: "twitter:player:stream:content_type", content: url["mediaType"]], []} diff --git a/test/pleroma/web/metadata/providers/twitter_card_test.exs b/test/pleroma/web/metadata/providers/twitter_card_test.exs index 196bca20a..6d761f4e4 100644 --- a/test/pleroma/web/metadata/providers/twitter_card_test.exs +++ b/test/pleroma/web/metadata/providers/twitter_card_test.exs @@ -123,7 +123,12 @@ test "it renders supported types of attachments and skips unknown types" do }, %{ "url" => [ - %{"mediaType" => "video/webm", "href" => "https://pleroma.gov/about/juche.webm"} + %{ + "mediaType" => "video/webm", + "href" => "https://pleroma.gov/about/juche.webm", + "height" => 600, + "width" => 800 + } ] } ] @@ -143,8 +148,8 @@ test "it renders supported types of attachments and skips unknown types" do property: "twitter:player", content: Router.Helpers.o_status_url(Endpoint, :notice_player, activity.id) ], []}, - {:meta, [property: "twitter:player:width", content: "480"], []}, - {:meta, [property: "twitter:player:height", content: "480"], []}, + {:meta, [property: "twitter:player:width", content: "800"], []}, + {:meta, [property: "twitter:player:height", content: "600"], []}, {:meta, [ property: "twitter:player:stream", From 1be14cc45f915824e5796396fcdb7cb402ffe138 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Tue, 8 Jun 2021 16:07:51 -0500 Subject: [PATCH 298/339] Ignore runtime deps in Pleroma.Config.Loader with Module.concat/1 Speeds up recompilation --- lib/pleroma/config/loader.ex | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/lib/pleroma/config/loader.ex b/lib/pleroma/config/loader.ex index 9489f58c4..2a945999e 100644 --- a/lib/pleroma/config/loader.ex +++ b/lib/pleroma/config/loader.ex @@ -3,21 +3,21 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Config.Loader do - defp reject_keys, - do: [ - Pleroma.Repo, - Pleroma.Web.Endpoint, - :env, - :configurable_from_database, - :database, - :swarm - ] + # These modules are only being used as keys here (for equality check), + # so it's okay to use `Module.concat/1` to have the compiler ignore them. + @reject_keys [ + Module.concat(["Pleroma.Repo"]), + Module.concat(["Pleroma.Web.Endpoint"]), + :env, + :configurable_from_database, + :database, + :swarm + ] - defp reject_groups, - do: [ - :postgrex, - :tesla - ] + @reject_groups [ + :postgrex, + :tesla + ] if Code.ensure_loaded?(Config.Reader) do @reader Config.Reader @@ -54,7 +54,7 @@ defp filter(configs) do @spec filter_group(atom(), keyword()) :: keyword() def filter_group(group, configs) do Enum.reject(configs[group], fn {key, _v} -> - key in reject_keys() or group in reject_groups() or + key in @reject_keys or group in @reject_groups or (group == :phoenix and key == :serve_endpoints) end) end From d4ac9445cd485a4055f93360714923c3f64d9673 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 8 Jun 2021 16:19:12 -0500 Subject: [PATCH 299/339] Twittercard metadata for images should also include dimensions if available --- lib/pleroma/web/metadata/providers/twitter_card.ex | 13 ++++++------- .../web/metadata/providers/twitter_card_test.exs | 11 ++++++++++- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/web/metadata/providers/twitter_card.ex b/lib/pleroma/web/metadata/providers/twitter_card.ex index e28f832d4..bf6d4bcbe 100644 --- a/lib/pleroma/web/metadata/providers/twitter_card.ex +++ b/lib/pleroma/web/metadata/providers/twitter_card.ex @@ -55,7 +55,9 @@ defp build_attachments(id, %{data: %{"attachment" => attachments}}) do Enum.reduce(attachments, [], fn attachment, acc -> rendered_tags = Enum.reduce(attachment["url"], [], fn url, acc -> - # TODO: Add additional properties to objects when we have the data available. + height = url["height"] || 480 + width = url["width"] || 480 + case Utils.fetch_media_type(@media_types, url["mediaType"]) do "audio" -> [ @@ -73,16 +75,13 @@ defp build_attachments(id, %{data: %{"attachment" => attachments}}) do [ property: "twitter:player", content: Utils.attachment_url(url["href"]) - ], []} + ], []}, + {:meta, [property: "twitter:player:width", content: "#{width}"], []}, + {:meta, [property: "twitter:player:height", content: "#{height}"], []} | acc ] - # TODO: Need the true width and height values here or Twitter renders an iFrame with - # a bad aspect ratio "video" -> - height = url["height"] || 480 - width = url["width"] || 480 - [ {:meta, [property: "twitter:card", content: "player"], []}, {:meta, [property: "twitter:player", content: player_url(id)], []}, diff --git a/test/pleroma/web/metadata/providers/twitter_card_test.exs b/test/pleroma/web/metadata/providers/twitter_card_test.exs index 6d761f4e4..dbb15b79f 100644 --- a/test/pleroma/web/metadata/providers/twitter_card_test.exs +++ b/test/pleroma/web/metadata/providers/twitter_card_test.exs @@ -111,7 +111,14 @@ test "it renders supported types of attachments and skips unknown types" do "content" => "pleroma in a nutshell", "attachment" => [ %{ - "url" => [%{"mediaType" => "image/png", "href" => "https://pleroma.gov/tenshi.png"}] + "url" => [ + %{ + "mediaType" => "image/png", + "href" => "https://pleroma.gov/tenshi.png", + "height" => 1024, + "width" => 1280 + } + ] }, %{ "url" => [ @@ -142,6 +149,8 @@ test "it renders supported types of attachments and skips unknown types" do {:meta, [property: "twitter:description", content: "pleroma in a nutshell"], []}, {:meta, [property: "twitter:card", content: "summary_large_image"], []}, {:meta, [property: "twitter:player", content: "https://pleroma.gov/tenshi.png"], []}, + {:meta, [property: "twitter:player:width", content: "1280"], []}, + {:meta, [property: "twitter:player:height", content: "1024"], []}, {:meta, [property: "twitter:card", content: "player"], []}, {:meta, [ From aa8cc4e86e5c7a53fa8bc606dbce6c6b3a0a8c02 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 8 Jun 2021 16:31:12 -0500 Subject: [PATCH 300/339] Only use fallback for videos and only add this metadata for images if we really have it. --- .../web/metadata/providers/twitter_card.ex | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/web/metadata/providers/twitter_card.ex b/lib/pleroma/web/metadata/providers/twitter_card.ex index bf6d4bcbe..dfe477a8a 100644 --- a/lib/pleroma/web/metadata/providers/twitter_card.ex +++ b/lib/pleroma/web/metadata/providers/twitter_card.ex @@ -55,9 +55,6 @@ defp build_attachments(id, %{data: %{"attachment" => attachments}}) do Enum.reduce(attachments, [], fn attachment, acc -> rendered_tags = Enum.reduce(attachment["url"], [], fn url, acc -> - height = url["height"] || 480 - width = url["width"] || 480 - case Utils.fetch_media_type(@media_types, url["mediaType"]) do "audio" -> [ @@ -75,13 +72,16 @@ defp build_attachments(id, %{data: %{"attachment" => attachments}}) do [ property: "twitter:player", content: Utils.attachment_url(url["href"]) - ], []}, - {:meta, [property: "twitter:player:width", content: "#{width}"], []}, - {:meta, [property: "twitter:player:height", content: "#{height}"], []} + ], []} | acc ] + |> maybe_add_dimensions(url) "video" -> + # fallback to old placeholder values + height = url["height"] || 480 + width = url["width"] || 480 + [ {:meta, [property: "twitter:card", content: "player"], []}, {:meta, [property: "twitter:player", content: player_url(id)], []}, @@ -107,4 +107,20 @@ defp build_attachments(_id, _object), do: [] defp player_url(id) do Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice_player, id) end + + # Videos have problems without dimensions, but we used to not provide WxH for images. + # A default (read: incorrect) fallback for images is likely to cause rendering bugs. + defp maybe_add_dimensions(metadata, url) do + cond do + !is_nil(url["height"]) && !is_nil(url["width"]) -> + metadata ++ + [ + {:meta, [property: "twitter:player:width", content: "#{url["width"]}"], []}, + {:meta, [property: "twitter:player:height", content: "#{url["height"]}"], []} + ] + + true -> + metadata + end + end end From 4faeec2c449c73563635424b6a7d597b9222bfe2 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Tue, 8 Jun 2021 15:58:19 -0500 Subject: [PATCH 301/339] Create AdminAPI.UserView to avoid compile-time dep Speeds up recompilation --- .../web/admin_api/controllers/user_controller.ex | 2 -- lib/pleroma/web/admin_api/views/user_view.ex | 10 ++++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 lib/pleroma/web/admin_api/views/user_view.ex diff --git a/lib/pleroma/web/admin_api/controllers/user_controller.ex b/lib/pleroma/web/admin_api/controllers/user_controller.ex index d3e4c18a3..637a0e702 100644 --- a/lib/pleroma/web/admin_api/controllers/user_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/user_controller.ex @@ -45,8 +45,6 @@ defmodule Pleroma.Web.AdminAPI.UserController do when action in [:follow, :unfollow] ) - plug(:put_view, Pleroma.Web.AdminAPI.AccountView) - action_fallback(AdminAPI.FallbackController) defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.UserOperation diff --git a/lib/pleroma/web/admin_api/views/user_view.ex b/lib/pleroma/web/admin_api/views/user_view.ex new file mode 100644 index 000000000..e91265ffe --- /dev/null +++ b/lib/pleroma/web/admin_api/views/user_view.ex @@ -0,0 +1,10 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.AdminAPI.UserView do + use Pleroma.Web, :view + alias Pleroma.Web.AdminAPI + + def render(view, opts), do: AdminAPI.AccountView.render(view, opts) +end From d70db63084449e48e90288bc7484733171246625 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 8 Jun 2021 16:58:33 -0500 Subject: [PATCH 302/339] Set the correct height/width if the data is available when generating opengraph metadata --- .../web/metadata/providers/open_graph.ex | 22 +++++++++++++++++-- .../metadata/providers/open_graph_test.exs | 20 ++++++++++++++--- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/metadata/providers/open_graph.ex b/lib/pleroma/web/metadata/providers/open_graph.ex index 18ddde84b..78cef1525 100644 --- a/lib/pleroma/web/metadata/providers/open_graph.ex +++ b/lib/pleroma/web/metadata/providers/open_graph.ex @@ -69,8 +69,7 @@ defp build_attachments(%{data: %{"attachment" => attachments}}) do Enum.reduce(attachments, [], fn attachment, acc -> rendered_tags = Enum.reduce(attachment["url"], [], fn url, acc -> - # TODO: Add additional properties to objects when we have the data available. - # Also, Whatsapp only wants JPEG or PNGs. It seems that if we add a second og:image + # TODO: Whatsapp only wants JPEG or PNGs. It seems that if we add a second og:image # object when a Video or GIF is attached it will display that in Whatsapp Rich Preview. case Utils.fetch_media_type(@media_types, url["mediaType"]) do "audio" -> @@ -85,12 +84,14 @@ defp build_attachments(%{data: %{"attachment" => attachments}}) do {:meta, [property: "og:image:alt", content: attachment["name"]], []} | acc ] + |> maybe_add_dimensions(url) "video" -> [ {:meta, [property: "og:video", content: Utils.attachment_url(url["href"])], []} | acc ] + |> maybe_add_dimensions(url) _ -> acc @@ -102,4 +103,21 @@ defp build_attachments(%{data: %{"attachment" => attachments}}) do end defp build_attachments(_), do: [] + + # We can use url["mediaType"] to dynamically fill the metadata + defp maybe_add_dimensions(metadata, url) do + type = url["mediaType"] |> String.split("/") |> List.first() + + cond do + !is_nil(url["height"]) && !is_nil(url["width"]) -> + metadata ++ + [ + {:meta, [property: "og:#{type}:width", content: "#{url["width"]}"], []}, + {:meta, [property: "og:#{type}:height", content: "#{url["height"]}"], []} + ] + + true -> + metadata + end + end end diff --git a/test/pleroma/web/metadata/providers/open_graph_test.exs b/test/pleroma/web/metadata/providers/open_graph_test.exs index fc44b3cbd..f5f71cee5 100644 --- a/test/pleroma/web/metadata/providers/open_graph_test.exs +++ b/test/pleroma/web/metadata/providers/open_graph_test.exs @@ -22,7 +22,12 @@ test "it renders all supported types of attachments and skips unknown types" do "attachment" => [ %{ "url" => [ - %{"mediaType" => "image/png", "href" => "https://pleroma.gov/tenshi.png"} + %{ + "mediaType" => "image/png", + "href" => "https://pleroma.gov/tenshi.png", + "height" => 1024, + "width" => 1280 + } ] }, %{ @@ -35,7 +40,12 @@ test "it renders all supported types of attachments and skips unknown types" do }, %{ "url" => [ - %{"mediaType" => "video/webm", "href" => "https://pleroma.gov/about/juche.webm"} + %{ + "mediaType" => "video/webm", + "href" => "https://pleroma.gov/about/juche.webm", + "height" => 600, + "width" => 800 + } ] }, %{ @@ -55,11 +65,15 @@ test "it renders all supported types of attachments and skips unknown types" do assert Enum.all?( [ {:meta, [property: "og:image", content: "https://pleroma.gov/tenshi.png"], []}, + {:meta, [property: "og:image:width", content: "1280"], []}, + {:meta, [property: "og:image:height", content: "1024"], []}, {:meta, [property: "og:audio", content: "http://www.gnu.org/music/free-software-song.au"], []}, {:meta, [property: "og:video", content: "https://pleroma.gov/about/juche.webm"], - []} + []}, + {:meta, [property: "og:video:width", content: "800"], []}, + {:meta, [property: "og:video:height", content: "600"], []} ], fn element -> element in result end ) From 9cb8960284c7046d4a058c0526b55bce5ef9513b Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Tue, 8 Jun 2021 17:14:30 -0500 Subject: [PATCH 303/339] Switch OGP default type from "website" to "article" This is what Mastodon uses and might fix some link preview bugs I've encountered --- lib/pleroma/web/metadata/providers/open_graph.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/metadata/providers/open_graph.ex b/lib/pleroma/web/metadata/providers/open_graph.ex index 78cef1525..e5712ec63 100644 --- a/lib/pleroma/web/metadata/providers/open_graph.ex +++ b/lib/pleroma/web/metadata/providers/open_graph.ex @@ -32,7 +32,7 @@ def build_tags(%{ property: "og:description", content: scrubbed_content ], []}, - {:meta, [property: "og:type", content: "website"], []} + {:meta, [property: "og:type", content: "article"], []} ] ++ if attachments == [] or Metadata.activity_nsfw?(object) do [ @@ -57,7 +57,7 @@ def build_tags(%{user: user}) do ], []}, {:meta, [property: "og:url", content: user.uri || user.ap_id], []}, {:meta, [property: "og:description", content: truncated_bio], []}, - {:meta, [property: "og:type", content: "website"], []}, + {:meta, [property: "og:type", content: "article"], []}, {:meta, [property: "og:image", content: Utils.attachment_url(User.avatar_url(user))], []}, {:meta, [property: "og:image:width", content: 150], []}, {:meta, [property: "og:image:height", content: 150], []} From 45ab24f2d9b289498c3b009d9509ee7aec818eba Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Tue, 8 Jun 2021 18:03:21 -0500 Subject: [PATCH 304/339] Switch to runtime deps in Pleroma.Instances Speeds up recompilation by limiting compile cycles --- lib/pleroma/instances.ex | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/instances.ex b/lib/pleroma/instances.ex index 80addcc52..6b57e56da 100644 --- a/lib/pleroma/instances.ex +++ b/lib/pleroma/instances.ex @@ -5,13 +5,18 @@ defmodule Pleroma.Instances do @moduledoc "Instances context." - @adapter Pleroma.Instances.Instance + alias Pleroma.Instances.Instance - defdelegate filter_reachable(urls_or_hosts), to: @adapter - defdelegate reachable?(url_or_host), to: @adapter - defdelegate set_reachable(url_or_host), to: @adapter - defdelegate set_unreachable(url_or_host, unreachable_since \\ nil), to: @adapter - defdelegate get_consistently_unreachable(), to: @adapter + def filter_reachable(urls_or_hosts), do: Instance.filter_reachable(urls_or_hosts) + + def reachable?(url_or_host), do: Instance.reachable?(url_or_host) + + def set_reachable(url_or_host), do: Instance.set_reachable(url_or_host) + + def set_unreachable(url_or_host, unreachable_since \\ nil), + do: Instance.set_unreachable(url_or_host, unreachable_since) + + def get_consistently_unreachable, do: Instance.get_consistently_unreachable() def set_consistently_unreachable(url_or_host), do: set_unreachable(url_or_host, reachability_datetime_threshold()) From 67ec0e6c18ec6c84fc3aefe9ab883e8f0b01792f Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Tue, 8 Jun 2021 18:18:25 -0500 Subject: [PATCH 305/339] Switch to runtime deps in ActivityPub.SideEffects Speeds up recompilation by reducing compile cycles --- lib/pleroma/web/activity_pub/side_effects.ex | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 674356d9a..1eca1cb31 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -28,11 +28,12 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do require Logger @cachex Pleroma.Config.get([:cachex, :provider], Cachex) - @ap_streamer Pleroma.Config.get([:side_effects, :ap_streamer], ActivityPub) @logger Pleroma.Config.get([:side_effects, :logger], Logger) @behaviour Pleroma.Web.ActivityPub.SideEffects.Handling + defp ap_streamer, do: Pleroma.Config.get([:side_effects, :ap_streamer], ActivityPub) + @impl true def handle(object, meta \\ []) @@ -302,8 +303,8 @@ def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, MessageReference.delete_for_object(deleted_object) - @ap_streamer.stream_out(object) - @ap_streamer.stream_out_participations(deleted_object, user) + ap_streamer().stream_out(object) + ap_streamer().stream_out_participations(deleted_object, user) :ok else {:actor, _} -> From 45b7325b9ef8110b424df3541b321c9a220f886c Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Tue, 8 Jun 2021 19:14:12 -0500 Subject: [PATCH 306/339] Refactor skipped plugs into Pleroma.Web functions Speeds up recompilation by reducing compile cycles --- lib/pleroma/web.ex | 8 ++++++++ lib/pleroma/web/masto_fe_controller.ex | 8 ++------ .../web/mastodon_api/controllers/account_controller.ex | 5 ++--- .../web/mastodon_api/controllers/app_controller.ex | 8 +------- .../mastodon_api/controllers/custom_emoji_controller.ex | 6 +----- .../web/mastodon_api/controllers/instance_controller.ex | 6 +----- .../mastodon_api/controllers/mastodon_api_controller.ex | 6 +----- .../web/mastodon_api/controllers/status_controller.ex | 5 +---- .../web/mastodon_api/controllers/timeline_controller.ex | 3 +-- lib/pleroma/web/o_auth/o_auth_controller.ex | 5 +---- .../web/pleroma_api/controllers/account_controller.ex | 6 +----- .../web/pleroma_api/controllers/emoji_pack_controller.ex | 6 +----- lib/pleroma/web/twitter_api/controller.ex | 7 +------ 13 files changed, 22 insertions(+), 57 deletions(-) diff --git a/lib/pleroma/web.ex b/lib/pleroma/web.ex index d26931af9..5761e3b38 100644 --- a/lib/pleroma/web.ex +++ b/lib/pleroma/web.ex @@ -62,6 +62,14 @@ defp skip_plug(conn, plug_modules) do ) end + defp skip_auth(conn, _) do + skip_plug(conn, [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug]) + end + + defp skip_public_check(conn, _) do + skip_plug(conn, EnsurePublicOrAuthenticatedPlug) + end + # Executed just before actual controller action, invokes before-action hooks (callbacks) defp action(conn, params) do with %{halted: false} = conn <- diff --git a/lib/pleroma/web/masto_fe_controller.ex b/lib/pleroma/web/masto_fe_controller.ex index e788ab37a..d2460f51d 100644 --- a/lib/pleroma/web/masto_fe_controller.ex +++ b/lib/pleroma/web/masto_fe_controller.ex @@ -8,13 +8,12 @@ defmodule Pleroma.Web.MastoFEController do alias Pleroma.User alias Pleroma.Web.MastodonAPI.AuthController alias Pleroma.Web.OAuth.Token - alias Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Web.Plugs.OAuthScopesPlug plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action == :put_settings) # Note: :index action handles attempt of unauthenticated access to private instance with redirect - plug(:skip_plug, EnsurePublicOrAuthenticatedPlug when action == :index) + plug(:skip_public_check when action == :index) plug( OAuthScopesPlug, @@ -22,10 +21,7 @@ defmodule Pleroma.Web.MastoFEController do when action == :index ) - plug( - :skip_plug, - [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug] when action == :manifest - ) + plug(:skip_auth when action == :manifest) @doc "GET /web/*path" def index(conn, _params) do diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 4cc3645d4..5fcbffc34 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -24,7 +24,6 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do alias Pleroma.Web.MastodonAPI.MastodonAPIController alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.OAuth.OAuthController - alias Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Web.Plugs.OAuthScopesPlug alias Pleroma.Web.Plugs.RateLimiter alias Pleroma.Web.TwitterAPI.TwitterAPI @@ -32,9 +31,9 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do plug(Pleroma.Web.ApiSpec.CastAndValidate) - plug(:skip_plug, [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug] when action == :create) + plug(:skip_auth when action == :create) - plug(:skip_plug, EnsurePublicOrAuthenticatedPlug when action in [:show, :statuses]) + plug(:skip_public_check when action in [:show, :statuses]) plug( OAuthScopesPlug, diff --git a/lib/pleroma/web/mastodon_api/controllers/app_controller.ex b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex index dd3b39c77..a95cc52fd 100644 --- a/lib/pleroma/web/mastodon_api/controllers/app_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex @@ -14,16 +14,10 @@ defmodule Pleroma.Web.MastodonAPI.AppController do alias Pleroma.Web.OAuth.App alias Pleroma.Web.OAuth.Scopes alias Pleroma.Web.OAuth.Token - alias Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug - alias Pleroma.Web.Plugs.OAuthScopesPlug action_fallback(Pleroma.Web.MastodonAPI.FallbackController) - plug( - :skip_plug, - [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug] - when action in [:create, :verify_credentials] - ) + plug(:skip_auth when action in [:create, :verify_credentials]) plug(Pleroma.Web.ApiSpec.CastAndValidate) diff --git a/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex b/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex index d7e18dc92..31b647755 100644 --- a/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/custom_emoji_controller.ex @@ -7,11 +7,7 @@ defmodule Pleroma.Web.MastodonAPI.CustomEmojiController do plug(Pleroma.Web.ApiSpec.CastAndValidate) - plug( - :skip_plug, - [Pleroma.Web.Plugs.OAuthScopesPlug, Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug] - when action == :index - ) + plug(:skip_auth when action == :index) defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.CustomEmojiOperation diff --git a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex index c7a5267d4..5376e4594 100644 --- a/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/instance_controller.ex @@ -7,11 +7,7 @@ defmodule Pleroma.Web.MastodonAPI.InstanceController do plug(Pleroma.Web.ApiSpec.CastAndValidate) - plug( - :skip_plug, - [Pleroma.Web.Plugs.OAuthScopesPlug, Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug] - when action in [:show, :peers] - ) + plug(:skip_auth when action in [:show, :peers]) defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.InstanceOperation diff --git a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex index a1bcc91d9..a0f79f377 100644 --- a/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/mastodon_api_controller.ex @@ -15,11 +15,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do require Logger - plug( - :skip_plug, - [Pleroma.Web.Plugs.OAuthScopesPlug, Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug] - when action in [:empty_array, :empty_object] - ) + plug(:skip_auth when action in [:empty_array, :empty_object]) action_fallback(Pleroma.Web.MastodonAPI.FallbackController) diff --git a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex index 724dc5c5d..2eff4d9d0 100644 --- a/lib/pleroma/web/mastodon_api/controllers/status_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/status_controller.ex @@ -27,10 +27,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do plug(Pleroma.Web.ApiSpec.CastAndValidate) - plug( - :skip_plug, - Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug when action in [:index, :show] - ) + plug(:skip_public_check when action in [:index, :show]) @unauthenticated_access %{fallback: :proceed_unauthenticated, scopes: []} diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 845f546d4..4b49b74ca 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -12,12 +12,11 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do alias Pleroma.Pagination alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Web.Plugs.OAuthScopesPlug alias Pleroma.Web.Plugs.RateLimiter plug(Pleroma.Web.ApiSpec.CastAndValidate) - plug(:skip_plug, EnsurePublicOrAuthenticatedPlug when action in [:public, :hashtag]) + plug(:skip_public_check when action in [:public, :hashtag]) # TODO: Replace with a macro when there is a Phoenix release with the following commit in it: # https://github.com/phoenixframework/phoenix/commit/2e8c63c01fec4dde5467dbbbf9705ff9e780735e diff --git a/lib/pleroma/web/o_auth/o_auth_controller.ex b/lib/pleroma/web/o_auth/o_auth_controller.ex index 6951e0253..247d8399c 100644 --- a/lib/pleroma/web/o_auth/o_auth_controller.ex +++ b/lib/pleroma/web/o_auth/o_auth_controller.ex @@ -32,10 +32,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do plug(:fetch_session) plug(:fetch_flash) - plug(:skip_plug, [ - Pleroma.Web.Plugs.OAuthScopesPlug, - Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug - ]) + plug(:skip_auth) plug(RateLimiter, [name: :authentication] when action == :create_authorization) diff --git a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex index 6e01c5497..8e4d3e7f7 100644 --- a/lib/pleroma/web/pleroma_api/controllers/account_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/account_controller.ex @@ -11,7 +11,6 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.MastodonAPI.StatusView - alias Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Web.Plugs.OAuthScopesPlug alias Pleroma.Web.Plugs.RateLimiter @@ -29,10 +28,7 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do plug(Pleroma.Web.ApiSpec.CastAndValidate) - plug( - :skip_plug, - [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug] when action == :confirmation_resend - ) + plug(:skip_auth when action == :confirmation_resend) plug( OAuthScopesPlug, diff --git a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex index d0f677d3c..1ea44f347 100644 --- a/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/emoji_pack_controller.ex @@ -22,11 +22,7 @@ defmodule Pleroma.Web.PleromaAPI.EmojiPackController do ] ) - @skip_plugs [ - Pleroma.Web.Plugs.OAuthScopesPlug, - Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug - ] - plug(:skip_plug, @skip_plugs when action in [:index, :archive, :show]) + plug(:skip_auth when action in [:index, :archive, :show]) defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PleromaEmojiPackOperation diff --git a/lib/pleroma/web/twitter_api/controller.ex b/lib/pleroma/web/twitter_api/controller.ex index e32713311..1e78ff2c1 100644 --- a/lib/pleroma/web/twitter_api/controller.ex +++ b/lib/pleroma/web/twitter_api/controller.ex @@ -7,17 +7,12 @@ defmodule Pleroma.Web.TwitterAPI.Controller do alias Pleroma.User alias Pleroma.Web.OAuth.Token - alias Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Web.Plugs.OAuthScopesPlug alias Pleroma.Web.TwitterAPI.TokenView require Logger - plug( - :skip_plug, - [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug] when action == :confirm_email - ) - + plug(:skip_auth when action == :confirm_email) plug(:skip_plug, OAuthScopesPlug when action in [:oauth_tokens, :revoke_token]) action_fallback(:errors) From c839078a7517f6c3119cffa4eed953ea0c9334d2 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Wed, 9 Jun 2021 03:43:01 +0200 Subject: [PATCH 307/339] ObjectValidators.{Announce,EmojiReact,Like}: Fix context, actor & addressing --- .../object_validators/announce_validator.ex | 27 +++++++--- .../object_validators/common_fixes.ex | 20 +++++++- .../emoji_react_validator.ex | 31 ++++++------ .../object_validators/like_validator.ex | 50 +++++++------------ .../announce_validation_test.exs | 22 ++++---- .../like_validation_test.exs | 35 +++++++------ test/pleroma/web/activity_pub/relay_test.exs | 2 +- .../transmogrifier/announce_handling_test.exs | 23 --------- 8 files changed, 105 insertions(+), 105 deletions(-) diff --git a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex index a2f752ac3..4db76f387 100644 --- a/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/announce_validator.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator do alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Object alias Pleroma.User + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Visibility @@ -23,7 +24,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator do field(:type, :string) field(:object, ObjectValidators.ObjectID) field(:actor, ObjectValidators.ObjectID) - field(:context, :string, autogenerate: {Utils, :generate_context_id, []}) + field(:context, :string) field(:to, ObjectValidators.Recipients, default: []) field(:cc, ObjectValidators.Recipients, default: []) field(:published, ObjectValidators.DateTime) @@ -36,6 +37,10 @@ def cast_and_validate(data) do end def cast_data(data) do + data = + data + |> fix() + %__MODULE__{} |> changeset(data) end @@ -43,11 +48,21 @@ def cast_data(data) do def changeset(struct, data) do struct |> cast(data, __schema__(:fields)) - |> fix_after_cast() end - def fix_after_cast(cng) do - cng + defp fix(data) do + data = + data + |> CommonFixes.fix_actor() + |> CommonFixes.fix_activity_addressing() + + with %Object{} = object <- Object.normalize(data["object"]) do + data + |> CommonFixes.fix_activity_context(object) + |> CommonFixes.fix_object_action_recipients(object) + else + _ -> data + end end defp validate_data(data_cng) do @@ -60,7 +75,7 @@ defp validate_data(data_cng) do |> validate_announcable() end - def validate_announcable(cng) do + defp validate_announcable(cng) do with actor when is_binary(actor) <- get_field(cng, :actor), object when is_binary(object) <- get_field(cng, :object), %User{} = actor <- User.get_cached_by_ap_id(actor), @@ -91,7 +106,7 @@ def validate_announcable(cng) do end end - def validate_existing_announce(cng) do + defp validate_existing_announce(cng) do actor = get_field(cng, :actor) object = get_field(cng, :object) diff --git a/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex b/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex index c958fcc5d..9631013a7 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex @@ -4,6 +4,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes do alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.Object alias Pleroma.Object.Containment alias Pleroma.User alias Pleroma.Web.ActivityPub.Transmogrifier @@ -36,7 +37,7 @@ def fix_object_defaults(data) do |> Transmogrifier.fix_implicit_addressing(follower_collection) end - def fix_activity_addressing(activity, _meta) do + def fix_activity_addressing(activity) do %User{follower_address: follower_collection} = User.get_cached_by_ap_id(activity["actor"]) activity @@ -57,4 +58,21 @@ def fix_actor(data) do |> Map.put("actor", actor) |> Map.put("attributedTo", actor) end + + def fix_activity_context(data, %Object{data: %{"context" => object_context}}) do + data + |> Map.put("context", object_context) + end + + def fix_object_action_recipients(%{"actor" => actor} = data, %Object{data: %{"actor" => actor}}) do + to = ((data["to"] || []) -- [actor]) |> Enum.uniq() + + Map.put(data, "to", to) + end + + def fix_object_action_recipients(data, %Object{data: %{"actor" => actor}}) do + to = ((data["to"] || []) ++ [actor]) |> Enum.uniq() + + Map.put(data, "to", to) + end end diff --git a/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex b/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex index ec7566515..a18bd7540 100644 --- a/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/emoji_react_validator.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator do alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Object + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @@ -31,6 +32,10 @@ def cast_and_validate(data) do end def cast_data(data) do + data = + data + |> fix() + %__MODULE__{} |> changeset(data) end @@ -38,28 +43,24 @@ def cast_data(data) do def changeset(struct, data) do struct |> cast(data, __schema__(:fields)) - |> fix_after_cast() end - def fix_after_cast(cng) do - cng - |> fix_context() - end + defp fix(data) do + data = + data + |> CommonFixes.fix_actor() + |> CommonFixes.fix_activity_addressing() - def fix_context(cng) do - object = get_field(cng, :object) - - with nil <- get_field(cng, :context), - %Object{data: %{"context" => context}} <- Object.get_cached_by_ap_id(object) do - cng - |> put_change(:context, context) + with %Object{} = object <- Object.normalize(data["object"]) do + data + |> CommonFixes.fix_activity_context(object) + |> CommonFixes.fix_object_action_recipients(object) else - _ -> - cng + _ -> data end end - def validate_emoji(cng) do + defp validate_emoji(cng) do content = get_field(cng, :content) if Pleroma.Emoji.is_unicode_emoji?(content) do diff --git a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex index 509da507b..8b99c89b9 100644 --- a/lib/pleroma/web/activity_pub/object_validators/like_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/like_validator.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator do alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Object + alias Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes alias Pleroma.Web.ActivityPub.Utils import Ecto.Changeset @@ -31,6 +32,10 @@ def cast_and_validate(data) do end def cast_data(data) do + data = + data + |> fix() + %__MODULE__{} |> changeset(data) end @@ -38,41 +43,20 @@ def cast_data(data) do def changeset(struct, data) do struct |> cast(data, __schema__(:fields)) - |> fix_after_cast() end - def fix_after_cast(cng) do - cng - |> fix_recipients() - |> fix_context() - end + defp fix(data) do + data = + data + |> CommonFixes.fix_actor() + |> CommonFixes.fix_activity_addressing() - def fix_context(cng) do - object = get_field(cng, :object) - - with nil <- get_field(cng, :context), - %Object{data: %{"context" => context}} <- Object.get_cached_by_ap_id(object) do - cng - |> put_change(:context, context) + with %Object{} = object <- Object.normalize(data["object"]) do + data + |> CommonFixes.fix_activity_context(object) + |> CommonFixes.fix_object_action_recipients(object) else - _ -> - cng - end - end - - def fix_recipients(cng) do - to = get_field(cng, :to) - cc = get_field(cng, :cc) - object = get_field(cng, :object) - - with {[], []} <- {to, cc}, - %Object{data: %{"actor" => actor}} <- Object.get_cached_by_ap_id(object), - {:ok, actor} <- ObjectValidators.ObjectID.cast(actor) do - cng - |> put_change(:to, [actor]) - else - _ -> - cng + _ -> data end end @@ -85,7 +69,7 @@ defp validate_data(data_cng) do |> validate_existing_like() end - def validate_existing_like(%{changes: %{actor: actor, object: object}} = cng) do + defp validate_existing_like(%{changes: %{actor: actor, object: object}} = cng) do if Utils.get_existing_like(actor, %{data: %{"id" => object}}) do cng |> add_error(:actor, "already liked this object") @@ -95,5 +79,5 @@ def validate_existing_like(%{changes: %{actor: actor, object: object}} = cng) do end end - def validate_existing_like(cng), do: cng + defp validate_existing_like(cng), do: cng end diff --git a/test/pleroma/web/activity_pub/object_validators/announce_validation_test.exs b/test/pleroma/web/activity_pub/object_validators/announce_validation_test.exs index 939922127..20964e855 100644 --- a/test/pleroma/web/activity_pub/object_validators/announce_validation_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/announce_validation_test.exs @@ -33,6 +33,18 @@ test "returns ok for a valid announce", %{valid_announce: valid_announce} do assert {:ok, _object, _meta} = ObjectValidator.validate(valid_announce, []) end + test "keeps announced object context", %{valid_announce: valid_announce} do + assert %Object{data: %{"context" => object_context}} = + Object.get_cached_by_ap_id(valid_announce["object"]) + + {:ok, %{"context" => context}, _} = + valid_announce + |> Map.put("context", "https://example.org/invalid_context_id") + |> ObjectValidator.validate([]) + + assert context == object_context + end + test "returns an error if the object can't be found", %{valid_announce: valid_announce} do without_object = valid_announce @@ -51,16 +63,6 @@ test "returns an error if the object can't be found", %{valid_announce: valid_an assert {:object, {"can't find object", []}} in cng.errors end - test "returns an error if we don't have the actor", %{valid_announce: valid_announce} do - nonexisting_actor = - valid_announce - |> Map.put("actor", "https://gensokyo.2hu/users/raymoo") - - {:error, cng} = ObjectValidator.validate(nonexisting_actor, []) - - assert {:actor, {"can't find user", []}} in cng.errors - end - test "returns an error if the actor already announced the object", %{ valid_announce: valid_announce, announcer: announcer, diff --git a/test/pleroma/web/activity_pub/object_validators/like_validation_test.exs b/test/pleroma/web/activity_pub/object_validators/like_validation_test.exs index 55f67232e..e9ad817f1 100644 --- a/test/pleroma/web/activity_pub/object_validators/like_validation_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/like_validation_test.exs @@ -40,17 +40,30 @@ test "is valid for a valid object", %{valid_like: valid_like} do assert LikeValidator.cast_and_validate(valid_like).valid? end - test "sets the 'to' field to the object actor if no recipients are given", %{ + test "Add object actor from 'to' field if it doesn't owns the like", %{valid_like: valid_like} do + user = insert(:user) + + object_actor = valid_like["actor"] + + valid_like = + valid_like + |> Map.put("actor", user.ap_id) + |> Map.put("to", []) + + {:ok, object, _meta} = ObjectValidator.validate(valid_like, []) + assert object_actor in object["to"] + end + + test "Removes object actor from 'to' field if it owns the like", %{ valid_like: valid_like, user: user } do - without_recipients = + valid_like = valid_like - |> Map.delete("to") + |> Map.put("to", [user.ap_id]) - {:ok, object, _meta} = ObjectValidator.validate(without_recipients, []) - - assert object["to"] == [user.ap_id] + {:ok, object, _meta} = ObjectValidator.validate(valid_like, []) + refute user.ap_id in object["to"] end test "sets the context field to the context of the object if no context is given", %{ @@ -66,16 +79,6 @@ test "sets the context field to the context of the object if no context is given assert object["context"] == post_activity.data["context"] end - test "it errors when the actor is missing or not known", %{valid_like: valid_like} do - without_actor = Map.delete(valid_like, "actor") - - refute LikeValidator.cast_and_validate(without_actor).valid? - - with_invalid_actor = Map.put(valid_like, "actor", "invalidactor") - - refute LikeValidator.cast_and_validate(with_invalid_actor).valid? - end - test "it errors when the object is missing or not known", %{valid_like: valid_like} do without_object = Map.delete(valid_like, "object") diff --git a/test/pleroma/web/activity_pub/relay_test.exs b/test/pleroma/web/activity_pub/relay_test.exs index 2aa07d1b5..d6de7d61e 100644 --- a/test/pleroma/web/activity_pub/relay_test.exs +++ b/test/pleroma/web/activity_pub/relay_test.exs @@ -148,7 +148,7 @@ test "returns error when object is unknown" do assert {:ok, %Activity{} = activity} = Relay.publish(note) assert activity.data["type"] == "Announce" assert activity.data["actor"] == service_actor.ap_id - assert activity.data["to"] == [service_actor.follower_address] + assert service_actor.follower_address in activity.data["to"] assert called(Pleroma.Web.Federator.publish(activity)) end diff --git a/test/pleroma/web/activity_pub/transmogrifier/announce_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/announce_handling_test.exs index 1886fea3f..524acddaf 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/announce_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/announce_handling_test.exs @@ -150,27 +150,4 @@ test "it rejects incoming announces with an inlined activity from another origin assert {:error, _e} = Transmogrifier.handle_incoming(data) end - - test "it does not clobber the addressing on announce activities" do - user = insert(:user) - {:ok, activity} = CommonAPI.post(user, %{status: "hey"}) - - data = - File.read!("test/fixtures/mastodon-announce.json") - |> Jason.decode!() - |> Map.put("object", Object.normalize(activity, fetch: false).data["id"]) - |> Map.put("to", ["http://mastodon.example.org/users/admin/followers"]) - |> Map.put("cc", []) - - _user = - insert(:user, - local: false, - ap_id: data["actor"], - follower_address: "http://mastodon.example.org/users/admin/followers" - ) - - {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - - assert data["to"] == ["http://mastodon.example.org/users/admin/followers"] - end end From d0147eba78ea6aeb054f53f18c36017a7583ff5c Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Wed, 9 Jun 2021 09:28:22 -0500 Subject: [PATCH 308/339] Use eblurhash 1.1.0 from Hex --- mix.exs | 4 +--- mix.lock | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/mix.exs b/mix.exs index 5d945bf5f..afb4da1f6 100644 --- a/mix.exs +++ b/mix.exs @@ -196,9 +196,7 @@ defp deps do {:majic, git: "https://git.pleroma.social/pleroma/elixir-libraries/majic.git", ref: "289cda1b6d0d70ccb2ba508a2b0bd24638db2880"}, - {:eblurhash, - git: "https://github.com/zotonic/eblurhash.git", - ref: "04a0b76eadf4de1be17726f39b6313b88708fd12"}, + {:eblurhash, "~> 1.1.0"}, {:open_api_spex, "~> 3.10"}, ## dev & test diff --git a/mix.lock b/mix.lock index 1a0cae3ee..9665ca753 100644 --- a/mix.lock +++ b/mix.lock @@ -29,7 +29,7 @@ "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "earmark": {:hex, :earmark, "1.4.15", "2c7f924bf495ec1f65bd144b355d0949a05a254d0ec561740308a54946a67888", [:mix], [{:earmark_parser, ">= 1.4.13", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "3b1209b85bc9f3586f370f7c363f6533788fb4e51db23aa79565875e7f9999ee"}, "earmark_parser": {:hex, :earmark_parser, "1.4.13", "0c98163e7d04a15feb62000e1a891489feb29f3d10cb57d4f845c405852bbef8", [:mix], [], "hexpm", "d602c26af3a0af43d2f2645613f65841657ad6efc9f0e361c3b6c06b578214ba"}, - "eblurhash": {:git, "https://github.com/zotonic/eblurhash.git", "04a0b76eadf4de1be17726f39b6313b88708fd12", [ref: "04a0b76eadf4de1be17726f39b6313b88708fd12"]}, + "eblurhash": {:hex, :eblurhash, "1.1.0", "e10ccae762598507ebfacf0b645ed49520f2afa3e7e9943e73a91117dffce415", [:rebar3], [], "hexpm", "2e6b889d09fddd374e3c5ac57c486138768763264e99ac1074ae5fa7fc9ab51d"}, "ecto": {:hex, :ecto, "3.4.6", "08f7afad3257d6eb8613309af31037e16c36808dfda5a3cd0cb4e9738db030e4", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6f13a9e2a62e75c2dcfc7207bfc65645ab387af8360db4c89fee8b5a4bf3f70b"}, "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, "ecto_explain": {:hex, :ecto_explain, "0.1.2", "a9d504cbd4adc809911f796d5ef7ebb17a576a6d32286c3d464c015bd39d5541", [:mix], [], "hexpm", "1d0e7798ae30ecf4ce34e912e5354a0c1c832b7ebceba39298270b9a9f316330"}, From 19a49dd757ebf60e8501c481f2d2be9d5e326808 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Wed, 9 Jun 2021 09:58:29 -0500 Subject: [PATCH 309/339] Remove Metadata.Utils.attachment_url/1 This was a wasteful shortcut to MediaProxy.preview_url/1 and we don't always want the preview_url in the metadata anyway. --- lib/pleroma/web/metadata/providers/open_graph.ex | 16 ++++++++++------ .../web/metadata/providers/twitter_card.ex | 12 +++++++++--- lib/pleroma/web/metadata/utils.ex | 5 ----- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/lib/pleroma/web/metadata/providers/open_graph.ex b/lib/pleroma/web/metadata/providers/open_graph.ex index e5712ec63..75d155236 100644 --- a/lib/pleroma/web/metadata/providers/open_graph.ex +++ b/lib/pleroma/web/metadata/providers/open_graph.ex @@ -4,6 +4,7 @@ defmodule Pleroma.Web.Metadata.Providers.OpenGraph do alias Pleroma.User + alias Pleroma.Web.MediaProxy alias Pleroma.Web.Metadata alias Pleroma.Web.Metadata.Providers.Provider alias Pleroma.Web.Metadata.Utils @@ -36,8 +37,7 @@ def build_tags(%{ ] ++ if attachments == [] or Metadata.activity_nsfw?(object) do [ - {:meta, [property: "og:image", content: Utils.attachment_url(User.avatar_url(user))], - []}, + {:meta, [property: "og:image", content: MediaProxy.preview_url(User.avatar_url(user))], []}, {:meta, [property: "og:image:width", content: 150], []}, {:meta, [property: "og:image:height", content: 150], []} ] @@ -58,7 +58,7 @@ def build_tags(%{user: user}) do {:meta, [property: "og:url", content: user.uri || user.ap_id], []}, {:meta, [property: "og:description", content: truncated_bio], []}, {:meta, [property: "og:type", content: "article"], []}, - {:meta, [property: "og:image", content: Utils.attachment_url(User.avatar_url(user))], []}, + {:meta, [property: "og:image", content: MediaProxy.preview_url(User.avatar_url(user))], []}, {:meta, [property: "og:image:width", content: 150], []}, {:meta, [property: "og:image:height", content: 150], []} ] @@ -74,13 +74,17 @@ defp build_attachments(%{data: %{"attachment" => attachments}}) do case Utils.fetch_media_type(@media_types, url["mediaType"]) do "audio" -> [ - {:meta, [property: "og:audio", content: Utils.attachment_url(url["href"])], []} + {:meta, [property: "og:audio", content: MediaProxy.url(url["href"])], []} | acc ] + # Not using preview_url for this. It saves bandwidth, but the image dimensions will be wrong. + # We generate it on the fly and have no way to capture or analyze the image to get the dimensions. + # This can be an issue for apps/FEs rendering images in timelines too, but you can get clever with + # the aspect ratio metadata as a workaround. "image" -> [ - {:meta, [property: "og:image", content: Utils.attachment_url(url["href"])], []}, + {:meta, [property: "og:image", content: MediaProxy.url(url["href"])], []}, {:meta, [property: "og:image:alt", content: attachment["name"]], []} | acc ] @@ -88,7 +92,7 @@ defp build_attachments(%{data: %{"attachment" => attachments}}) do "video" -> [ - {:meta, [property: "og:video", content: Utils.attachment_url(url["href"])], []} + {:meta, [property: "og:video", content: MediaProxy.url(url["href"])], []} | acc ] |> maybe_add_dimensions(url) diff --git a/lib/pleroma/web/metadata/providers/twitter_card.ex b/lib/pleroma/web/metadata/providers/twitter_card.ex index dfe477a8a..a952d0a05 100644 --- a/lib/pleroma/web/metadata/providers/twitter_card.ex +++ b/lib/pleroma/web/metadata/providers/twitter_card.ex @@ -5,6 +5,7 @@ defmodule Pleroma.Web.Metadata.Providers.TwitterCard do alias Pleroma.User + alias Pleroma.Web.MediaProxy alias Pleroma.Web.Metadata alias Pleroma.Web.Metadata.Providers.Provider alias Pleroma.Web.Metadata.Utils @@ -48,7 +49,8 @@ defp title_tag(user) do end def image_tag(user) do - {:meta, [property: "twitter:image", content: Utils.attachment_url(User.avatar_url(user))], []} + {:meta, [property: "twitter:image", content: MediaProxy.preview_url(User.avatar_url(user))], + []} end defp build_attachments(id, %{data: %{"attachment" => attachments}}) do @@ -65,13 +67,17 @@ defp build_attachments(id, %{data: %{"attachment" => attachments}}) do | acc ] + # Not using preview_url for this. It saves bandwidth, but the image dimensions will be wrong. + # We generate it on the fly and have no way to capture or analyze the image to get the dimensions. + # This can be an issue for apps/FEs rendering images in timelines too, but you can get clever with + # the aspect ratio metadata as a workaround. "image" -> [ {:meta, [property: "twitter:card", content: "summary_large_image"], []}, {:meta, [ property: "twitter:player", - content: Utils.attachment_url(url["href"]) + content: MediaProxy.url(url["href"]) ], []} | acc ] @@ -87,7 +93,7 @@ defp build_attachments(id, %{data: %{"attachment" => attachments}}) do {:meta, [property: "twitter:player", content: player_url(id)], []}, {:meta, [property: "twitter:player:width", content: "#{width}"], []}, {:meta, [property: "twitter:player:height", content: "#{height}"], []}, - {:meta, [property: "twitter:player:stream", content: url["href"]], []}, + {:meta, [property: "twitter:player:stream", content: MediaProxy.url(url["href"])], []}, {:meta, [property: "twitter:player:stream:content_type", content: url["mediaType"]], []} | acc diff --git a/lib/pleroma/web/metadata/utils.ex b/lib/pleroma/web/metadata/utils.ex index bc31d66b9..caca42934 100644 --- a/lib/pleroma/web/metadata/utils.ex +++ b/lib/pleroma/web/metadata/utils.ex @@ -7,7 +7,6 @@ defmodule Pleroma.Web.Metadata.Utils do alias Pleroma.Emoji alias Pleroma.Formatter alias Pleroma.HTML - alias Pleroma.Web.MediaProxy def scrub_html_and_truncate(%{data: %{"content" => content}} = object) do content @@ -38,10 +37,6 @@ def scrub_html(content) when is_binary(content) do def scrub_html(content), do: content - def attachment_url(url) do - MediaProxy.preview_url(url) - end - def user_name_string(user) do "#{user.name} " <> if user.local do From 2cf648d41989dc9cf243fb0972b075726c86adad Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Wed, 9 Jun 2021 10:02:41 -0500 Subject: [PATCH 310/339] Add a video thumbnail to the OpenGraph metadata if Media Preview Proxy is enabled. --- lib/pleroma/web/metadata/providers/open_graph.ex | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/pleroma/web/metadata/providers/open_graph.ex b/lib/pleroma/web/metadata/providers/open_graph.ex index 75d155236..332684782 100644 --- a/lib/pleroma/web/metadata/providers/open_graph.ex +++ b/lib/pleroma/web/metadata/providers/open_graph.ex @@ -96,6 +96,7 @@ defp build_attachments(%{data: %{"attachment" => attachments}}) do | acc ] |> maybe_add_dimensions(url) + |> maybe_add_video_thumbnail(url) _ -> acc @@ -124,4 +125,18 @@ defp maybe_add_dimensions(metadata, url) do metadata end end + + defp maybe_add_video_thumbnail(url, metadata) do + cond do + Pleroma.Config.get([:media_preview_proxy, :enabled], false) -> + [ + {:meta, [property: "og:image:width", content: "#{url["width"]}"], []}, + {:meta, [property: "og:image:height", content: "#{url["height"]}"], []}, + {:meta, [property: "og:image", content: MediaProxy.preview_url(url["href"])], []} + ] + + true -> + metadata + end + end end From dc8fe91decd9fd94b5e1ea4fcf2f798430b4c42e Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Wed, 9 Jun 2021 10:06:44 -0500 Subject: [PATCH 311/339] Metadata.Utils.attachment_url/1 was used in this test too --- test/pleroma/web/metadata/providers/twitter_card_test.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/pleroma/web/metadata/providers/twitter_card_test.exs b/test/pleroma/web/metadata/providers/twitter_card_test.exs index dbb15b79f..1b8d27cda 100644 --- a/test/pleroma/web/metadata/providers/twitter_card_test.exs +++ b/test/pleroma/web/metadata/providers/twitter_card_test.exs @@ -9,6 +9,7 @@ defmodule Pleroma.Web.Metadata.Providers.TwitterCardTest do alias Pleroma.User alias Pleroma.Web.CommonAPI alias Pleroma.Web.Endpoint + alias Pleroma.Web.MediaProxy alias Pleroma.Web.Metadata.Providers.TwitterCard alias Pleroma.Web.Metadata.Utils alias Pleroma.Web.Router @@ -17,7 +18,7 @@ defmodule Pleroma.Web.Metadata.Providers.TwitterCardTest do test "it renders twitter card for user info" do user = insert(:user, name: "Jimmy Hendriks", bio: "born 19 March 1994") - avatar_url = Utils.attachment_url(User.avatar_url(user)) + avatar_url = MediaProxy.preview_url(User.avatar_url(user)) res = TwitterCard.build_tags(%{user: user}) assert res == [ From 86bcb87e6c58797387934cbda5ec14b81f3f5f1d Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Wed, 9 Jun 2021 11:05:24 -0500 Subject: [PATCH 312/339] Fix incorrectly ordered arguments to the function and not properly merging lists. --- lib/pleroma/web/metadata/providers/open_graph.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/metadata/providers/open_graph.ex b/lib/pleroma/web/metadata/providers/open_graph.ex index 332684782..0a90904af 100644 --- a/lib/pleroma/web/metadata/providers/open_graph.ex +++ b/lib/pleroma/web/metadata/providers/open_graph.ex @@ -126,9 +126,10 @@ defp maybe_add_dimensions(metadata, url) do end end - defp maybe_add_video_thumbnail(url, metadata) do + defp maybe_add_video_thumbnail(metadata, url) do cond do Pleroma.Config.get([:media_preview_proxy, :enabled], false) -> + metadata ++ [ {:meta, [property: "og:image:width", content: "#{url["width"]}"], []}, {:meta, [property: "og:image:height", content: "#{url["height"]}"], []}, From 2a47156b87c668d11f3f2eeee5782472c12c5279 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Wed, 9 Jun 2021 11:06:53 -0500 Subject: [PATCH 313/339] Lint --- lib/pleroma/web/metadata/providers/open_graph.ex | 16 +++++++++------- .../web/metadata/providers/twitter_card.ex | 3 ++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/web/metadata/providers/open_graph.ex b/lib/pleroma/web/metadata/providers/open_graph.ex index 0a90904af..f6c5c36d7 100644 --- a/lib/pleroma/web/metadata/providers/open_graph.ex +++ b/lib/pleroma/web/metadata/providers/open_graph.ex @@ -37,7 +37,8 @@ def build_tags(%{ ] ++ if attachments == [] or Metadata.activity_nsfw?(object) do [ - {:meta, [property: "og:image", content: MediaProxy.preview_url(User.avatar_url(user))], []}, + {:meta, [property: "og:image", content: MediaProxy.preview_url(User.avatar_url(user))], + []}, {:meta, [property: "og:image:width", content: 150], []}, {:meta, [property: "og:image:height", content: 150], []} ] @@ -58,7 +59,8 @@ def build_tags(%{user: user}) do {:meta, [property: "og:url", content: user.uri || user.ap_id], []}, {:meta, [property: "og:description", content: truncated_bio], []}, {:meta, [property: "og:type", content: "article"], []}, - {:meta, [property: "og:image", content: MediaProxy.preview_url(User.avatar_url(user))], []}, + {:meta, [property: "og:image", content: MediaProxy.preview_url(User.avatar_url(user))], + []}, {:meta, [property: "og:image:width", content: 150], []}, {:meta, [property: "og:image:height", content: 150], []} ] @@ -130,11 +132,11 @@ defp maybe_add_video_thumbnail(metadata, url) do cond do Pleroma.Config.get([:media_preview_proxy, :enabled], false) -> metadata ++ - [ - {:meta, [property: "og:image:width", content: "#{url["width"]}"], []}, - {:meta, [property: "og:image:height", content: "#{url["height"]}"], []}, - {:meta, [property: "og:image", content: MediaProxy.preview_url(url["href"])], []} - ] + [ + {:meta, [property: "og:image:width", content: "#{url["width"]}"], []}, + {:meta, [property: "og:image:height", content: "#{url["height"]}"], []}, + {:meta, [property: "og:image", content: MediaProxy.preview_url(url["href"])], []} + ] true -> metadata diff --git a/lib/pleroma/web/metadata/providers/twitter_card.ex b/lib/pleroma/web/metadata/providers/twitter_card.ex index a952d0a05..bfcacf988 100644 --- a/lib/pleroma/web/metadata/providers/twitter_card.ex +++ b/lib/pleroma/web/metadata/providers/twitter_card.ex @@ -93,7 +93,8 @@ defp build_attachments(id, %{data: %{"attachment" => attachments}}) do {:meta, [property: "twitter:player", content: player_url(id)], []}, {:meta, [property: "twitter:player:width", content: "#{width}"], []}, {:meta, [property: "twitter:player:height", content: "#{height}"], []}, - {:meta, [property: "twitter:player:stream", content: MediaProxy.url(url["href"])], []}, + {:meta, [property: "twitter:player:stream", content: MediaProxy.url(url["href"])], + []}, {:meta, [property: "twitter:player:stream:content_type", content: url["mediaType"]], []} | acc From 5f7901cc48031dc7cb552a63b77721a6457425f6 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Wed, 9 Jun 2021 11:09:14 -0500 Subject: [PATCH 314/339] Credo --- lib/pleroma/web/metadata/providers/open_graph.ex | 9 +++++---- lib/pleroma/web/metadata/providers/twitter_card.ex | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/web/metadata/providers/open_graph.ex b/lib/pleroma/web/metadata/providers/open_graph.ex index f6c5c36d7..d9f2597ae 100644 --- a/lib/pleroma/web/metadata/providers/open_graph.ex +++ b/lib/pleroma/web/metadata/providers/open_graph.ex @@ -80,10 +80,11 @@ defp build_attachments(%{data: %{"attachment" => attachments}}) do | acc ] - # Not using preview_url for this. It saves bandwidth, but the image dimensions will be wrong. - # We generate it on the fly and have no way to capture or analyze the image to get the dimensions. - # This can be an issue for apps/FEs rendering images in timelines too, but you can get clever with - # the aspect ratio metadata as a workaround. + # Not using preview_url for this. It saves bandwidth, but the image dimensions will + # be wrong. We generate it on the fly and have no way to capture or analyze the + # analyze the image to get the dimensions. This can be an issue for apps/FEs + # rendering images in timelines too, but you can get clever with the aspect ratio + # metadata as a workaround. "image" -> [ {:meta, [property: "og:image", content: MediaProxy.url(url["href"])], []}, diff --git a/lib/pleroma/web/metadata/providers/twitter_card.ex b/lib/pleroma/web/metadata/providers/twitter_card.ex index bfcacf988..8adab818d 100644 --- a/lib/pleroma/web/metadata/providers/twitter_card.ex +++ b/lib/pleroma/web/metadata/providers/twitter_card.ex @@ -67,10 +67,11 @@ defp build_attachments(id, %{data: %{"attachment" => attachments}}) do | acc ] - # Not using preview_url for this. It saves bandwidth, but the image dimensions will be wrong. - # We generate it on the fly and have no way to capture or analyze the image to get the dimensions. - # This can be an issue for apps/FEs rendering images in timelines too, but you can get clever with - # the aspect ratio metadata as a workaround. + # Not using preview_url for this. It saves bandwidth, but the image dimensions will + # be wrong. We generate it on the fly and have no way to capture or analyze the + # analyze the image to get the dimensions. This can be an issue for apps/FEs + # rendering images in timelines too, but you can get clever with the aspect ratio + # metadata as a workaround. "image" -> [ {:meta, [property: "twitter:card", content: "summary_large_image"], []}, From f37db238480f841763555418d11859e3f0a06e5e Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Wed, 9 Jun 2021 11:46:31 -0500 Subject: [PATCH 315/339] Test that videos only get image thumbnails in OGP metadata when we can produce them with Preview Proxy --- .../metadata/providers/open_graph_test.exs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/test/pleroma/web/metadata/providers/open_graph_test.exs b/test/pleroma/web/metadata/providers/open_graph_test.exs index f5f71cee5..28ca8839c 100644 --- a/test/pleroma/web/metadata/providers/open_graph_test.exs +++ b/test/pleroma/web/metadata/providers/open_graph_test.exs @@ -107,4 +107,84 @@ test "it does not render attachments if post is nsfw" do refute {:meta, [property: "og:image", content: "https://misskey.microsoft/corndog.png"], []} in result end + + test "video attachments have image thumbnail with WxH metadata with Preview Proxy enabled" do + clear_config([:media_proxy, :enabled], true) + clear_config([:media_preview_proxy, :enabled], true) + user = insert(:user) + + note = + insert(:note, %{ + data: %{ + "actor" => user.ap_id, + "id" => "https://pleroma.gov/objects/whatever", + "content" => "test video post", + "sensitive" => false, + "attachment" => [ + %{ + "url" => [ + %{ + "mediaType" => "video/webm", + "href" => "https://pleroma.gov/about/juche.webm", + "height" => 600, + "width" => 800 + } + ] + } + ] + } + }) + + result = OpenGraph.build_tags(%{object: note, url: note.data["id"], user: user}) + + assert {:meta, [property: "og:image:width", content: "800"], []} in result + assert {:meta, [property: "og:image:height", content: "600"], []} in result + + assert {:meta, + [ + property: "og:image", + content: + "http://localhost:4001/proxy/preview/LzAnlke-l5oZbNzWsrHfprX1rGw/aHR0cHM6Ly9wbGVyb21hLmdvdi9hYm91dC9qdWNoZS53ZWJt/juche.webm" + ], []} in result + end + + test "video attachments have no image thumbnail with Preview Proxy disabled" do + clear_config([:media_proxy, :enabled], true) + clear_config([:media_preview_proxy, :enabled], false) + user = insert(:user) + + note = + insert(:note, %{ + data: %{ + "actor" => user.ap_id, + "id" => "https://pleroma.gov/objects/whatever", + "content" => "test video post", + "sensitive" => false, + "attachment" => [ + %{ + "url" => [ + %{ + "mediaType" => "video/webm", + "href" => "https://pleroma.gov/about/juche.webm", + "height" => 600, + "width" => 800 + } + ] + } + ] + } + }) + + result = OpenGraph.build_tags(%{object: note, url: note.data["id"], user: user}) + + refute {:meta, [property: "og:image:width", content: "800"], []} in result + refute {:meta, [property: "og:image:height", content: "600"], []} in result + + refute {:meta, + [ + property: "og:image", + content: + "http://localhost:4001/proxy/preview/LzAnlke-l5oZbNzWsrHfprX1rGw/aHR0cHM6Ly9wbGVyb21hLmdvdi9hYm91dC9qdWNoZS53ZWJt/juche.webm" + ], []} in result + end end From d12e62c0b6f83a439c49a4bd94b4e77e53da66a1 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Wed, 9 Jun 2021 11:56:54 -0500 Subject: [PATCH 316/339] Add new Twittercard/OGP changes --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcb462f07..52d92c6d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - The `application` metadata returned with statuses is no longer hardcoded. Apps that want to display these details will now have valid data for new posts after this change. - HTTPSecurityPlug now sends a response header to opt out of Google's FLoC (Federated Learning of Cohorts) targeted advertising. - Email address is now returned if requesting user is the owner of the user account so it can be exposed in client and FE user settings UIs. +- Improved Twittercard and OpenGraph meta tag generation including thumbnails and image dimension metadata when available. ### Added From 6aa7fc15df372478fbff02730bc521fab2ccd1e3 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Wed, 9 Jun 2021 11:58:51 -0500 Subject: [PATCH 317/339] Formatting of the comment --- lib/pleroma/web/metadata/providers/open_graph.ex | 6 +++--- lib/pleroma/web/metadata/providers/twitter_card.ex | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/web/metadata/providers/open_graph.ex b/lib/pleroma/web/metadata/providers/open_graph.ex index d9f2597ae..ef4ad6885 100644 --- a/lib/pleroma/web/metadata/providers/open_graph.ex +++ b/lib/pleroma/web/metadata/providers/open_graph.ex @@ -82,9 +82,9 @@ defp build_attachments(%{data: %{"attachment" => attachments}}) do # Not using preview_url for this. It saves bandwidth, but the image dimensions will # be wrong. We generate it on the fly and have no way to capture or analyze the - # analyze the image to get the dimensions. This can be an issue for apps/FEs - # rendering images in timelines too, but you can get clever with the aspect ratio - # metadata as a workaround. + # image to get the dimensions. This can be an issue for apps/FEs rendering images + # in timelines too, but you can get clever with the aspect ratio metadata as a + # workaround. "image" -> [ {:meta, [property: "og:image", content: MediaProxy.url(url["href"])], []}, diff --git a/lib/pleroma/web/metadata/providers/twitter_card.ex b/lib/pleroma/web/metadata/providers/twitter_card.ex index 8adab818d..79183df86 100644 --- a/lib/pleroma/web/metadata/providers/twitter_card.ex +++ b/lib/pleroma/web/metadata/providers/twitter_card.ex @@ -69,9 +69,9 @@ defp build_attachments(id, %{data: %{"attachment" => attachments}}) do # Not using preview_url for this. It saves bandwidth, but the image dimensions will # be wrong. We generate it on the fly and have no way to capture or analyze the - # analyze the image to get the dimensions. This can be an issue for apps/FEs - # rendering images in timelines too, but you can get clever with the aspect ratio - # metadata as a workaround. + # image to get the dimensions. This can be an issue for apps/FEs rendering images + # in timelines too, but you can get clever with the aspect ratio metadata as a + # workaround. "image" -> [ {:meta, [property: "twitter:card", content: "summary_large_image"], []}, From 4bb578a1d76c8094db36021db0aed2dfcebd1dbc Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Sun, 23 May 2021 18:31:07 -0500 Subject: [PATCH 318/339] Add cycles test to .gitlab-ci.yml Thank you @jb55@bitcoinhackers.org for the awk syntax --- .gitlab-ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b155c81bd..88504b3e3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -154,6 +154,14 @@ analysis: script: - mix credo --strict --only=warnings,todo,fixme,consistency,readability +cycles: + stage: test + image: elixir:1.11 + script: + - mix deps.get + - mix compile + - mix xref graph --format cycles --label compile | awk '{print $0} END{exit ($0 != "No cycles found")}' + docs-deploy: stage: deploy cache: *testing_cache_policy From cefb952dffb3f6fb3e515167e58f910e7e6fc8ea Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Wed, 9 Jun 2021 13:08:24 -0500 Subject: [PATCH 319/339] CI: echo $MIX_ENV --- .gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 88504b3e3..a790d60a4 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -24,6 +24,7 @@ stages: - docker before_script: + - echo $MIX_ENV - rm -rf _build/*/lib/pleroma - apt-get update && apt-get install -y cmake - mix local.hex --force From 87cd04fe0c10f5952aa456237906e4c966e445ea Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Wed, 9 Jun 2021 13:12:33 -0500 Subject: [PATCH 320/339] Cycles CI: disable cache --- .gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a790d60a4..056af56cd 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -158,6 +158,7 @@ analysis: cycles: stage: test image: elixir:1.11 + cache: {} script: - mix deps.get - mix compile From 15e2aaa9f6e2201c46d18d8ddead922a2ef3288f Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Wed, 9 Jun 2021 13:30:19 -0500 Subject: [PATCH 321/339] Fix compile cycle in Pleroma.Tests.AuthTestController --- lib/pleroma/tests/auth_test_controller.ex | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/lib/pleroma/tests/auth_test_controller.ex b/lib/pleroma/tests/auth_test_controller.ex index ddf3fea4f..76514948b 100644 --- a/lib/pleroma/tests/auth_test_controller.ex +++ b/lib/pleroma/tests/auth_test_controller.ex @@ -9,7 +9,6 @@ defmodule Pleroma.Tests.AuthTestController do use Pleroma.Web, :controller alias Pleroma.User - alias Pleroma.Web.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Web.Plugs.OAuthScopesPlug # Serves only with proper OAuth token (:api and :authenticated_api) @@ -47,10 +46,7 @@ defmodule Pleroma.Tests.AuthTestController do # Via :authenticated_api, serves if token is present and has requested scopes # # Suggested use: as :fallback_oauth_check but open with nil :user for :api on private instances - plug( - :skip_plug, - EnsurePublicOrAuthenticatedPlug when action == :fallback_oauth_skip_publicity_check - ) + plug(:skip_public_check when action == :fallback_oauth_skip_publicity_check) plug( OAuthScopesPlug, @@ -62,11 +58,7 @@ defmodule Pleroma.Tests.AuthTestController do # Via :authenticated_api, serves if :user is set (regardless of token presence and its scopes) # # Suggested use: making an :api endpoint always accessible (e.g. email confirmation endpoint) - plug( - :skip_plug, - [OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug] - when action == :skip_oauth_skip_publicity_check - ) + plug(:skip_auth when action == :skip_oauth_skip_publicity_check) # Via :authenticated_api, always fails with 403 (endpoint is insecure) # Via :api, drops :user if present and serves if public (private instance rejects on no user) From 202ee5fd77e721c8822dd779c8b558ec8cfacfcc Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Thu, 10 Jun 2021 09:56:43 -0500 Subject: [PATCH 322/339] Add note about video thumbnails for code spelunkers unfamiliar with Media Preview Proxy --- lib/pleroma/web/metadata/providers/open_graph.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/pleroma/web/metadata/providers/open_graph.ex b/lib/pleroma/web/metadata/providers/open_graph.ex index ef4ad6885..df0cca74a 100644 --- a/lib/pleroma/web/metadata/providers/open_graph.ex +++ b/lib/pleroma/web/metadata/providers/open_graph.ex @@ -129,6 +129,8 @@ defp maybe_add_dimensions(metadata, url) do end end + # Media Preview Proxy makes thumbnails of videos without resizing, so we can trust the + # width and height of the source video. defp maybe_add_video_thumbnail(metadata, url) do cond do Pleroma.Config.get([:media_preview_proxy, :enabled], false) -> From 6b1f7f2f528824a1f5e935a14645e7731a9c2a9c Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Fri, 11 Jun 2021 08:43:36 +0200 Subject: [PATCH 323/339] docs: Use one file to describe dependencies --- docs/installation/alpine_linux_en.md | 20 +------------------ docs/installation/debian_based_en.md | 20 +------------------ docs/installation/freebsd_en.md | 4 +++- .../installation/generic_dependencies.include | 16 +++++++++++++++ docs/installation/gentoo_en.md | 4 +--- docs/installation/netbsd_en.md | 4 +++- docs/installation/openbsd_en.md | 14 +++---------- 7 files changed, 28 insertions(+), 54 deletions(-) create mode 100644 docs/installation/generic_dependencies.include diff --git a/docs/installation/alpine_linux_en.md b/docs/installation/alpine_linux_en.md index 54859bf03..13395ff25 100644 --- a/docs/installation/alpine_linux_en.md +++ b/docs/installation/alpine_linux_en.md @@ -5,25 +5,7 @@ This guide is a step-by-step installation guide for Alpine Linux. The instructio It assumes that you have administrative rights, either as root or a user with [sudo permissions](https://www.linode.com/docs/tools-reference/custom-kernels-distros/install-alpine-linux-on-your-linode/#configuration). If you want to run this guide with root, ignore the `sudo` at the beginning of the lines, unless it calls a user like `sudo -Hu pleroma`; in this case, use `su -l <username> -s $SHELL -c 'command'` instead. -### Required packages - -* `postgresql` -* `elixir` -* `erlang` -* `erlang-parsetools` -* `erlang-xmerl` -* `git` -* `file-dev` -* Development Tools -* `cmake` - -#### Optional packages used in this guide - -* `nginx` (preferred, example configs for other reverse proxies can be found in the repo) -* `certbot` (or any other ACME client for Let’s Encrypt certificates) -* `ImageMagick` -* `ffmpeg` -* `exiftool` +{! backend/installation/generic_dependencies.include !} ### Prepare the system diff --git a/docs/installation/debian_based_en.md b/docs/installation/debian_based_en.md index b8c2b8e86..b6d24a5e9 100644 --- a/docs/installation/debian_based_en.md +++ b/docs/installation/debian_based_en.md @@ -3,25 +3,7 @@ This guide will assume you are on Debian Stretch. This guide should also work with Ubuntu 16.04 and 18.04. It also assumes that you have administrative rights, either as root or a user with [sudo permissions](https://www.digitalocean.com/community/tutorials/how-to-add-delete-and-grant-sudo-privileges-to-users-on-a-debian-vps). If you want to run this guide with root, ignore the `sudo` at the beginning of the lines, unless it calls a user like `sudo -Hu pleroma`; in this case, use `su <username> -s $SHELL -c 'command'` instead. -### Required packages - -* `postgresql` (9.6+, Ubuntu 16.04 comes with 9.5, you can get a newer version from [here](https://www.postgresql.org/download/linux/ubuntu/)) -* `postgresql-contrib` (9.6+, same situtation as above) -* `elixir` (1.8+, Follow the guide to install from the Erlang Solutions repo or use [asdf](https://github.com/asdf-vm/asdf) as the pleroma user) -* `erlang-dev` -* `erlang-nox` -* `libmagic-dev` -* `git` -* `build-essential` -* `cmake` - -#### Optional packages used in this guide - -* `nginx` (preferred, example configs for other reverse proxies can be found in the repo) -* `certbot` (or any other ACME client for Let’s Encrypt certificates) -* `ImageMagick` -* `ffmpeg` -* `exiftool` +{! backend/installation/generic_dependencies.include !} ### Prepare the system diff --git a/docs/installation/freebsd_en.md b/docs/installation/freebsd_en.md index 39b8e8d66..9cbe0f203 100644 --- a/docs/installation/freebsd_en.md +++ b/docs/installation/freebsd_en.md @@ -2,7 +2,9 @@ This document was written for FreeBSD 12.1, but should be work on future releases. -## Required software +{! backend/installation/generic_dependencies.include !} + +## Installing software used in this guide This assumes the target system has `pkg(8)`. diff --git a/docs/installation/generic_dependencies.include b/docs/installation/generic_dependencies.include new file mode 100644 index 000000000..baed19de0 --- /dev/null +++ b/docs/installation/generic_dependencies.include @@ -0,0 +1,16 @@ +## Required dependencies + +* PostgreSQL 9.6+ +* Elixir 1.9+ +* Erlang OTP 22.2+ +* git +* file / libmagic +* gcc (clang might also work) +* GNU make +* CMake + +## Optionnal dependencies + +* ImageMagick +* FFmpeg +* exiftool diff --git a/docs/installation/gentoo_en.md b/docs/installation/gentoo_en.md index d649393fc..982ab52d2 100644 --- a/docs/installation/gentoo_en.md +++ b/docs/installation/gentoo_en.md @@ -3,9 +3,7 @@ This guide will assume that you have administrative rights, either as root or a user with [sudo permissions](https://wiki.gentoo.org/wiki/Sudo). Lines that begin with `#` indicate that they should be run as the superuser. Lines using `$` should be run as the indicated user, e.g. `pleroma$` should be run as the `pleroma` user. -### Configuring your hostname (optional) - -If you would like your prompt to permanently include your host/domain, change `/etc/conf.d/hostname` to your hostname. You can reboot or use the `hostname` command to make immediate changes. +{! backend/installation/generic_dependencies.include !} ### Your make.conf, package.use, and USE flags diff --git a/docs/installation/netbsd_en.md b/docs/installation/netbsd_en.md index fc56e79ce..41b3b0072 100644 --- a/docs/installation/netbsd_en.md +++ b/docs/installation/netbsd_en.md @@ -1,6 +1,8 @@ # Installing on NetBSD -## Required software +{! backend/installation/generic_dependencies.include !} + +## Installing software used in this guide pkgin should have been installed by the NetBSD installer if you selected the right options. If it isn't installed, install it using pkg_add. diff --git a/docs/installation/openbsd_en.md b/docs/installation/openbsd_en.md index 95f029180..c80c8f678 100644 --- a/docs/installation/openbsd_en.md +++ b/docs/installation/openbsd_en.md @@ -4,19 +4,11 @@ This guide describes the installation and configuration of pleroma (and the requ For any additional information regarding commands and configuration files mentioned here, check the man pages [online](https://man.openbsd.org/) or directly on your server with the man command. +{! backend/installation/generic_dependencies.include !} + +### Preparing the system #### Required software -The following packages need to be installed: - - * elixir - * gmake - * git - * postgresql-server - * postgresql-contrib - * cmake - * ffmpeg - * ImageMagick - To install them, run the following command (with doas or as root): ``` From 17f980e9abf25b005570d3b638a111b953e87ee0 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Fri, 11 Jun 2021 08:44:27 +0200 Subject: [PATCH 324/339] docs: Remove Erlang Solutions repository --- docs/installation/debian_based_en.md | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/docs/installation/debian_based_en.md b/docs/installation/debian_based_en.md index b6d24a5e9..02682e5b0 100644 --- a/docs/installation/debian_based_en.md +++ b/docs/installation/debian_based_en.md @@ -1,7 +1,7 @@ # Installing on Debian Based Distributions ## Installation -This guide will assume you are on Debian Stretch. This guide should also work with Ubuntu 16.04 and 18.04. It also assumes that you have administrative rights, either as root or a user with [sudo permissions](https://www.digitalocean.com/community/tutorials/how-to-add-delete-and-grant-sudo-privileges-to-users-on-a-debian-vps). If you want to run this guide with root, ignore the `sudo` at the beginning of the lines, unless it calls a user like `sudo -Hu pleroma`; in this case, use `su <username> -s $SHELL -c 'command'` instead. +This guide will assume you are on Debian 11 (“bullseye”) or later. This guide should also work with Ubuntu 18.04 (“Bionic Beaver”) and later. It also assumes that you have administrative rights, either as root or a user with [sudo permissions](https://www.digitalocean.com/community/tutorials/how-to-add-delete-and-grant-sudo-privileges-to-users-on-a-debian-vps). If you want to run this guide with root, ignore the `sudo` at the beginning of the lines, unless it calls a user like `sudo -Hu pleroma`; in this case, use `su <username> -s $SHELL -c 'command'` instead. {! backend/installation/generic_dependencies.include !} @@ -22,20 +22,14 @@ sudo apt install git build-essential postgresql postgresql-contrib cmake libmagi ### Install Elixir and Erlang -* Download and add the Erlang repository: - -```shell -wget -P /tmp/ https://packages.erlang-solutions.com/erlang-solutions_2.0_all.deb -sudo dpkg -i /tmp/erlang-solutions_2.0_all.deb -``` - -* Install Elixir and Erlang: +* Install Elixir and Erlang (you might need to use backports or [asdf](https://github.com/asdf-vm/asdf) on old systems): ```shell sudo apt update sudo apt install elixir erlang-dev erlang-nox ``` + ### Optional packages: [`docs/installation/optional/media_graphics_packages.md`](../installation/optional/media_graphics_packages.md) ```shell From 822196f393e8b214b03ece875af0f53e41f40528 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Fri, 11 Jun 2021 08:46:38 +0200 Subject: [PATCH 325/339] =?UTF-8?q?docs/=E2=80=A6/opt=5Fen.md:=20Reuse=20/?= =?UTF-8?q?main/=20repository=20url=20for=20the=20/community/=20repo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/installation/otp_en.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/installation/otp_en.md b/docs/installation/otp_en.md index 8e43e3239..3f67534ac 100644 --- a/docs/installation/otp_en.md +++ b/docs/installation/otp_en.md @@ -31,7 +31,7 @@ Other than things bundled in the OTP release Pleroma depends on: === "Alpine" ``` - echo "http://nl.alpinelinux.org/alpine/latest-stable/community" >> /etc/apk/repositories + awk 'NR==2' /etc/apk/repositories | sed 's/main/community/' | tee -a /etc/apk/repositories apk update apk add curl unzip ncurses postgresql postgresql-contrib nginx certbot file-dev ``` @@ -50,7 +50,6 @@ Per [`docs/installation/optional/media_graphics_packages.md`](optional/media_gra === "Alpine" ``` - echo "http://nl.alpinelinux.org/alpine/latest-stable/community" >> /etc/apk/repositories apk update apk add imagemagick ffmpeg exiftool ``` From 640e1cf09d501b5b0088cb0c3bdb123c171e730a Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Fri, 11 Jun 2021 08:45:19 -0500 Subject: [PATCH 326/339] Cycles CI: skip unless Elixir code is modified --- .gitlab-ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 056af56cd..3ac30b13d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -158,6 +158,11 @@ analysis: cycles: stage: test image: elixir:1.11 + only: + changes: + - "**/*.ex" + - "**/*.exs" + - "mix.lock" cache: {} script: - mix deps.get From a851a24036e07db99f9d893ec9043f0d826ca877 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Tue, 22 Jun 2021 11:12:53 +0200 Subject: [PATCH 327/339] Downgrade Plug to 1.10.x, revert upload_limit tuple to function change This should fix setting the upload limit in the database as found in: https://queer.hacktivis.me/notice/A8XUZp74Cg7eYNEMxU This reverts commit 7d350b73f58664eb822efaa5f522fcf2bd38f669. --- lib/pleroma/web/endpoint.ex | 2 +- mix.exs | 3 +++ mix.lock | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex index 7591d0ae5..8e274de88 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -102,7 +102,7 @@ defmodule Pleroma.Web.Endpoint do plug(Plug.Parsers, parsers: [ :urlencoded, - {:multipart, length: Config.get([:instance, :upload_limit])}, + {:multipart, length: {Config, :get, [[:instance, :upload_limit]]}}, :json ], pass: ["*/*"], diff --git a/mix.exs b/mix.exs index afb4da1f6..a0a6106a9 100644 --- a/mix.exs +++ b/mix.exs @@ -199,6 +199,9 @@ defp deps do {:eblurhash, "~> 1.1.0"}, {:open_api_spex, "~> 3.10"}, + # indirect dependency version override + {:plug, "~> 1.10.4", override: true}, + ## dev & test {:ex_doc, "~> 0.22", only: :dev, runtime: false}, {:ex_machina, "~> 2.4", only: :test}, diff --git a/mix.lock b/mix.lock index 9665ca753..7a1dbb22c 100644 --- a/mix.lock +++ b/mix.lock @@ -95,7 +95,7 @@ "phoenix_html": {:hex, :phoenix_html, "2.14.3", "51f720d0d543e4e157ff06b65de38e13303d5778a7919bcc696599e5934271b8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "efd697a7fff35a13eeeb6b43db884705cba353a1a41d127d118fda5f90c8e80f"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, "phoenix_swoosh": {:hex, :phoenix_swoosh, "0.3.3", "039435dd975f7e55953525b88f1d596f26c6141412584c16f4db109708a8ee68", [:mix], [{:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.0", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "4a540cea32e05356541737033d666ee7fea7700eb2101bf76783adbfe06601cd"}, - "plug": {:hex, :plug, "1.11.1", "f2992bac66fdae679453c9e86134a4201f6f43a687d8ff1cd1b2862d53c80259", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "23524e4fefbb587c11f0833b3910bfb414bf2e2534d61928e920f54e3a1b881f"}, + "plug": {:hex, :plug, "1.10.4", "41eba7d1a2d671faaf531fa867645bd5a3dce0957d8e2a3f398ccff7d2ef017f", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad1e233fe73d2eec56616568d260777b67f53148a999dc2d048f4eb9778fe4a0"}, "plug_cowboy": {:hex, :plug_cowboy, "2.5.0", "51c998f788c4e68fc9f947a5eba8c215fbb1d63a520f7604134cab0270ea6513", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5b2c8925a5e2587446f33810a58c01e66b3c345652eeec809b76ba007acde71a"}, "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, "plug_static_index_html": {:hex, :plug_static_index_html, "1.0.0", "840123d4d3975585133485ea86af73cb2600afd7f2a976f9f5fd8b3808e636a0", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "79fd4fcf34d110605c26560cbae8f23c603ec4158c08298bd4360fdea90bb5cf"}, From fc6ab78a84b1ef384fa48349e792921364de5df9 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Tue, 22 Jun 2021 12:25:25 +0200 Subject: [PATCH 328/339] Add test on changing [:instance, :upload_limit] --- .../api_spec/operations/media_operation.ex | 2 + .../controllers/media_controller_test.exs | 55 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/lib/pleroma/web/api_spec/operations/media_operation.ex b/lib/pleroma/web/api_spec/operations/media_operation.ex index 1e245b291..451b6510f 100644 --- a/lib/pleroma/web/api_spec/operations/media_operation.ex +++ b/lib/pleroma/web/api_spec/operations/media_operation.ex @@ -24,6 +24,7 @@ def create_operation do requestBody: Helpers.request_body("Parameters", create_request()), responses: %{ 200 => Operation.response("Media", "application/json", Attachment), + 400 => Operation.response("Media", "application/json", ApiError), 401 => Operation.response("Media", "application/json", ApiError), 422 => Operation.response("Media", "application/json", ApiError) } @@ -121,6 +122,7 @@ def create2_operation do requestBody: Helpers.request_body("Parameters", create_request()), responses: %{ 202 => Operation.response("Media", "application/json", Attachment), + 400 => Operation.response("Media", "application/json", ApiError), 422 => Operation.response("Media", "application/json", ApiError), 500 => Operation.response("Media", "application/json", ApiError) } diff --git a/test/pleroma/web/mastodon_api/controllers/media_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/media_controller_test.exs index 39d7f99f6..ff988a7fd 100644 --- a/test/pleroma/web/mastodon_api/controllers/media_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/media_controller_test.exs @@ -5,6 +5,8 @@ defmodule Pleroma.Web.MastodonAPI.MediaControllerTest do use Pleroma.Web.ConnCase + import ExUnit.CaptureLog + alias Pleroma.Object alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub @@ -67,6 +69,59 @@ test "/api/v2/media", %{conn: conn, user: user, image: image} do object = Object.get_by_id(media["id"]) assert object.data["actor"] == user.ap_id end + + test "/api/v2/media, upload_limit", %{conn: conn, user: user} do + desc = "Description of the binary" + + upload_limit = Config.get([:instance, :upload_limit]) * 8 + 8 + + assert :ok == + File.write(Path.absname("test/tmp/large_binary.data"), <<0::size(upload_limit)>>) + + large_binary = %Plug.Upload{ + content_type: nil, + path: Path.absname("test/tmp/large_binary.data"), + filename: "large_binary.data" + } + + assert capture_log(fn -> + assert %{"error" => "file_too_large"} = + conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/v2/media", %{ + "file" => large_binary, + "description" => desc + }) + |> json_response_and_validate_schema(400) + end) =~ + "[error] Elixir.Pleroma.Upload store (using Pleroma.Uploaders.Local) failed: :file_too_large" + + clear_config([:instance, :upload_limit], upload_limit) + + assert response = + conn + |> put_req_header("content-type", "multipart/form-data") + |> post("/api/v2/media", %{ + "file" => large_binary, + "description" => desc + }) + |> json_response_and_validate_schema(202) + + assert media_id = response["id"] + + %{conn: conn} = oauth_access(["read:media"], user: user) + + media = + conn + |> get("/api/v1/media/#{media_id}") + |> json_response_and_validate_schema(200) + + assert media["type"] == "unknown" + assert media["description"] == desc + assert media["id"] + + assert :ok == File.rm(Path.absname("test/tmp/large_binary.data")) + end end describe "Update media description" do From 54af527759a222fff4adc7ab52425f4e1085eb2c Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Wed, 23 Jun 2021 13:02:41 -0500 Subject: [PATCH 329/339] Upgrade Ecto to v3.6.2, remove deprecated ecto_explain --- lib/pleroma/repo.ex | 2 -- mix.exs | 3 +-- mix.lock | 5 ++--- test/mix/tasks/pleroma/ecto/migrate_test.exs | 2 +- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/repo.ex b/lib/pleroma/repo.ex index b8ea06e33..61b64ed3e 100644 --- a/lib/pleroma/repo.ex +++ b/lib/pleroma/repo.ex @@ -8,8 +8,6 @@ defmodule Pleroma.Repo do adapter: Ecto.Adapters.Postgres, migration_timestamps: [type: :naive_datetime_usec] - use Ecto.Explain - import Ecto.Query require Logger diff --git a/mix.exs b/mix.exs index afb4da1f6..92b76d70c 100644 --- a/mix.exs +++ b/mix.exs @@ -121,8 +121,7 @@ defp deps do {:phoenix_pubsub, "~> 2.0"}, {:phoenix_ecto, "~> 4.0"}, {:ecto_enum, "~> 1.4"}, - {:ecto_explain, "~> 0.1.2"}, - {:ecto_sql, "~> 3.4.4"}, + {:ecto_sql, "~> 3.6.2"}, {:postgrex, ">= 0.15.5"}, {:oban, "~> 2.3.4"}, {:gettext, "~> 0.18"}, diff --git a/mix.lock b/mix.lock index 9665ca753..a5b9cb80f 100644 --- a/mix.lock +++ b/mix.lock @@ -30,10 +30,9 @@ "earmark": {:hex, :earmark, "1.4.15", "2c7f924bf495ec1f65bd144b355d0949a05a254d0ec561740308a54946a67888", [:mix], [{:earmark_parser, ">= 1.4.13", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "3b1209b85bc9f3586f370f7c363f6533788fb4e51db23aa79565875e7f9999ee"}, "earmark_parser": {:hex, :earmark_parser, "1.4.13", "0c98163e7d04a15feb62000e1a891489feb29f3d10cb57d4f845c405852bbef8", [:mix], [], "hexpm", "d602c26af3a0af43d2f2645613f65841657ad6efc9f0e361c3b6c06b578214ba"}, "eblurhash": {:hex, :eblurhash, "1.1.0", "e10ccae762598507ebfacf0b645ed49520f2afa3e7e9943e73a91117dffce415", [:rebar3], [], "hexpm", "2e6b889d09fddd374e3c5ac57c486138768763264e99ac1074ae5fa7fc9ab51d"}, - "ecto": {:hex, :ecto, "3.4.6", "08f7afad3257d6eb8613309af31037e16c36808dfda5a3cd0cb4e9738db030e4", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6f13a9e2a62e75c2dcfc7207bfc65645ab387af8360db4c89fee8b5a4bf3f70b"}, + "ecto": {:hex, :ecto, "3.6.2", "efdf52acfc4ce29249bab5417415bd50abd62db7b0603b8bab0d7b996548c2bc", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "efad6dfb04e6f986b8a3047822b0f826d9affe8e4ebdd2aeedbfcb14fd48884e"}, "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, - "ecto_explain": {:hex, :ecto_explain, "0.1.2", "a9d504cbd4adc809911f796d5ef7ebb17a576a6d32286c3d464c015bd39d5541", [:mix], [], "hexpm", "1d0e7798ae30ecf4ce34e912e5354a0c1c832b7ebceba39298270b9a9f316330"}, - "ecto_sql": {:hex, :ecto_sql, "3.4.5", "30161f81b167d561a9a2df4329c10ae05ff36eca7ccc84628f2c8b9fa1e43323", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.0", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "31990c6a3579b36a3c0841d34a94c275e727de8b84f58509da5f1b2032c98ac2"}, + "ecto_sql": {:hex, :ecto_sql, "3.6.2", "9526b5f691701a5181427634c30655ac33d11e17e4069eff3ae1176c764e0ba3", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.6.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.4.0 or ~> 0.5.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5ec9d7e6f742ea39b63aceaea9ac1d1773d574ea40df5a53ef8afbd9242fdb6b"}, "eimp": {:hex, :eimp, "1.0.14", "fc297f0c7e2700457a95a60c7010a5f1dcb768a083b6d53f49cd94ab95a28f22", [:rebar3], [{:p1_utils, "1.0.18", [hex: :p1_utils, repo: "hexpm", optional: false]}], "hexpm", "501133f3112079b92d9e22da8b88bf4f0e13d4d67ae9c15c42c30bd25ceb83b6"}, "elixir_make": {:hex, :elixir_make, "0.6.2", "7dffacd77dec4c37b39af867cedaabb0b59f6a871f89722c25b28fcd4bd70530", [:mix], [], "hexpm", "03e49eadda22526a7e5279d53321d1cced6552f344ba4e03e619063de75348d9"}, "esshd": {:hex, :esshd, "0.1.1", "d4dd4c46698093a40a56afecce8a46e246eb35463c457c246dacba2e056f31b5", [:mix], [], "hexpm", "d73e341e3009d390aa36387dc8862860bf9f874c94d9fd92ade2926376f49981"}, diff --git a/test/mix/tasks/pleroma/ecto/migrate_test.exs b/test/mix/tasks/pleroma/ecto/migrate_test.exs index 5bdfd8f30..3bfdde1c0 100644 --- a/test/mix/tasks/pleroma/ecto/migrate_test.exs +++ b/test/mix/tasks/pleroma/ecto/migrate_test.exs @@ -13,7 +13,7 @@ test "ecto.migrate info message" do assert capture_log(fn -> Mix.Tasks.Pleroma.Ecto.Migrate.run() - end) =~ "[info] Already up" + end) =~ "[info] Migrations already up" Logger.configure(level: level) end From 281806de75ae5b0cad0c9b0f5dbc7c01c0b64f93 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Thu, 24 Jun 2021 21:00:23 -0500 Subject: [PATCH 330/339] Activity deletion: fix FunctionClauseError #2686 --- lib/pleroma/activity.ex | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 53beca5e6..7e36c1b53 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -313,13 +313,15 @@ def delete_all_by_object_ap_id(id) when is_binary(id) do def delete_all_by_object_ap_id(_), do: nil - defp purge_web_resp_cache(%Activity{} = activity) do - %{path: path} = URI.parse(activity.data["id"]) - @cachex.del(:web_resp_cache, path) + defp purge_web_resp_cache(%Activity{data: %{"id" => id}} = activity) when is_binary(id) do + with %{path: path} <- URI.parse(id) do + @cachex.del(:web_resp_cache, path) + end + activity end - defp purge_web_resp_cache(nil), do: nil + defp purge_web_resp_cache(activity), do: activity def follow_accepted?( %Activity{data: %{"type" => "Follow", "object" => followed_ap_id}} = activity From be2da95c36c14ac42eee4009c6e3e803bafd3d2c Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Tue, 29 Jun 2021 21:45:38 -0500 Subject: [PATCH 331/339] Correctly purge a remote user --- lib/pleroma/user.ex | 16 ++++++++++------ test/pleroma/user_test.exs | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 9942617d8..aebb5da95 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1713,6 +1713,12 @@ def purge_user_changeset(user) do }) end + def purge(%User{} = user) do + user + |> purge_user_changeset() + |> update_and_set_cache() + end + def delete(users) when is_list(users) do for user <- users, do: delete(user) end @@ -1726,9 +1732,9 @@ defp delete_and_invalidate_cache(%User{} = user) do Repo.delete(user) end - defp delete_or_deactivate(%User{local: false} = user), do: delete_and_invalidate_cache(user) + defp delete_or_purge(%User{local: false} = user), do: purge(user) - defp delete_or_deactivate(%User{local: true} = user) do + defp delete_or_purge(%User{local: true} = user) do status = account_status(user) case status do @@ -1739,9 +1745,7 @@ defp delete_or_deactivate(%User{local: true} = user) do delete_and_invalidate_cache(user) _ -> - user - |> purge_user_changeset() - |> update_and_set_cache() + purge(user) end end @@ -1769,7 +1773,7 @@ def perform(:delete, %User{} = user) do delete_outgoing_pending_follow_requests(user) - delete_or_deactivate(user) + delete_or_purge(user) end def perform(:set_activation_async, user, status), do: set_activation(user, status) diff --git a/test/pleroma/user_test.exs b/test/pleroma/user_test.exs index 6f5bcab57..529f837e8 100644 --- a/test/pleroma/user_test.exs +++ b/test/pleroma/user_test.exs @@ -1684,6 +1684,24 @@ test "delete/1 purges a user when they wouldn't be fully deleted" do } = user end + test "delete/1 purges a remote user" do + user = + insert(:user, %{ + name: "qqqqqqq", + avatar: %{"a" => "b"}, + banner: %{"a" => "b"}, + local: false + }) + + {:ok, job} = User.delete(user) + {:ok, _} = ObanHelpers.perform(job) + user = User.get_by_id(user.id) + + assert user.name == nil + assert user.avatar == %{} + assert user.banner == %{} + end + test "get_public_key_for_ap_id fetches a user that's not in the db" do assert {:ok, _key} = User.get_public_key_for_ap_id("http://mastodon.example.org/users/admin") end From c6d4133727ba623d4c96358e3c4de5f2194d07f8 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Tue, 29 Jun 2021 22:30:48 -0500 Subject: [PATCH 332/339] Deletions: purge the user immediately --- lib/pleroma/user.ex | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index aebb5da95..406a7f5f9 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1724,31 +1724,27 @@ def delete(users) when is_list(users) do end def delete(%User{} = user) do + purge(user) BackgroundWorker.enqueue("delete_user", %{"user_id" => user.id}) end - defp delete_and_invalidate_cache(%User{} = user) do + defp delete_from_db(%User{} = user) do invalidate_cache(user) Repo.delete(user) end - defp delete_or_purge(%User{local: false} = user), do: purge(user) - - defp delete_or_purge(%User{local: true} = user) do + defp maybe_delete_from_db(%User{local: true} = user) do status = account_status(user) - case status do - :confirmation_pending -> - delete_and_invalidate_cache(user) - - :approval_pending -> - delete_and_invalidate_cache(user) - - _ -> - purge(user) + if status in [:confirmation_pending, :approval_pending] do + delete_from_db(user) + else + {:ok, user} end end + defp maybe_delete_from_db(user), do: {:ok, user} + def perform(:force_password_reset, user), do: force_password_reset(user) @spec perform(atom(), User.t()) :: {:ok, User.t()} @@ -1770,10 +1766,9 @@ def perform(:delete, %User{} = user) do delete_user_activities(user) delete_notifications_from_user_activities(user) - delete_outgoing_pending_follow_requests(user) - delete_or_purge(user) + maybe_delete_from_db(user) end def perform(:set_activation_async, user, status), do: set_activation(user, status) From 01c2d2a29670d8b3a4acee06c5f91b52e371fd00 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Tue, 29 Jun 2021 22:53:33 -0500 Subject: [PATCH 333/339] Also purge the user in User.perform/2 --- lib/pleroma/user.ex | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 406a7f5f9..f3cf3c69b 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1724,6 +1724,7 @@ def delete(users) when is_list(users) do end def delete(%User{} = user) do + # Purge the user immediately purge(user) BackgroundWorker.enqueue("delete_user", %{"user_id" => user.id}) end @@ -1749,6 +1750,9 @@ def perform(:force_password_reset, user), do: force_password_reset(user) @spec perform(atom(), User.t()) :: {:ok, User.t()} def perform(:delete, %User{} = user) do + # Purge the user again, in case perform/2 is called directly + purge(user) + # Remove all relationships user |> get_followers() From a7929c4d89a07a7f577e7cde5638bde8b1cb586a Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Tue, 29 Jun 2021 23:56:19 -0500 Subject: [PATCH 334/339] Deletions: preserve account status fields during purge, fix checks --- lib/pleroma/user.ex | 22 ++++++++++++---------- test/pleroma/user_test.exs | 4 ++-- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index f3cf3c69b..5d8b936aa 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1692,9 +1692,7 @@ def purge_user_changeset(user) do follower_count: 0, following_count: 0, is_locked: false, - is_confirmed: true, password_reset_pending: false, - is_approved: true, registration_reason: nil, confirmation_token: nil, domain_blocks: [], @@ -1710,9 +1708,15 @@ def purge_user_changeset(user) do raw_fields: [], is_discoverable: false, also_known_as: [] + # id: preserved + # ap_id: preserved + # nickname: preserved }) end + # Purge doesn't delete the user from the database. + # It just nulls all its fields and deactivates it. + # See `User.purge_user_changeset/1` above. def purge(%User{} = user) do user |> purge_user_changeset() @@ -1729,20 +1733,18 @@ def delete(%User{} = user) do BackgroundWorker.enqueue("delete_user", %{"user_id" => user.id}) end + # *Actually* delete the user from the DB defp delete_from_db(%User{} = user) do invalidate_cache(user) Repo.delete(user) end - defp maybe_delete_from_db(%User{local: true} = user) do - status = account_status(user) + # If the user never finalized their account, it's safe to delete them. + defp maybe_delete_from_db(%User{local: true, is_confirmed: false} = user), + do: delete_from_db(user) - if status in [:confirmation_pending, :approval_pending] do - delete_from_db(user) - else - {:ok, user} - end - end + defp maybe_delete_from_db(%User{local: true, is_approved: false} = user), + do: delete_from_db(user) defp maybe_delete_from_db(user), do: {:ok, user} diff --git a/test/pleroma/user_test.exs b/test/pleroma/user_test.exs index 529f837e8..60bc58a48 100644 --- a/test/pleroma/user_test.exs +++ b/test/pleroma/user_test.exs @@ -1663,9 +1663,9 @@ test "delete/1 purges a user when they wouldn't be fully deleted" do follower_count: 0, following_count: 0, is_locked: false, - is_confirmed: true, + is_confirmed: false, password_reset_pending: false, - is_approved: true, + is_approved: false, registration_reason: nil, confirmation_token: nil, domain_blocks: [], From 43800d83f4fc3b251cdd93c28dab2df7297021b3 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Wed, 30 Jun 2021 01:14:34 -0500 Subject: [PATCH 335/339] Deletions: allow deactivated users to be deleted --- lib/pleroma/web/activity_pub/activity_pub.ex | 9 ++++++--- .../object_validators/delete_validator.ex | 12 +++++++++++- .../activity_pub/object_validators/undo_validator.ex | 12 +++++++++++- test/pleroma/user_test.exs | 8 ++++---- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 5b45e2ca1..787b5884f 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -52,15 +52,18 @@ defp get_recipients(data) do {recipients, to, cc} end - defp check_actor_is_active(nil), do: true + defp check_actor_can_insert(%{"type" => "Delete"}), do: true + defp check_actor_can_insert(%{"type" => "Undo"}), do: true - defp check_actor_is_active(actor) when is_binary(actor) do + defp check_actor_can_insert(%{"actor" => actor}) when is_binary(actor) do case User.get_cached_by_ap_id(actor) do %User{is_active: true} -> true _ -> false end end + defp check_actor_can_insert(_), do: true + defp check_remote_limit(%{"object" => %{"content" => content}}) when not is_nil(content) do limit = Config.get([:instance, :remote_limit]) String.length(content) <= limit @@ -116,7 +119,7 @@ def persist(object, meta) 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), - {_, true} <- {:actor_check, bypass_actor_check || check_actor_is_active(map["actor"])}, + {_, true} <- {:actor_check, bypass_actor_check || check_actor_can_insert(map)}, {_, true} <- {:remote_limit_pass, check_remote_limit(map)}, {:ok, map} <- MRF.filter(map), {recipients, _, _} = get_recipients(map), diff --git a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex index fc1a79a72..750ea0f7f 100644 --- a/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/delete_validator.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do alias Pleroma.Activity alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.User import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @@ -57,7 +58,7 @@ def validate_data(cng) do cng |> validate_required([:id, :type, :actor, :to, :cc, :object]) |> validate_inclusion(:type, ["Delete"]) - |> validate_actor_presence() + |> validate_delete_actor(:actor) |> validate_modification_rights() |> validate_object_or_user_presence(allowed_types: @deletable_types) |> add_deleted_activity_id() @@ -72,4 +73,13 @@ def cast_and_validate(data) do |> cast_data |> validate_data end + + defp validate_delete_actor(cng, field_name) do + validate_change(cng, field_name, fn field_name, actor -> + case User.get_cached_by_ap_id(actor) do + %User{} -> [] + _ -> [{field_name, "can't find user"}] + end + end) + end end diff --git a/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex b/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex index 783a79ddb..ab29f9820 100644 --- a/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/undo_validator.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator do alias Pleroma.Activity alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.User import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @@ -42,7 +43,7 @@ def validate_data(data_cng) do data_cng |> validate_inclusion(:type, ["Undo"]) |> validate_required([:id, :type, :object, :actor, :to, :cc]) - |> validate_actor_presence() + |> validate_undo_actor(:actor) |> validate_object_presence() |> validate_undo_rights() end @@ -59,4 +60,13 @@ def validate_undo_rights(cng) do _ -> cng end end + + defp validate_undo_actor(cng, field_name) do + validate_change(cng, field_name, fn field_name, actor -> + case User.get_cached_by_ap_id(actor) do + %User{} -> [] + _ -> [{field_name, "can't find user"}] + end + end) + end end diff --git a/test/pleroma/user_test.exs b/test/pleroma/user_test.exs index 60bc58a48..181990e4b 100644 --- a/test/pleroma/user_test.exs +++ b/test/pleroma/user_test.exs @@ -1621,9 +1621,9 @@ test "delete/1 purges a user when they wouldn't be fully deleted" do follower_count: 9, following_count: 9001, is_locked: true, - is_confirmed: false, + is_confirmed: true, password_reset_pending: true, - is_approved: false, + is_approved: true, registration_reason: "ahhhhh", confirmation_token: "qqqq", domain_blocks: ["lain.com"], @@ -1663,9 +1663,9 @@ test "delete/1 purges a user when they wouldn't be fully deleted" do follower_count: 0, following_count: 0, is_locked: false, - is_confirmed: false, + is_confirmed: true, password_reset_pending: false, - is_approved: false, + is_approved: true, registration_reason: nil, confirmation_token: nil, domain_blocks: [], From beb1c98ab5e0848127a4490180364552f6fcdbf5 Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Wed, 30 Jun 2021 01:48:17 -0500 Subject: [PATCH 336/339] Deletions: don't purge keys so Delete/Undo activities can be signed --- lib/pleroma/user.ex | 2 -- test/pleroma/user_test.exs | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 5d8b936aa..de3b8ca3b 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1680,8 +1680,6 @@ def purge_user_changeset(user) do email: nil, name: nil, password_hash: nil, - keys: nil, - public_key: nil, avatar: %{}, tags: [], last_refreshed_at: nil, diff --git a/test/pleroma/user_test.exs b/test/pleroma/user_test.exs index 181990e4b..ec0aaa9eb 100644 --- a/test/pleroma/user_test.exs +++ b/test/pleroma/user_test.exs @@ -1651,8 +1651,8 @@ test "delete/1 purges a user when they wouldn't be fully deleted" do email: nil, name: nil, password_hash: nil, - keys: nil, - public_key: nil, + keys: "RSA begin buplic key", + public_key: "--PRIVATE KEYE--", avatar: %{}, tags: [], last_refreshed_at: nil, From 310ef6b70d9ca18d857f43677d857d09d91ffe0e Mon Sep 17 00:00:00 2001 From: Alex Gleason <alex@alexgleason.me> Date: Wed, 30 Jun 2021 12:25:20 -0500 Subject: [PATCH 337/339] Deletions: change User.purge/1 to defp, add CHANGELOG entry --- CHANGELOG.md | 2 ++ lib/pleroma/user.ex | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52d92c6d2..330802b29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Don't crash so hard when email settings are invalid. - Checking activated Upload Filters for required commands. +- Remote users can no longer reappear after being deleted. +- Deactivated users may now be deleted. - Mix task `pleroma.database prune_objects` ### Removed diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index f5b12abad..62506f37a 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -1730,7 +1730,7 @@ def purge_user_changeset(user) do # Purge doesn't delete the user from the database. # It just nulls all its fields and deactivates it. # See `User.purge_user_changeset/1` above. - def purge(%User{} = user) do + defp purge(%User{} = user) do user |> purge_user_changeset() |> update_and_set_cache() From 64d009693e35039025b0ff1cc536206054c2b918 Mon Sep 17 00:00:00 2001 From: Mark Felder <feld@feld.me> Date: Thu, 8 Jul 2021 12:33:17 -0500 Subject: [PATCH 338/339] Update Linkify to fix crash on posts with a URL we failed to parse correctly --- CHANGELOG.md | 1 + mix.exs | 2 +- mix.lock | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 330802b29..9854eb531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Remote users can no longer reappear after being deleted. - Deactivated users may now be deleted. - Mix task `pleroma.database prune_objects` +- Linkify: Parsing crash with URLs ending in unbalanced closed paren, no path separator, and no query parameters ### Removed - **Breaking**: Remove deprecated `/api/qvitter/statuses/notifications/read` (replaced by `/api/v1/pleroma/notifications/read`) diff --git a/mix.exs b/mix.exs index e4b160971..1a7aac6a4 100644 --- a/mix.exs +++ b/mix.exs @@ -157,7 +157,7 @@ defp deps do {:floki, "~> 0.27"}, {:timex, "~> 3.6"}, {:ueberauth, "~> 0.4"}, - {:linkify, "~> 0.5.0"}, + {:linkify, "~> 0.5.1"}, {:http_signatures, "~> 0.1.0"}, {:telemetry, "~> 0.3"}, {:poolboy, "~> 1.5"}, diff --git a/mix.lock b/mix.lock index 65a225504..b78ae0bc9 100644 --- a/mix.lock +++ b/mix.lock @@ -67,7 +67,7 @@ "jose": {:hex, :jose, "1.11.1", "59da64010c69aad6cde2f5b9248b896b84472e99bd18f246085b7b9fe435dcdb", [:mix, :rebar3], [], "hexpm", "078f6c9fb3cd2f4cfafc972c814261a7d1e8d2b3685c0a76eb87e158efff1ac5"}, "jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"}, "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"}, - "linkify": {:hex, :linkify, "0.5.0", "e0ea8de73ff44742d6a889721221f4c4eccaad5284957ee9832ffeb347602d54", [:mix], [], "hexpm", "4ccd958350aee7c51c89e21f05b15d30596ebbba707e051d21766be1809df2d7"}, + "linkify": {:hex, :linkify, "0.5.1", "6dc415cbc948b2f6ecec7cb226aab7ba9d3a1815bb501ae33e042334d707ecee", [:mix], [], "hexpm", "a3128c7e22fada4aa7214009501d8131e1fa3faf2f0a68b33dba379dc84ff944"}, "majic": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/majic.git", "289cda1b6d0d70ccb2ba508a2b0bd24638db2880", [ref: "289cda1b6d0d70ccb2ba508a2b0bd24638db2880"]}, "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, "makeup_elixir": {:hex, :makeup_elixir, "0.14.1", "4f0e96847c63c17841d42c08107405a005a2680eb9c7ccadfd757bd31dabccfb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f2438b1a80eaec9ede832b5c41cd4f373b38fd7aa33e3b22d9db79e640cbde11"}, From 6dc78f5f6f8c607c90246ff30520aeb2f84634df Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" <contact@hacktivis.me> Date: Fri, 18 Sep 2020 14:22:27 +0200 Subject: [PATCH 339/339] AP C2S: Remove restrictions and make it go through pipeline --- CHANGELOG.md | 1 + lib/pleroma/activity.ex | 3 +- .../activity_pub/activity_pub_controller.ex | 104 +++++++++--------- .../web/activity_pub/object_validator.ex | 2 + .../activity_pub_controller_test.exs | 31 +++--- 5 files changed, 77 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9854eb531..036b9e775 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - HTTPSecurityPlug now sends a response header to opt out of Google's FLoC (Federated Learning of Cohorts) targeted advertising. - Email address is now returned if requesting user is the owner of the user account so it can be exposed in client and FE user settings UIs. - Improved Twittercard and OpenGraph meta tag generation including thumbnails and image dimension metadata when available. +- ActivityPub Client-to-Server(C2S): Limitation on the type of Activity/Object are lifted as they are now passed through ObjectValidators ### Added diff --git a/lib/pleroma/activity.ex b/lib/pleroma/activity.ex index 7e36c1b53..6a991c48e 100644 --- a/lib/pleroma/activity.ex +++ b/lib/pleroma/activity.ex @@ -292,7 +292,8 @@ def get_in_reply_to_activity(%Activity{} = activity) do get_in_reply_to_activity_from_object(Object.normalize(activity, fetch: false)) end - def normalize(obj) when is_map(obj), do: get_by_ap_id_with_object(obj["id"]) + def normalize(%Activity{data: %{"id" => ap_id}}), do: get_by_ap_id_with_object(ap_id) + def normalize(%{"id" => ap_id}), do: get_by_ap_id_with_object(ap_id) def normalize(ap_id) when is_binary(ap_id), do: get_by_ap_id_with_object(ap_id) def normalize(_), do: nil diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 5aa3b281a..57ac40b42 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -11,7 +11,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do alias Pleroma.Object.Fetcher alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub - alias Pleroma.Web.ActivityPub.Builder alias Pleroma.Web.ActivityPub.InternalFetchActor alias Pleroma.Web.ActivityPub.ObjectView alias Pleroma.Web.ActivityPub.Pipeline @@ -403,83 +402,90 @@ def read_inbox(%{assigns: %{user: %User{nickname: as_nickname}}} = conn, %{ |> json(err) end - defp handle_user_activity( - %User{} = user, - %{"type" => "Create", "object" => %{"type" => "Note"} = object} = params - ) do - content = if is_binary(object["content"]), do: object["content"], else: "" - name = if is_binary(object["name"]), do: object["name"], else: "" - summary = if is_binary(object["summary"]), do: object["summary"], else: "" - length = String.length(content <> name <> summary) + defp fix_user_message(%User{ap_id: actor}, %{"type" => "Create", "object" => object} = activity) + when is_map(object) do + length = + [object["content"], object["summary"], object["name"]] + |> Enum.filter(&is_binary(&1)) + |> Enum.join("") + |> String.length() - if length > Pleroma.Config.get([:instance, :limit]) do - {:error, dgettext("errors", "Note is over the character limit")} - else + limit = Pleroma.Config.get([:instance, :limit]) + + if length < limit do object = object - |> Map.merge(Map.take(params, ["to", "cc"])) - |> Map.put("attributedTo", user.ap_id) - |> Transmogrifier.fix_object() + |> Transmogrifier.strip_internal_fields() + |> Map.put("attributedTo", actor) + |> Map.put("actor", actor) + |> Map.put("id", Utils.generate_object_id()) - ActivityPub.create(%{ - to: params["to"], - actor: user, - context: object["context"], - object: object, - additional: Map.take(params, ["cc"]) - }) - end - end - - defp handle_user_activity(%User{} = user, %{"type" => "Delete"} = params) do - with %Object{} = object <- Object.normalize(params["object"], fetch: false), - true <- user.is_moderator || user.ap_id == object.data["actor"], - {:ok, delete_data, _} <- Builder.delete(user, object.data["id"]), - {:ok, delete, _} <- Pipeline.common_pipeline(delete_data, local: true) do - {:ok, delete} + {:ok, Map.put(activity, "object", object)} else - _ -> {:error, dgettext("errors", "Can't delete object")} + {:error, + dgettext( + "errors", + "Character limit (%{limit} characters) exceeded, contains %{length} characters", + limit: limit, + length: length + )} end end - defp handle_user_activity(%User{} = user, %{"type" => "Like"} = params) do - with %Object{} = object <- Object.normalize(params["object"], fetch: false), - {_, {:ok, like_object, meta}} <- {:build_object, Builder.like(user, object)}, - {_, {:ok, %Activity{} = activity, _meta}} <- - {:common_pipeline, - Pipeline.common_pipeline(like_object, Keyword.put(meta, :local, true))} do + defp fix_user_message( + %User{ap_id: actor} = user, + %{"type" => "Delete", "object" => object} = activity + ) do + with {_, %Object{data: object_data}} <- {:normalize, Object.normalize(object, fetch: false)}, + {_, true} <- {:permission, user.is_moderator || actor == object_data["actor"]} do {:ok, activity} else - _ -> {:error, dgettext("errors", "Can't like object")} + {:normalize, _} -> + {:error, "No such object found"} + + {:permission, _} -> + {:forbidden, "You can't delete this object"} end end - defp handle_user_activity(_, _) do - {:error, dgettext("errors", "Unhandled activity type")} + defp fix_user_message(%User{}, activity) do + {:ok, activity} end def update_outbox( - %{assigns: %{user: %User{nickname: nickname} = user}} = conn, + %{assigns: %{user: %User{nickname: nickname, ap_id: actor} = user}} = conn, %{"nickname" => nickname} = params ) do - actor = user.ap_id - params = params - |> Map.drop(["id"]) + |> Map.drop(["nickname"]) + |> Map.put("id", Utils.generate_activity_id()) |> Map.put("actor", actor) - |> Transmogrifier.fix_addressing() - with {:ok, %Activity{} = activity} <- handle_user_activity(user, params) do + with {:ok, params} <- fix_user_message(user, params), + {:ok, activity, _} <- Pipeline.common_pipeline(params, local: true), + %Activity{data: activity_data} <- Activity.normalize(activity) do conn |> put_status(:created) - |> put_resp_header("location", activity.data["id"]) - |> json(activity.data) + |> put_resp_header("location", activity_data["id"]) + |> json(activity_data) else + {:forbidden, message} -> + conn + |> put_status(:forbidden) + |> json(message) + {:error, message} -> conn |> put_status(:bad_request) |> json(message) + + e -> + Logger.warn(fn -> "AP C2S: #{inspect(e)}" end) + + conn + |> put_status(:bad_request) + |> json("Bad Request") end end diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 248a12a36..50999539c 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -175,6 +175,8 @@ def validate(%{"type" => type} = object, meta) when type in ~w(Add Remove) do end end + def validate(o, m), do: {:error, {:validator_not_set, {o, m}}} + def cast_and_apply(%{"type" => "ChatMessage"} = object) do ChatMessageValidator.cast_and_apply(object) end diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index c7039d1f8..50315e21f 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -1334,9 +1334,12 @@ test "It returns poll Answers when authenticated", %{conn: conn} do activity: %{ "@context" => "https://www.w3.org/ns/activitystreams", "type" => "Create", - "object" => %{"type" => "Note", "content" => "AP C2S test"}, - "to" => "https://www.w3.org/ns/activitystreams#Public", - "cc" => [] + "object" => %{ + "type" => "Note", + "content" => "AP C2S test", + "to" => "https://www.w3.org/ns/activitystreams#Public", + "cc" => [] + } } ] end @@ -1442,19 +1445,19 @@ test "it erects a tombstone when receiving a delete activity", %{conn: conn} do user = User.get_cached_by_ap_id(note_activity.data["actor"]) data = %{ - type: "Delete", - object: %{ - id: note_object.data["id"] + "type" => "Delete", + "object" => %{ + "id" => note_object.data["id"] } } - conn = + result = conn |> assign(:user, user) |> put_req_header("content-type", "application/activity+json") |> post("/users/#{user.nickname}/outbox", data) + |> json_response(201) - result = json_response(conn, 201) assert Activity.get_by_ap_id(result["id"]) assert object = Object.get_by_ap_id(note_object.data["id"]) @@ -1479,7 +1482,7 @@ test "it rejects delete activity of object from other actor", %{conn: conn} do |> put_req_header("content-type", "application/activity+json") |> post("/users/#{user.nickname}/outbox", data) - assert json_response(conn, 400) + assert json_response(conn, 403) end test "it increases like count when receiving a like action", %{conn: conn} do @@ -1557,7 +1560,7 @@ test "Character limitation", %{conn: conn, activity: activity} do |> post("/users/#{user.nickname}/outbox", activity) |> json_response(400) - assert result == "Note is over the character limit" + assert result == "Character limit (5 characters) exceeded, contains 11 characters" end end @@ -1934,10 +1937,10 @@ test "POST /api/ap/upload_media", %{conn: conn} do "object" => %{ "type" => "Note", "content" => "AP C2S test, attachment", - "attachment" => [object] - }, - "to" => "https://www.w3.org/ns/activitystreams#Public", - "cc" => [] + "attachment" => [object], + "to" => "https://www.w3.org/ns/activitystreams#Public", + "cc" => [] + } } activity_response =