From eeb2f851e06ee6c03424d721b807e77d8f6910d8 Mon Sep 17 00:00:00 2001 From: Luna D Date: Tue, 22 Mar 2022 22:23:30 +0100 Subject: [PATCH] post and comment approval --- lib/philomena/comments.ex | 34 +++++++++++++ lib/philomena/comments/comment.ex | 11 +++- lib/philomena/comments/elasticsearch_index.ex | 6 ++- lib/philomena/conversations/message.ex | 2 + lib/philomena/images/elasticsearch_index.ex | 6 ++- lib/philomena/images/image.ex | 2 +- lib/philomena/posts.ex | 34 +++++++++++++ lib/philomena/posts/elasticsearch_index.ex | 6 ++- lib/philomena/posts/post.ex | 13 ++++- lib/philomena/reports.ex | 20 ++++++++ lib/philomena/schema/approval.ex | 45 ++++++++++++++++ lib/philomena/topics/topic.ex | 2 +- lib/philomena/users/ability.ex | 29 ++++++++++- .../conversation/approve_controller.ex | 33 ++++++++++++ .../controllers/image/approve_controller.ex | 24 +++++++++ .../image/comment/approve_controller.ex | 35 +++++++++++++ .../controllers/image/comment_controller.ex | 7 ++- .../controllers/topic/approve_controller.ex | 45 ++++++++++++++++ .../topic/post/approve_controller.ex | 51 +++++++++++++++++++ .../controllers/topic/post_controller.ex | 7 ++- lib/philomena_web/router.ex | 9 +++- .../templates/comment/_comment.html.slime | 22 ++++++++ .../templates/image/comment/index.html.slime | 2 +- .../templates/image/comment/show.html.slime | 2 +- .../templates/post/_post.html.slime | 22 ++++++++ .../templates/topic/show.html.slime | 2 +- lib/philomena_web/views/admin/user_view.ex | 4 +- lib/philomena_web/views/app_view.ex | 15 ++++++ .../20220321173359_add_approval_queue.exs | 8 +-- priv/repo/seeds.json | 3 +- priv/repo/seeds_development.exs | 3 ++ priv/repo/structure.sql | 8 +-- 32 files changed, 484 insertions(+), 28 deletions(-) create mode 100644 lib/philomena/schema/approval.ex create mode 100644 lib/philomena_web/controllers/conversation/approve_controller.ex create mode 100644 lib/philomena_web/controllers/image/approve_controller.ex create mode 100644 lib/philomena_web/controllers/image/comment/approve_controller.ex create mode 100644 lib/philomena_web/controllers/topic/approve_controller.ex create mode 100644 lib/philomena_web/controllers/topic/post/approve_controller.ex diff --git a/lib/philomena/comments.ex b/lib/philomena/comments.ex index 34527361..62e7c260 100644 --- a/lib/philomena/comments.ex +++ b/lib/philomena/comments.ex @@ -197,6 +197,40 @@ defmodule Philomena.Comments do |> Repo.update() end + def approve_comment(%Comment{} = comment, user) do + reports = + Report + |> where(reportable_type: "Comment", reportable_id: ^comment.id) + |> select([r], r.id) + |> update(set: [open: false, state: "closed", admin_id: ^user.id]) + + comment = Comment.approve_changeset(comment) + + Multi.new() + |> Multi.update(:comment, comment) + |> Multi.update_all(:reports, reports, []) + |> Repo.transaction() + |> case do + {:ok, %{comment: comment, reports: {_count, reports}}} -> + Reports.reindex_reports(reports) + reindex_comment(comment) + + {:ok, comment} + + error -> + error + end + end + + def report_non_approved(comment) do + Reports.create_system_report( + comment.id, + "Comment", + "Approval", + "Comment contains externally-embedded images and has been flagged for review." + ) + end + def migrate_comments(image, duplicate_of_image) do {count, nil} = Comment diff --git a/lib/philomena/comments/comment.ex b/lib/philomena/comments/comment.ex index 079bdca1..9571b450 100644 --- a/lib/philomena/comments/comment.ex +++ b/lib/philomena/comments/comment.ex @@ -4,6 +4,7 @@ defmodule Philomena.Comments.Comment do alias Philomena.Images.Image alias Philomena.Users.User + alias Philomena.Schema.Approval schema "comments" do belongs_to :user, User @@ -22,7 +23,7 @@ defmodule Philomena.Comments.Comment do field :deletion_reason, :string, default: "" field :destroyed_content, :boolean, default: false field :name_at_post_time, :string - field :approved_at, :utc_datetime + field :approved, :boolean timestamps(inserted_at: :created_at, type: :utc_datetime) end @@ -35,6 +36,8 @@ defmodule Philomena.Comments.Comment do |> validate_length(:body, min: 1, max: 300_000, count: :bytes) |> change(attribution) |> put_name_at_post_time(attribution[:user]) + |> Approval.maybe_put_approval(attribution[:user]) + |> Approval.maybe_strip_images(attribution[:user]) end def changeset(comment, attrs, edited_at \\ nil) do @@ -44,6 +47,7 @@ defmodule Philomena.Comments.Comment do |> validate_required([:body]) |> validate_length(:body, min: 1, max: 300_000, count: :bytes) |> validate_length(:edit_reason, max: 70, count: :bytes) + |> Approval.maybe_put_approval(comment.user) end def hide_changeset(comment, attrs, user) do @@ -66,6 +70,11 @@ defmodule Philomena.Comments.Comment do |> put_change(:body, "") end + def approve_changeset(comment) do + change(comment) + |> put_change(:approved, true) + end + defp put_name_at_post_time(changeset, nil), do: changeset defp put_name_at_post_time(changeset, user), do: change(changeset, name_at_post_time: user.name) end diff --git a/lib/philomena/comments/elasticsearch_index.ex b/lib/philomena/comments/elasticsearch_index.ex index 54012b04..82d65bcc 100644 --- a/lib/philomena/comments/elasticsearch_index.ex +++ b/lib/philomena/comments/elasticsearch_index.ex @@ -30,7 +30,8 @@ defmodule Philomena.Comments.ElasticsearchIndex do anonymous: %{type: "keyword"}, # boolean hidden_from_users: %{type: "keyword"}, - body: %{type: "text", analyzer: "snowball"} + body: %{type: "text", analyzer: "snowball"}, + approved: %{type: "boolean"} } } } @@ -49,7 +50,8 @@ defmodule Philomena.Comments.ElasticsearchIndex do image_tag_ids: comment.image.tags |> Enum.map(& &1.id), anonymous: comment.anonymous, hidden_from_users: comment.image.hidden_from_users || comment.hidden_from_users, - body: comment.body + body: comment.body, + approved: comment.image.approved && comment.approved } end diff --git a/lib/philomena/conversations/message.ex b/lib/philomena/conversations/message.ex index f1dc1129..56188f0f 100644 --- a/lib/philomena/conversations/message.ex +++ b/lib/philomena/conversations/message.ex @@ -4,6 +4,7 @@ defmodule Philomena.Conversations.Message do alias Philomena.Conversations.Conversation alias Philomena.Users.User + alias Philomena.Schema.Approval schema "messages" do belongs_to :conversation, Conversation @@ -28,5 +29,6 @@ defmodule Philomena.Conversations.Message do |> validate_required([:body]) |> put_assoc(:from, user) |> validate_length(:body, max: 300_000, count: :bytes) + |> Approval.maybe_put_approval(user) end end diff --git a/lib/philomena/images/elasticsearch_index.ex b/lib/philomena/images/elasticsearch_index.ex index 54154c7b..0936656f 100644 --- a/lib/philomena/images/elasticsearch_index.ex +++ b/lib/philomena/images/elasticsearch_index.ex @@ -86,7 +86,8 @@ defmodule Philomena.Images.ElasticsearchIndex do name_in_namespace: %{type: "keyword"}, namespace: %{type: "keyword"} } - } + }, + approved: %{type: "boolean"} } } } @@ -149,7 +150,8 @@ defmodule Philomena.Images.ElasticsearchIndex do hidden_by_users: image.hiders |> Enum.map(&String.downcase(&1.name)), upvoters: image.upvoters |> Enum.map(&String.downcase(&1.name)), downvoters: image.downvoters |> Enum.map(&String.downcase(&1.name)), - deleted_by_user: if(!!image.deleter, do: image.deleter.name) + deleted_by_user: if(!!image.deleter, do: image.deleter.name), + approved: image.approved } end diff --git a/lib/philomena/images/image.ex b/lib/philomena/images/image.ex index 91a1ac09..8e27d4f5 100644 --- a/lib/philomena/images/image.ex +++ b/lib/philomena/images/image.ex @@ -82,7 +82,7 @@ defmodule Philomena.Images.Image do field :hidden_image_key, :string field :scratchpad, :string field :hides_count, :integer, default: 0 - field :approved_at, :utc_datetime + field :approved, :boolean # todo: can probably remove these now field :tag_list_cache, :string diff --git a/lib/philomena/posts.ex b/lib/philomena/posts.ex index 01b32452..7e41b473 100644 --- a/lib/philomena/posts.ex +++ b/lib/philomena/posts.ex @@ -117,6 +117,15 @@ defmodule Philomena.Posts do Exq.enqueue(Exq, "notifications", NotificationWorker, ["Posts", post.id]) end + def report_non_approved(post) do + Reports.create_system_report( + post.id, + "Post", + "Approval", + "Post contains externally-embedded images and has been flagged for review." + ) + end + def perform_notify(post_id) do post = get_post!(post_id) @@ -237,6 +246,31 @@ defmodule Philomena.Posts do |> reindex_after_update() end + def approve_post(%Post{} = post, user) do + reports = + Report + |> where(reportable_type: "Post", reportable_id: ^post.id) + |> select([r], r.id) + |> update(set: [open: false, state: "closed", admin_id: ^user.id]) + + post = Post.approve_changeset(post) + + Multi.new() + |> Multi.update(:post, post) + |> Multi.update_all(:reports, reports, []) + |> Repo.transaction() + |> case do + {:ok, %{post: post, reports: {_count, reports}}} -> + Reports.reindex_reports(reports) + reindex_post(post) + + {:ok, post} + + error -> + error + end + end + @doc """ Returns an `%Ecto.Changeset{}` for tracking post changes. diff --git a/lib/philomena/posts/elasticsearch_index.ex b/lib/philomena/posts/elasticsearch_index.ex index 827d819c..395965ce 100644 --- a/lib/philomena/posts/elasticsearch_index.ex +++ b/lib/philomena/posts/elasticsearch_index.ex @@ -36,7 +36,8 @@ defmodule Philomena.Posts.ElasticsearchIndex do created_at: %{type: "date"}, deleted: %{type: "boolean"}, access_level: %{type: "keyword"}, - destroyed_content: %{type: "boolean"} + destroyed_content: %{type: "boolean"}, + approved: %{type: "boolean"} } } } @@ -63,7 +64,8 @@ defmodule Philomena.Posts.ElasticsearchIndex do updated_at: post.updated_at, deleted: post.hidden_from_users, access_level: post.topic.forum.access_level, - destroyed_content: post.destroyed_content + destroyed_content: post.destroyed_content, + approved: post.topic.approved && post.approved } end diff --git a/lib/philomena/posts/post.ex b/lib/philomena/posts/post.ex index 68e35d7c..55d5b401 100644 --- a/lib/philomena/posts/post.ex +++ b/lib/philomena/posts/post.ex @@ -4,6 +4,7 @@ defmodule Philomena.Posts.Post do alias Philomena.Users.User alias Philomena.Topics.Topic + alias Philomena.Schema.Approval schema "posts" do belongs_to :user, User @@ -23,7 +24,7 @@ defmodule Philomena.Posts.Post do field :deletion_reason, :string, default: "" field :destroyed_content, :boolean, default: false field :name_at_post_time, :string - field :approved_at, :utc_datetime + field :approved, :boolean, default: false timestamps(inserted_at: :created_at, type: :utc_datetime) end @@ -36,6 +37,7 @@ defmodule Philomena.Posts.Post do |> validate_required([:body]) |> validate_length(:body, min: 1, max: 300_000, count: :bytes) |> validate_length(:edit_reason, max: 70, count: :bytes) + |> Approval.maybe_put_approval(post.user) end @doc false @@ -46,6 +48,8 @@ defmodule Philomena.Posts.Post do |> validate_length(:body, min: 1, max: 300_000, count: :bytes) |> change(attribution) |> put_name_at_post_time(attribution[:user]) + |> Approval.maybe_put_approval(attribution[:user]) + |> Approval.maybe_strip_images(attribution[:user]) end @doc false @@ -58,6 +62,8 @@ defmodule Philomena.Posts.Post do |> change(attribution) |> change(topic_position: 0) |> put_name_at_post_time(attribution[:user]) + |> Approval.maybe_put_approval(attribution[:user]) + |> Approval.maybe_strip_images(attribution[:user]) end def hide_changeset(post, attrs, user) do @@ -80,6 +86,11 @@ defmodule Philomena.Posts.Post do |> put_change(:body, "") end + def approve_changeset(post) do + change(post) + |> put_change(:approved, true) + end + defp put_name_at_post_time(changeset, nil), do: changeset defp put_name_at_post_time(changeset, user), do: change(changeset, name_at_post_time: user.name) end diff --git a/lib/philomena/reports.ex b/lib/philomena/reports.ex index 904222ca..25a04e74 100644 --- a/lib/philomena/reports.ex +++ b/lib/philomena/reports.ex @@ -60,6 +60,26 @@ defmodule Philomena.Reports do |> reindex_after_update() end + def create_system_report(reportable_id, reportable_type, category, reason) do + attrs = %{ + reason: reason, + category: category + } + + attributes = %{ + system: true, + ip: %Postgrex.INET{address: {127, 0, 0, 1}, netmask: 32}, + fingerprint: "ffff", + user_agent: + "Mozilla/5.0 (X11; Philomena; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0" + } + + %Report{reportable_id: reportable_id, reportable_type: reportable_type} + |> Report.creation_changeset(attrs, attributes) + |> Repo.insert() + |> reindex_after_update() + end + @doc """ Updates a report. diff --git a/lib/philomena/schema/approval.ex b/lib/philomena/schema/approval.ex new file mode 100644 index 00000000..512c5aab --- /dev/null +++ b/lib/philomena/schema/approval.ex @@ -0,0 +1,45 @@ +defmodule Philomena.Schema.Approval do + alias Philomena.Users.User + import Ecto.Changeset + + @image_embed_regex ~r/!+\[/ + + def maybe_put_approval(changeset, nil), + do: change(changeset, approved: true) + + def maybe_put_approval(changeset, %{role: role}) + when role != "user", + do: change(changeset, approved: true) + + def maybe_put_approval( + %{changes: %{body: body}, valid?: true} = changeset, + %User{} = user + ) do + now = now_time() + # 14 * 24 * 60 * 60 + two_weeks = 1_209_600 + + case String.match?(body, @image_embed_regex) do + true -> + case DateTime.compare(now, DateTime.add(user.created_at, two_weeks)) do + :gt -> change(changeset, approved: true) + _ -> change(changeset, approved: false) + end + + _ -> + change(changeset, approved: true) + end + end + + def maybe_put_approval(changeset, _user), do: changeset + + def maybe_strip_images( + %{changes: %{body: body}, valid?: true} = changeset, + nil + ), + do: change(changeset, body: Regex.replace(@image_embed_regex, body, "[")) + + def maybe_strip_images(changeset, _user), do: changeset + + defp now_time(), do: DateTime.truncate(DateTime.utc_now(), :second) +end diff --git a/lib/philomena/topics/topic.ex b/lib/philomena/topics/topic.ex index 886a055a..43b54002 100644 --- a/lib/philomena/topics/topic.ex +++ b/lib/philomena/topics/topic.ex @@ -31,7 +31,7 @@ defmodule Philomena.Topics.Topic do field :slug, :string field :anonymous, :boolean, default: false field :hidden_from_users, :boolean, default: false - field :approved_at, :utc_datetime + field :approved, :boolean timestamps(inserted_at: :created_at, type: :utc_datetime) end diff --git a/lib/philomena/users/ability.ex b/lib/philomena/users/ability.ex index ac8d471d..cca29a3e 100644 --- a/lib/philomena/users/ability.ex +++ b/lib/philomena/users/ability.ex @@ -45,8 +45,14 @@ defimpl Canada.Can, for: [Atom, Philomena.Users.User] do # View filters def can?(%User{role: "moderator"}, :show, %Filter{}), do: true - # Manage images + # Privileged mods can hard-delete images + def can?(%User{role: "moderator", role_map: %{"Image" => "admin"}}, :destroy, %Image{}), + do: true + + # ...but normal ones cannot def can?(%User{role: "moderator"}, :destroy, %Image{}), do: false + + # Manage images def can?(%User{role: "moderator"}, _action, Image), do: true def can?(%User{role: "moderator"}, _action, %Image{}), do: true @@ -62,8 +68,9 @@ defimpl Canada.Can, for: [Atom, Philomena.Users.User] do def can?(%User{role: "moderator"}, :show, %Topic{hidden_from_users: true}), do: true - # View conversations + # View and approve conversations def can?(%User{role: "moderator"}, :show, %Conversation{}), do: true + def can?(%User{role: "moderator"}, :approve, %Conversation{}), do: true # View IP addresses and fingerprints def can?(%User{role: "moderator"}, :show, :ip_address), do: true @@ -90,9 +97,11 @@ defimpl Canada.Can, for: [Atom, Philomena.Users.User] do def can?(%User{role: "moderator"}, :edit, %Post{}), do: true def can?(%User{role: "moderator"}, :hide, %Post{}), do: true def can?(%User{role: "moderator"}, :delete, %Post{}), do: true + def can?(%User{role: "moderator"}, :approve, %Post{}), do: true def can?(%User{role: "moderator"}, :edit, %Comment{}), do: true def can?(%User{role: "moderator"}, :hide, %Comment{}), do: true def can?(%User{role: "moderator"}, :delete, %Comment{}), do: true + def can?(%User{role: "moderator"}, :approve, %Comment{}), do: true # Show the DNP list def can?(%User{role: "moderator"}, _action, DnpEntry), do: true @@ -198,6 +207,13 @@ defimpl Canada.Can, for: [Atom, Philomena.Users.User] do ), do: true + def can?( + %User{role: "assistant", role_map: %{"Image" => "moderator"}}, + :approve, + %Image{} + ), + do: true + # Dupe assistant actions def can?( %User{role: "assistant", role_map: %{"DuplicateReport" => "moderator"}}, @@ -244,6 +260,9 @@ defimpl Canada.Can, for: [Atom, Philomena.Users.User] do def can?(%User{role: "assistant", role_map: %{"Comment" => "moderator"}}, :hide, %Comment{}), do: true + def can?(%User{role: "assistant", role_map: %{"Comment" => "moderator"}}, :approve, %Comment{}), + do: true + # Topic assistant actions def can?(%User{role: "assistant", role_map: %{"Topic" => "moderator"}}, :show, %Topic{}), do: true @@ -254,6 +273,9 @@ defimpl Canada.Can, for: [Atom, Philomena.Users.User] do def can?(%User{role: "assistant", role_map: %{"Topic" => "moderator"}}, :hide, %Topic{}), do: true + def can?(%User{role: "assistant", role_map: %{"Topic" => "moderator"}}, :approve, %Topic{}), + do: true + def can?(%User{role: "assistant", role_map: %{"Topic" => "moderator"}}, :show, %Post{}), do: true @@ -263,6 +285,9 @@ defimpl Canada.Can, for: [Atom, Philomena.Users.User] do def can?(%User{role: "assistant", role_map: %{"Topic" => "moderator"}}, :hide, %Post{}), do: true + def can?(%User{role: "assistant", role_map: %{"Topic" => "moderator"}}, :approve, %Post{}), + do: true + # Tag assistant actions def can?(%User{role: "assistant", role_map: %{"Tag" => "moderator"}}, :edit, %Tag{}), do: true diff --git a/lib/philomena_web/controllers/conversation/approve_controller.ex b/lib/philomena_web/controllers/conversation/approve_controller.ex new file mode 100644 index 00000000..20529e14 --- /dev/null +++ b/lib/philomena_web/controllers/conversation/approve_controller.ex @@ -0,0 +1,33 @@ +defmodule PhilomenaWeb.Conversation.ApproveController do + use PhilomenaWeb, :controller + + alias Philomena.Conversations.Conversation + alias Philomena.Conversations + + plug PhilomenaWeb.CanaryMapPlug, create: :approve + + plug :load_and_authorize_resource, + model: Conversation, + id_field: "slug", + id_name: "conversation_id", + persisted: true + + def create(conn, _params) do + message = conn.assigns.message + user = conn.assigns.current_user + + {:ok, _message} = Conversations.approve_conversation_message(message) + + conn + |> put_flash(:info, "Conversation message approved.") + |> moderation_log(details: &log_details/3, data: message) + |> redirect(to: "/") + end + + defp log_details(conn, _action, message) do + %{ + body: "Approved private message in conversation ##{message.conversation_id}", + subject_path: "/" + } + end +end diff --git a/lib/philomena_web/controllers/image/approve_controller.ex b/lib/philomena_web/controllers/image/approve_controller.ex new file mode 100644 index 00000000..66e5551f --- /dev/null +++ b/lib/philomena_web/controllers/image/approve_controller.ex @@ -0,0 +1,24 @@ +defmodule PhilomenaWeb.Image.ApproveController do + use PhilomenaWeb, :controller + + alias Philomena.Images.Image + alias Philomena.Images + + plug PhilomenaWeb.CanaryMapPlug, create: :approve + plug :load_and_authorize_resource, model: Image, id_name: "image_id", persisted: true + + def create(conn, _params) do + image = conn.assigns.image + + {:ok, _comment} = Images.approve_image(image) + + conn + |> put_flash(:info, "Image has been approved.") + |> moderation_log(details: &log_details/3, data: image) + |> redirect(to: Routes.image_path(conn, :show, image)) + end + + defp log_details(conn, _action, image) do + %{body: "Approved image #{image.id}", subject_path: Routes.image_path(conn, :show, image)} + end +end diff --git a/lib/philomena_web/controllers/image/comment/approve_controller.ex b/lib/philomena_web/controllers/image/comment/approve_controller.ex new file mode 100644 index 00000000..699d0cc2 --- /dev/null +++ b/lib/philomena_web/controllers/image/comment/approve_controller.ex @@ -0,0 +1,35 @@ +defmodule PhilomenaWeb.Image.Comment.ApproveController do + use PhilomenaWeb, :controller + + alias Philomena.Comments.Comment + alias Philomena.Comments + alias Philomena.UserStatistics + + plug PhilomenaWeb.CanaryMapPlug, create: :approve + + plug :load_and_authorize_resource, + model: Comment, + id_name: "comment_id", + persisted: true, + preload: [:user] + + def create(conn, _params) do + comment = conn.assigns.comment + + {:ok, _comment} = Comments.approve_comment(comment, conn.assigns.current_user) + + UserStatistics.inc_stat(comment.user, :comments_posted) + + conn + |> put_flash(:info, "Comment has been approved.") + |> moderation_log(details: &log_details/3, data: comment) + |> redirect(to: Routes.image_path(conn, :show, comment.image_id) <> "#comment_#{comment.id}") + end + + defp log_details(conn, _action, comment) do + %{ + body: "Approved comment on image >>#{comment.image_id}", + subject_path: Routes.image_path(conn, :show, comment.image_id) <> "#comment_#{comment.id}" + } + end +end diff --git a/lib/philomena_web/controllers/image/comment_controller.ex b/lib/philomena_web/controllers/image/comment_controller.ex index adff5887..21002e4a 100644 --- a/lib/philomena_web/controllers/image/comment_controller.ex +++ b/lib/philomena_web/controllers/image/comment_controller.ex @@ -81,7 +81,12 @@ defmodule PhilomenaWeb.Image.CommentController do Comments.notify_comment(comment) Comments.reindex_comment(comment) Images.reindex_image(conn.assigns.image) - UserStatistics.inc_stat(conn.assigns.current_user, :comments_posted) + + if comment.approved do + UserStatistics.inc_stat(conn.assigns.current_user, :comments_posted) + else + Comments.report_non_approved(comment) + end index(conn, %{"comment_id" => comment.id}) diff --git a/lib/philomena_web/controllers/topic/approve_controller.ex b/lib/philomena_web/controllers/topic/approve_controller.ex new file mode 100644 index 00000000..5d98d540 --- /dev/null +++ b/lib/philomena_web/controllers/topic/approve_controller.ex @@ -0,0 +1,45 @@ +defmodule PhilomenaWeb.Topic.ApproveController do + import Plug.Conn + use PhilomenaWeb, :controller + + alias Philomena.Forums.Forum + alias Philomena.Topics.Topic + alias Philomena.Topics + + plug PhilomenaWeb.CanaryMapPlug, create: :show + + plug :load_and_authorize_resource, + model: Forum, + id_name: "forum_id", + id_field: "short_name", + persisted: true + + plug PhilomenaWeb.LoadTopicPlug + plug PhilomenaWeb.CanaryMapPlug, create: :approve + plug :authorize_resource, model: Topic, persisted: true + + def create(conn, _params) do + topic = conn.assigns.topic + user = conn.assigns.current_user + + case Topics.approve_topic(topic) do + {:ok, topic} -> + conn + |> put_flash(:info, "Topic successfully approved!") + |> moderation_log(details: &log_details/3, data: topic) + |> redirect(to: Routes.forum_topic_path(conn, :show, topic.forum, topic)) + + {:error, _changeset} -> + conn + |> put_flash(:error, "Unable to approve the topic!") + |> redirect(to: Routes.forum_topic_path(conn, :show, topic.forum, topic)) + end + end + + defp log_details(conn, action, topic) do + %{ + body: "Approved topic '#{topic.title}' in #{topic.forum.name}", + subject_path: Routes.forum_topic_path(conn, :show, topic.forum, topic) + } + end +end diff --git a/lib/philomena_web/controllers/topic/post/approve_controller.ex b/lib/philomena_web/controllers/topic/post/approve_controller.ex new file mode 100644 index 00000000..094edad0 --- /dev/null +++ b/lib/philomena_web/controllers/topic/post/approve_controller.ex @@ -0,0 +1,51 @@ +defmodule PhilomenaWeb.Topic.Post.ApproveController do + use PhilomenaWeb, :controller + + alias Philomena.Posts.Post + alias Philomena.Posts + + plug PhilomenaWeb.CanaryMapPlug, create: :approve + + plug :load_and_authorize_resource, + model: Post, + id_name: "post_id", + persisted: true, + preload: [:topic, :user, topic: :forum] + + def create(conn, _params) do + post = conn.assigns.post + user = conn.assigns.current_user + + case Posts.approve_post(post, user) do + {:ok, post} -> + UserStatistics.inc_stat(post.user(:forum_posts)) + + conn + |> put_flash(:info, "Post successfully approved.") + |> moderation_log(details: &log_details/3, data: post) + |> redirect( + to: + Routes.forum_topic_path(conn, :show, post.topic.forum, post.topic, post_id: post.id) <> + "#post_#{post.id}" + ) + + {:error, _changeset} -> + conn + |> put_flash(:error, "Unable to approve post!") + |> redirect( + to: + Routes.forum_topic_path(conn, :show, post.topic.forum, post.topic, post_id: post.id) <> + "#post_#{post.id}" + ) + end + end + + defp log_details(conn, action, post) do + %{ + body: "Approved forum post ##{post.id} in topic '#{post.topic.title}'", + subject_path: + Routes.forum_topic_path(conn, :show, post.topic.forum, post.topic, post_id: post.id) <> + "#post_#{post.id}" + } + end +end diff --git a/lib/philomena_web/controllers/topic/post_controller.ex b/lib/philomena_web/controllers/topic/post_controller.ex index 43478cbb..0fc29600 100644 --- a/lib/philomena_web/controllers/topic/post_controller.ex +++ b/lib/philomena_web/controllers/topic/post_controller.ex @@ -36,7 +36,12 @@ defmodule PhilomenaWeb.Topic.PostController do case Posts.create_post(topic, attributes, post_params) do {:ok, %{post: post}} -> Posts.notify_post(post) - UserStatistics.inc_stat(conn.assigns.current_user, :forum_posts) + + if post.approved do + UserStatistics.inc_stat(conn.assigns.current_user, :forum_posts) + else + Posts.report_non_approved(post) + end if forum.access_level == "normal" do PhilomenaWeb.Endpoint.broadcast!( diff --git a/lib/philomena_web/router.ex b/lib/philomena_web/router.ex index 9b4d902c..3cec4363 100644 --- a/lib/philomena_web/router.ex +++ b/lib/philomena_web/router.ex @@ -180,12 +180,14 @@ defmodule PhilomenaWeb.Router do resources "/messages", Conversation.MessageController, only: [:create] resources "/read", Conversation.ReadController, only: [:create, :delete], singleton: true resources "/hide", Conversation.HideController, only: [:create, :delete], singleton: true + resources "/approve", Conversation.ApproveController, only: [:create], singleton: true end resources "/images", ImageController, only: [] do resources "/vote", Image.VoteController, only: [:create, :delete], singleton: true resources "/fave", Image.FaveController, only: [:create, :delete], singleton: true resources "/hide", Image.HideController, only: [:create, :delete], singleton: true + resources "/approve", Image.ApproveController, only: [:create], singleton: true resources "/subscription", Image.SubscriptionController, only: [:create, :delete], @@ -196,6 +198,7 @@ defmodule PhilomenaWeb.Router do resources "/comments", Image.CommentController, only: [:edit, :update] do resources "/hide", Image.Comment.HideController, only: [:create, :delete], singleton: true resources "/delete", Image.Comment.DeleteController, only: [:create], singleton: true + resources "/approve", Image.Comment.ApproveController, only: [:create], singleton: true end resources "/delete", Image.DeleteController, @@ -237,10 +240,12 @@ defmodule PhilomenaWeb.Router do resources "/stick", Topic.StickController, only: [:create, :delete], singleton: true resources "/lock", Topic.LockController, only: [:create, :delete], singleton: true resources "/hide", Topic.HideController, only: [:create, :delete], singleton: true + resources "/approve", Topic.ApproveController, only: [:create], singleton: true resources "/posts", Topic.PostController, only: [:edit, :update] do resources "/hide", Topic.Post.HideController, only: [:create, :delete], singleton: true resources "/delete", Topic.Post.DeleteController, only: [:create], singleton: true + resources "/approve", Topic.Post.ApproveController, only: [:create], singleton: true end resources "/poll", Topic.PollController, only: [:edit, :update], singleton: true do @@ -379,7 +384,9 @@ defmodule PhilomenaWeb.Router do only: [:create, :delete], singleton: true - resources "/verification", User.VerificationController, only: [:create, :delete], singleton: true + resources "/verification", User.VerificationController, + only: [:create, :delete], + singleton: true resources "/unlock", User.UnlockController, only: [:create], singleton: true resources "/api_key", User.ApiKeyController, only: [:delete], singleton: true diff --git a/lib/philomena_web/templates/comment/_comment.html.slime b/lib/philomena_web/templates/comment/_comment.html.slime index 5bc4b1ee..e79f3852 100644 --- a/lib/philomena_web/templates/comment/_comment.html.slime +++ b/lib/philomena_web/templates/comment/_comment.html.slime @@ -1,4 +1,26 @@ article.block.communication id="comment_#{@comment.id}" + = if not @comment.approved and not @comment.hidden_from_users and (can?(@conn, :hide, @comment) or @comment.user_id == @conn.assigns.current_user.id) do + .block__content + .block.block--fixed.block--danger + p + i.fas.fa-exclamation-triangle> + ' This comment is pending approval from a staff member. + = if can?(@conn, :approve, @comment) do + p + ul.horizontal-list + li + = link(to: Routes.image_comment_approve_path(@conn, :create, @comment.image_id, @comment), data: [confirm: "Are you sure?"], method: "post", class: "button") do + i.fas.fa-check> + ' Approve + li + a.button.togglable-delete-form-link href="#" data-click-toggle="#inline-reject-form-comment-#{@comment.id}" + i.fa.fa-times> + ' Reject + + = form_for :comment, Routes.image_comment_hide_path(@conn, :create, @comment.image_id, @comment), [class: "togglable-delete-form hidden flex", id: "inline-reject-form-comment-#{@comment.id}"], fn f -> + = text_input f, :deletion_reason, class: "input input--wide", placeholder: "Deletion Reason", id: "inline-reject-reason-comment-#{@comment.id}", required: true + = submit "Delete", class: "button" + .block__content.flex.flex--no-wrap class=communication_body_class(@comment) .flex__fixed.spacing-right = render PhilomenaWeb.UserAttributionView, "_anon_user_avatar.html", object: @comment, conn: @conn diff --git a/lib/philomena_web/templates/image/comment/index.html.slime b/lib/philomena_web/templates/image/comment/index.html.slime index 75d5e0e3..e0c3eee6 100644 --- a/lib/philomena_web/templates/image/comment/index.html.slime +++ b/lib/philomena_web/templates/image/comment/index.html.slime @@ -14,7 +14,7 @@ elixir: i.fa.fa-sync span.hide-mobile<> Refresh -= for {comment, body} <- @comments, not comment.destroyed_content or (can?(@conn, :show, comment) and not hide_staff_tools?(@conn)) do += for {comment, body} <- @comments, can_view_communication?(@conn, comment) do = render PhilomenaWeb.CommentView, "_comment.html", comment: comment, body: body, conn: @conn .block diff --git a/lib/philomena_web/templates/image/comment/show.html.slime b/lib/philomena_web/templates/image/comment/show.html.slime index 9497f8db..ead59411 100644 --- a/lib/philomena_web/templates/image/comment/show.html.slime +++ b/lib/philomena_web/templates/image/comment/show.html.slime @@ -1 +1 @@ -= render PhilomenaWeb.CommentView, "_comment.html", comment: @comment, body: @body, conn: @conn \ No newline at end of file += render PhilomenaWeb.CommentView, "_comment.html", comment: @comment, body: @body, conn: @conn diff --git a/lib/philomena_web/templates/post/_post.html.slime b/lib/philomena_web/templates/post/_post.html.slime index 1293fa1d..64712c84 100644 --- a/lib/philomena_web/templates/post/_post.html.slime +++ b/lib/philomena_web/templates/post/_post.html.slime @@ -1,4 +1,26 @@ article.block.communication id="post_#{@post.id}" + = if not @post.approved and not @post.hidden_from_users and (can?(@conn, :hide, @post) or @post.user_id == @conn.assigns.current_user.id) do + .block__content + .block.block--fixed.block--danger + p + i.fas.fa-exclamation-triangle> + ' This post is pending approval from a staff member. + = if can?(@conn, :approve, @post) do + p + ul.horizontal-list + li + = link(to: Routes.forum_topic_post_approve_path(@conn, :create, @post.topic.forum, @post.topic, @post), data: [confirm: "Are you sure?"], method: "post", class: "button") do + i.fas.fa-check> + ' Approve + li + a.button.togglable-delete-form-link href="#" data-click-toggle="#inline-reject-form-post-#{@post.id}" + i.fa.fa-times> + ' Reject + + = form_for :post, Routes.forum_topic_post_hide_path(@conn, :create, @post.topic.forum, @post.topic, @post), [class: "togglable-delete-form hidden flex", id: "inline-reject-form-post-#{@post.id}"], fn f -> + = text_input f, :deletion_reason, class: "input input--wide", placeholder: "Deletion Reason", id: "inline-reject-reason-post-#{@post.id}", required: true + = submit "Delete", class: "button" + .block__content.flex.flex--no-wrap class=communication_body_class(@post) .flex__fixed.spacing-right = render PhilomenaWeb.UserAttributionView, "_anon_user_avatar.html", object: @post, conn: @conn diff --git a/lib/philomena_web/templates/topic/show.html.slime b/lib/philomena_web/templates/topic/show.html.slime index 058a6592..ff54dffd 100644 --- a/lib/philomena_web/templates/topic/show.html.slime +++ b/lib/philomena_web/templates/topic/show.html.slime @@ -59,7 +59,7 @@ h1 = @topic.title / The actual posts .posts-area .post-list - = for {post, body} <- @posts, (!post.destroyed_content or can?(@conn, :hide, post)) do + = for {post, body} <- @posts, can_view_communication?(@conn, post) do = render PhilomenaWeb.PostView, "_post.html", conn: @conn, post: post, body: body = if @conn.assigns.advert do diff --git a/lib/philomena_web/views/admin/user_view.ex b/lib/philomena_web/views/admin/user_view.ex index db9e189f..19f33b48 100644 --- a/lib/philomena_web/views/admin/user_view.ex +++ b/lib/philomena_web/views/admin/user_view.ex @@ -56,6 +56,7 @@ defmodule PhilomenaWeb.Admin.UserView do def description("admin", "Badge"), do: "Manage badges" def description("admin", "Advert"), do: "Manage ads" def description("admin", "StaticPage"), do: "Manage static pages" + def description("admin", "Image"), do: "Hard-delete images" def description(_name, _resource_type), do: "(unknown permission)" @@ -90,7 +91,8 @@ defmodule PhilomenaWeb.Admin.UserView do ["admin", "SiteNotice"], ["admin", "Badge"], ["admin", "Advert"], - ["admin", "StaticPage"] + ["admin", "StaticPage"], + ["admin", "Image"] ] end end diff --git a/lib/philomena_web/views/app_view.ex b/lib/philomena_web/views/app_view.ex index cdf1e5ec..5c5fb83c 100644 --- a/lib/philomena_web/views/app_view.ex +++ b/lib/philomena_web/views/app_view.ex @@ -162,6 +162,21 @@ defmodule PhilomenaWeb.AppView do def communication_body_class(%{destroyed_content: true}), do: "communication--destroyed" def communication_body_class(_communication), do: nil + def can_view_communication?(conn, communication) do + user_id = + case conn.assigns.current_user do + nil -> -1 + user -> user.id + end + + cond do + can?(conn, :hide, communication) and not hide_staff_tools?(conn) -> true + communication.destroyed_content -> false + not communication.approved and communication.user_id != user_id -> false + true -> true + end + end + def hide_staff_tools?(conn), do: conn.cookies["hide_staff_tools"] == "true" diff --git a/priv/repo/migrations/20220321173359_add_approval_queue.exs b/priv/repo/migrations/20220321173359_add_approval_queue.exs index a03f616d..4d1421ea 100644 --- a/priv/repo/migrations/20220321173359_add_approval_queue.exs +++ b/priv/repo/migrations/20220321173359_add_approval_queue.exs @@ -7,19 +7,19 @@ defmodule Philomena.Repo.Migrations.AddApprovalQueue do end alter table("images") do - add :approved_at, :utc_datetime + add :approved, :boolean, default: false end alter table("comments") do - add :approved_at, :utc_datetime + add :approved, :boolean, default: false end alter table("posts") do - add :approved_at, :utc_datetime + add :approved, :boolean, default: false end alter table("topics") do - add :approved_at, :utc_datetime + add :approved, :boolean, default: false end alter table("users") do diff --git a/priv/repo/seeds.json b/priv/repo/seeds.json index 80c945ec..3c5f06d9 100644 --- a/priv/repo/seeds.json +++ b/priv/repo/seeds.json @@ -89,7 +89,8 @@ {"name": "batch_update", "resource_type": "Tag"}, {"name": "moderator", "resource_type": "Topic"}, {"name": "admin", "resource_type": "Advert"}, - {"name": "admin", "resource_type": "StaticPage"} + {"name": "admin", "resource_type": "StaticPage"}, + {"name": "admin", "resource_type": "Image"} ], "pages": [] } diff --git a/priv/repo/seeds_development.exs b/priv/repo/seeds_development.exs index 74f71aa7..466b3111 100644 --- a/priv/repo/seeds_development.exs +++ b/priv/repo/seeds_development.exs @@ -70,6 +70,7 @@ for image_def <- resources["remote_images"] do ) |> case do {:ok, %{image: image}} -> + Images.approve_image(image) Images.reindex_image(image) Tags.reindex_tags(image.added_tags) @@ -91,6 +92,7 @@ for comment_body <- resources["comments"] do ) |> case do {:ok, %{comment: comment}} -> + Comments.approve_comment(comment, pleb) Comments.reindex_comment(comment) Images.reindex_image(image) @@ -126,6 +128,7 @@ for %{"forum" => forum_name, "topics" => topics} <- resources["forum_posts"] do ) |> case do {:ok, %{post: post}} -> + Posts.approve_post(post, pleb) Posts.reindex_post(post) {:error, :post, changeset, _so_far} -> diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql index 1f64996c..a6e13a4f 100644 --- a/priv/repo/structure.sql +++ b/priv/repo/structure.sql @@ -283,7 +283,7 @@ CREATE TABLE public.comments ( destroyed_content boolean DEFAULT false, name_at_post_time character varying, body character varying NOT NULL, - approved_at timestamp(0) without time zone + approved boolean DEFAULT false ); @@ -973,7 +973,7 @@ CREATE TABLE public.images ( image_duration double precision, description character varying DEFAULT ''::character varying NOT NULL, scratchpad character varying, - approved_at timestamp(0) without time zone + approved boolean DEFAULT false ); @@ -1261,7 +1261,7 @@ CREATE TABLE public.posts ( destroyed_content boolean DEFAULT false NOT NULL, name_at_post_time character varying, body character varying NOT NULL, - approved_at timestamp(0) without time zone + approved boolean DEFAULT false ); @@ -1681,7 +1681,7 @@ CREATE TABLE public.topics ( locked_by_id integer, last_post_id integer, hidden_from_users boolean DEFAULT false NOT NULL, - approved_at timestamp(0) without time zone + approved boolean DEFAULT false );