diff --git a/lib/philomena/bans.ex b/lib/philomena/bans.ex index 3e2c4500..6d2e1685 100644 --- a/lib/philomena/bans.ex +++ b/lib/philomena/bans.ex @@ -241,9 +241,9 @@ defmodule Philomena.Bans do {:error, %Ecto.Changeset{}} """ - def create_user(attrs \\ %{}) do - %User{} - |> User.changeset(attrs) + def create_user(creator, attrs \\ %{}) do + %User{banning_user_id: creator.id} + |> User.save_changeset(attrs) |> Repo.insert() end @@ -261,7 +261,7 @@ defmodule Philomena.Bans do """ def update_user(%User{} = user, attrs) do user - |> User.changeset(attrs) + |> User.save_changeset(attrs) |> Repo.update() end @@ -321,7 +321,7 @@ defmodule Philomena.Bans do defp fingerprint_query(fingerprint, now) do [ Fingerprint - |> select([f], %{reason: f.reason, valid_until: f.valid_until, generated_ban_id: f.generated_ban_id, type: "FingerprintBan"}) + |> select([f], %{reason: f.reason, valid_until: f.valid_until, generated_ban_id: f.generated_ban_id, type: ^"FingerprintBan"}) |> where([f], f.enabled and f.valid_until > ^now) |> where([f], f.fingerprint == ^fingerprint) ] @@ -333,7 +333,7 @@ defmodule Philomena.Bans do [ Subnet - |> select([s], %{reason: s.reason, valid_until: s.valid_until, generated_ban_id: s.generated_ban_id, type: "SubnetBan"}) + |> select([s], %{reason: s.reason, valid_until: s.valid_until, generated_ban_id: s.generated_ban_id, type: ^"SubnetBan"}) |> where([s], s.enabled and s.valid_until > ^now) |> where(fragment("specification >>= ?", ^inet)) ] @@ -343,7 +343,7 @@ defmodule Philomena.Bans do defp user_query(user, now) do [ User - |> select([u], %{reason: u.reason, valid_until: u.valid_until, generated_ban_id: u.generated_ban_id, type: "UserBan"}) + |> select([u], %{reason: u.reason, valid_until: u.valid_until, generated_ban_id: u.generated_ban_id, type: ^"UserBan"}) |> where([u], u.enabled and u.valid_until > ^now) |> where([u], u.user_id == ^user.id) ] diff --git a/lib/philomena/bans/fingerprint.ex b/lib/philomena/bans/fingerprint.ex index 8002a987..1ede621a 100644 --- a/lib/philomena/bans/fingerprint.ex +++ b/lib/philomena/bans/fingerprint.ex @@ -10,7 +10,7 @@ defmodule Philomena.Bans.Fingerprint do field :reason, :string field :note, :string field :enabled, :boolean, default: true - field :valid_until, :naive_datetime + field :valid_until, :utc_datetime field :fingerprint, :string field :generated_ban_id, :string diff --git a/lib/philomena/bans/subnet.ex b/lib/philomena/bans/subnet.ex index 9c7ef974..fcff1de9 100644 --- a/lib/philomena/bans/subnet.ex +++ b/lib/philomena/bans/subnet.ex @@ -10,7 +10,7 @@ defmodule Philomena.Bans.Subnet do field :reason, :string field :note, :string field :enabled, :boolean, default: true - field :valid_until, :naive_datetime + field :valid_until, :utc_datetime field :specification, EctoNetwork.INET field :generated_ban_id, :string diff --git a/lib/philomena/bans/user.ex b/lib/philomena/bans/user.ex index 53a3d6df..757d8f74 100644 --- a/lib/philomena/bans/user.ex +++ b/lib/philomena/bans/user.ex @@ -3,6 +3,8 @@ defmodule Philomena.Bans.User do import Ecto.Changeset alias Philomena.Users.User + alias Philomena.Repo + alias RelativeDate.Parser schema "user_bans" do belongs_to :user, User @@ -11,17 +13,71 @@ defmodule Philomena.Bans.User do field :reason, :string field :note, :string field :enabled, :boolean, default: true - field :valid_until, :naive_datetime + field :valid_until, :utc_datetime field :generated_ban_id, :string field :override_ip_ban, :boolean, default: false + field :username, :string, virtual: true + field :until, :string, virtual: true + timestamps(inserted_at: :created_at) end @doc false - def changeset(user, attrs) do - user + def changeset(user_ban, attrs) do + user_ban |> cast(attrs, []) - |> validate_required([]) + |> populate_until() + |> populate_username() end + + def save_changeset(user_ban, attrs) do + user_ban + |> cast(attrs, [:reason, :note, :enabled, :override_ip_ban, :username, :until]) + |> populate_valid_until() + |> populate_user_id() + |> put_ban_id() + |> validate_required([:reason, :enabled, :user_id, :valid_until]) + end + + defp populate_until(%{data: data} = changeset) do + put_change(changeset, :until, to_string(data.valid_until)) + end + + defp populate_valid_until(changeset) do + changeset + |> get_field(:until) + |> Parser.parse() + |> case do + {:ok, time} -> + change(changeset, valid_until: time) + + {:error, _err} -> + add_error(changeset, :until, "is not a valid absolute or relative date and time") + end + end + + defp populate_username(changeset) do + case maybe_get_by(:id, get_field(changeset, :user_id)) do + nil -> changeset + user -> put_change(changeset, :username, user.name) + end + end + + defp populate_user_id(changeset) do + case maybe_get_by(:name, get_field(changeset, :username)) do + nil -> changeset + %{id: id} -> put_change(changeset, :user_id, id) + end + end + + defp put_ban_id(%{data: %{generated_ban_id: nil}} = changeset) do + ban_id = Base.encode16(:crypto.strong_rand_bytes(3)) + + put_change(changeset, :generated_ban_id, "U#{ban_id}") + end + defp put_ban_id(changeset), do: changeset + + defp maybe_get_by(_field, nil), do: nil + defp maybe_get_by(field, value), do: Repo.get_by(User, [{field, value}]) end diff --git a/lib/philomena/users/ability.ex b/lib/philomena/users/ability.ex index b11799cf..540f364f 100644 --- a/lib/philomena/users/ability.ex +++ b/lib/philomena/users/ability.ex @@ -16,6 +16,10 @@ defimpl Canada.Can, for: [Atom, Philomena.Users.User] do alias Philomena.Tags.Tag alias Philomena.Reports.Report + alias Philomena.Bans.User, as: UserBan + alias Philomena.Bans.Subnet, as: SubnetBan + alias Philomena.Bans.Fingerprint, as: FingerprintBan + # Admins can do anything def can?(%User{role: "admin"}, _action, _model), do: true @@ -68,6 +72,11 @@ defimpl Canada.Can, for: [Atom, Philomena.Users.User] do def can?(%User{role: "moderator"}, :show_reason, %DnpEntry{}), do: true def can?(%User{role: "moderator"}, :show_feedback, %DnpEntry{}), do: true + # Create bans + def can?(%User{role: "moderator"}, _action, UserBan), do: true + def can?(%User{role: "moderator"}, _action, SubnetBan), do: true + def can?(%User{role: "moderator"}, _action, FingerprintBan), do: true + # # Assistants can... # diff --git a/lib/philomena_web/controllers/admin/user_ban_controller.ex b/lib/philomena_web/controllers/admin/user_ban_controller.ex new file mode 100644 index 00000000..fbbe7091 --- /dev/null +++ b/lib/philomena_web/controllers/admin/user_ban_controller.ex @@ -0,0 +1,94 @@ +defmodule PhilomenaWeb.Admin.UserBanController do + use PhilomenaWeb, :controller + + alias Philomena.Bans.User, as: UserBan + alias Philomena.Bans + alias Philomena.Repo + import Ecto.Query + + plug :verify_authorized + plug :load_resource, model: UserBan, only: [:edit, :update, :delete] + + def index(conn, %{"q" => q}) when is_binary(q) do + like_q = "%#{q}%" + + UserBan + |> join(:inner, [ub], _ in assoc(ub, :user)) + |> where([ub, u], + ilike(u.name, ^like_q) + or ub.generated_ban_id == ^q + or fragment("to_tsvector(?) @@ plainto_tsquery(?)", ub.reason, ^q) + or fragment("to_tsvector(?) @@ plainto_tsquery(?)", ub.note, ^q) + ) + |> load_bans(conn) + end + + def index(conn, %{"user_id" => user_id}) when is_binary(user_id) do + UserBan + |> where(user_id: ^user_id) + |> load_bans(conn) + end + + def index(conn, _params) do + load_bans(UserBan, conn) + end + + def new(conn, _params) do + changeset = Bans.change_user(%UserBan{}) + render(conn, "new.html", changeset: changeset) + end + + def create(conn, %{"user" => user_ban_params}) do + case Bans.create_user(conn.assigns.current_user, user_ban_params) do + {:ok, _user_ban} -> + conn + |> put_flash(:info, "User was successfully banned.") + |> redirect(to: Routes.admin_user_ban_path(conn, :index)) + + {:error, changeset} -> + render(conn, "new.html", changeset: changeset) + end + end + + def edit(conn, _params) do + changeset = Bans.change_user(conn.assigns.user) + render(conn, "edit.html", changeset: changeset) + end + + def update(conn, %{"user" => user_ban_params}) do + case Bans.update_user(conn.assigns.user, user_ban_params) do + {:ok, _user_ban} -> + conn + |> put_flash(:info, "User ban successfully updated.") + |> redirect(to: Routes.admin_user_ban_path(conn, :index)) + + {:error, changeset} -> + render(conn, "edit.html", changeset: changeset) + end + end + + def delete(conn, _params) do + {:ok, _user_ban} = Bans.delete_user(conn.assigns.user) + + conn + |> put_flash(:info, "User ban successfully deleted.") + |> redirect(to: Routes.admin_user_ban_path(conn, :index)) + end + + defp load_bans(queryable, conn) do + user_bans = + queryable + |> order_by(desc: :created_at) + |> preload([:user, :banning_user]) + |> Repo.paginate(conn.assigns.scrivener) + + render(conn, "index.html", layout_class: "layout--wide", user_bans: user_bans) + end + + defp verify_authorized(conn, _opts) do + case Canada.Can.can?(conn.assigns.current_user, :index, UserBan) do + true -> conn + false -> PhilomenaWeb.NotAuthorizedPlug.call(conn) + end + end +end diff --git a/lib/philomena_web/router.ex b/lib/philomena_web/router.ex index 6aaca70b..ca7918d4 100644 --- a/lib/philomena_web/router.ex +++ b/lib/philomena_web/router.ex @@ -190,6 +190,10 @@ defmodule PhilomenaWeb.Router do resources "/dnp_entries", DnpEntryController, only: [:index] do resources "/transition", DnpEntry.TransitionController, only: [:create], singleton: true end + + resources "/user_bans", UserBanController, only: [:index, :new, :create, :edit, :update, :delete] + resources "/subnet_bans", SubnetBanController, only: [:index, :new, :create, :edit, :update, :delete] + resources "/fingerprint_bans", FingerprintBanController, only: [:index, :new, :create, :edit, :update, :delete] end resources "/duplicate_reports", DuplicateReportController, only: [] do diff --git a/lib/philomena_web/templates/admin/user_ban/_form.html.slime b/lib/philomena_web/templates/admin/user_ban/_form.html.slime new file mode 100644 index 00000000..164991ee --- /dev/null +++ b/lib/philomena_web/templates/admin/user_ban/_form.html.slime @@ -0,0 +1,26 @@ += form_for @changeset, @action, fn f -> + = if @changeset.action do + .alert.alert-danger + p Oops, something went wrong! Please check the errors below. + + .field + => label f, :username, "Username:" + = text_input f, :username, class: "input", placeholder: "Username", required: true + + .field + => checkbox f, :override_ip_ban, class: "checkbox" + = label f, :override_ip_ban, "Override IP ban?" + + .field + => label f, :reason, "Reason (shown to the banned user, and to staff on the user's profile page):" + = text_input f, :reason, class: "input input--wide", placeholder: "Reason", required: true + + .field + => label f, :note, "Admin-only note:" + = text_input f, :note, class: "input input--wide", placeholder: "Note" + + .field + => label f, :until, "End time relative to now, in simple English (e.g. \"1 week from now\"):" + = text_input f, :until, class: "input input--wide", placeholder: "Until", required: true + + = submit "Save Ban", class: "button" diff --git a/lib/philomena_web/templates/admin/user_ban/edit.html.slime b/lib/philomena_web/templates/admin/user_ban/edit.html.slime new file mode 100644 index 00000000..ff4f23bb --- /dev/null +++ b/lib/philomena_web/templates/admin/user_ban/edit.html.slime @@ -0,0 +1,6 @@ +h1 Editing ban + += render PhilomenaWeb.Admin.UserBanView, "_form.html", changeset: @changeset, action: Routes.admin_user_ban_path(@conn, :update, @user), conn: @conn + +br += link "Back", to: Routes.admin_user_ban_path(@conn, :index) diff --git a/lib/philomena_web/templates/admin/user_ban/index.html.slime b/lib/philomena_web/templates/admin/user_ban/index.html.slime new file mode 100644 index 00000000..79dc6f52 --- /dev/null +++ b/lib/philomena_web/templates/admin/user_ban/index.html.slime @@ -0,0 +1,58 @@ +h1 User Bans + +- route = fn p -> Routes.admin_user_ban_path(@conn, :index, p) end +- pagination = render PhilomenaWeb.PaginationView, "_pagination.html", page: @user_bans, route: route, params: @conn.query_params + +.block + .block__header + = pagination + + .block__content + table.table + thead + tr + th User + th Created + th Expires + th Reason/Note + th Ban ID + th Auto IP Ban + th Options + + tbody + = for ban <- @user_bans do + tr + td + = link ban.user.name, to: Routes.profile_path(@conn, :show, ban.user) + + td + => pretty_time ban.created_at + = user_abbrv @conn, ban.banning_user + + td class=ban_row_class(ban) + = pretty_time ban.valid_until + + td + = ban.reason + + = if present?(ban.note) do + p.block.block--fixed + em + ' Note: + = ban.note + + td + = ban.generated_ban_id + + = if ban.override_ip_ban do + td.danger Disabled + - else + td.success Enabled + + td + => link "Edit", to: Routes.admin_user_ban_path(@conn, :edit, ban) + ' • + => link "Destroy", to: Routes.admin_user_ban_path(@conn, :delete, ban), data: [confirm: "Are you really, really sure?", method: "delete"] + + .block__header.block__header--light + = pagination diff --git a/lib/philomena_web/templates/admin/user_ban/new.html.slime b/lib/philomena_web/templates/admin/user_ban/new.html.slime new file mode 100644 index 00000000..468595cb --- /dev/null +++ b/lib/philomena_web/templates/admin/user_ban/new.html.slime @@ -0,0 +1,5 @@ +h1 New User Ban += render PhilomenaWeb.Admin.UserBanView, "_form.html", changeset: @changeset, action: Routes.admin_user_ban_path(@conn, :create), conn: @conn + +br += link "Back", to: Routes.admin_user_ban_path(@conn, :index) diff --git a/lib/philomena_web/templates/ban/_ban_reason.html.slime b/lib/philomena_web/templates/ban/_ban_reason.html.slime new file mode 100644 index 00000000..2537bc2e --- /dev/null +++ b/lib/philomena_web/templates/ban/_ban_reason.html.slime @@ -0,0 +1,16 @@ +.block.block--fixed.block--warning + h4 + ' You've been banned! + p + ' You cannot create comments or posts or update metadata (or do anything but read, really) until + = pretty_time @conn.assigns.current_ban.valid_until + ' . + + p + ' The reason given by the administrator who banned you is: + br + strong> + = @conn.assigns.current_ban.reason + ' (Ban ID: + = @conn.assigns.current_ban.generated_ban_id + ' ). diff --git a/lib/philomena_web/templates/comment/index.html.slime b/lib/philomena_web/templates/comment/index.html.slime index c02c46cf..dc3fc601 100644 --- a/lib/philomena_web/templates/comment/index.html.slime +++ b/lib/philomena_web/templates/comment/index.html.slime @@ -111,4 +111,4 @@ table.table td Literal td Matches comments with the specified user_id. Anonymous users will never match this term. td - code = link "user_id:211190", to: Routes.comment_path(@conn, :index, cq: "user_id:211190") \ No newline at end of file + code = link "user_id:211190", to: Routes.comment_path(@conn, :index, cq: "user_id:211190") diff --git a/lib/philomena_web/templates/conversation/show.html.slime b/lib/philomena_web/templates/conversation/show.html.slime index 2e28300d..e5b98825 100644 --- a/lib/philomena_web/templates/conversation/show.html.slime +++ b/lib/philomena_web/templates/conversation/show.html.slime @@ -27,13 +27,18 @@ h1 = @conversation.title .block__header.block__header--light = pagination -= if @messages.total_entries < 1_000 do - = render PhilomenaWeb.Conversation.MessageView, "_form.html", conversation: @conversation, changeset: @changeset, conn: @conn -- else - div - h2 Okay, we're impressed - p You've managed to send over 1,000 messages in this conversation! - p We'd like to ask you to make a new conversation. Don't worry, this one won't go anywhere if you need to refer back to it. - p - => link "Click here", to: Routes.conversation_path(@conn, :new, receipient: other.name) - ' to make a new conversation with this user. += cond do + - @conn.assigns.current_ban -> + = render PhilomenaWeb.BanView, "_ban_reason.html", conn: @conn + + - @messages.total_entries < 1_000 -> + = render PhilomenaWeb.Conversation.MessageView, "_form.html", conversation: @conversation, changeset: @changeset, conn: @conn + + - true -> + div + h2 Okay, we're impressed + p You've managed to send over 1,000 messages in this conversation! + p We'd like to ask you to make a new conversation. Don't worry, this one won't go anywhere if you need to refer back to it. + p + => link "Click here", to: Routes.conversation_path(@conn, :new, receipient: other.name) + ' to make a new conversation with this user. diff --git a/lib/philomena_web/templates/image/comment/index.html.slime b/lib/philomena_web/templates/image/comment/index.html.slime index 68db3532..b84675ad 100644 --- a/lib/philomena_web/templates/image/comment/index.html.slime +++ b/lib/philomena_web/templates/image/comment/index.html.slime @@ -13,7 +13,7 @@ elixir: i.fa.fa-sync span.hide-mobile<> Refresh -= for {comment, body} <- @comments do += for {comment, body} <- @comments, not comment.destroyed_content or can?(@conn, :show, comment) do = render PhilomenaWeb.CommentView, "_comment.html", comment: comment, body: body, conn: @conn .block diff --git a/lib/philomena_web/templates/image/show.html.slime b/lib/philomena_web/templates/image/show.html.slime index 4f652020..75c75448 100644 --- a/lib/philomena_web/templates/image/show.html.slime +++ b/lib/philomena_web/templates/image/show.html.slime @@ -16,12 +16,7 @@ h4 Comments = cond do - @conn.assigns.current_ban -> - .block.block--fixed.block--warning - h4 You've been banned! - p - ' You cannot post comments or update metadata (or do anything but - ' read, really) until - = pretty_time(@conn.assigns.current_ban.valid_until) + = render PhilomenaWeb.BanView, "_ban_reason.html", conn: @conn - @image.commenting_allowed -> = render PhilomenaWeb.Image.CommentView, "_form.html", image: @image, changeset: @comment_changeset, conn: @conn @@ -29,4 +24,4 @@ - true -> #comments data-current-url=Routes.image_comment_path(@conn, :index, @image, page: 1) data-loaded="true" - = render PhilomenaWeb.Image.CommentView, "index.html", image: @image, comments: @comments, conn: @conn \ No newline at end of file + = render PhilomenaWeb.Image.CommentView, "index.html", image: @image, comments: @comments, conn: @conn diff --git a/lib/philomena_web/templates/layout/_header_staff_links.html.slime b/lib/philomena_web/templates/layout/_header_staff_links.html.slime index 3bef6273..f8a5d9d6 100644 --- a/lib/philomena_web/templates/layout/_header_staff_links.html.slime +++ b/lib/philomena_web/templates/layout/_header_staff_links.html.slime @@ -71,15 +71,15 @@ ' B i.fa.fa-caret-down .dropdown__content.dropdown__content-right.js-burger-links - = if can?(@conn, :mod_read, UserBan) do - = link to: "#", class: "header__link" do + = if can?(@conn, :index, UserBan) do + = link to: Routes.admin_user_ban_path(@conn, :index), class: "header__link" do i.fa.fa-fw.fa-user> ' User Bans - = if can?(@conn, :mod_read, SubnetBan) do - = link to: "#", class: "header__link" do + = if can?(@conn, :index, SubnetBan) do + = link to: Routes.admin_subnet_ban_path(@conn, :index), class: "header__link" do i.fab.fa-fw.fa-internet-explorer> ' IP Bans - = if can?(@conn, :mod_read, FingerprintBan) do - = link to: "#", class: "header__link" do + = if can?(@conn, :index, FingerprintBan) do + = link to: Routes.admin_fingerprint_ban_path(@conn, :index), class: "header__link" do i.fa.fa-fw.fa-desktop> ' FP Bans diff --git a/lib/philomena_web/templates/topic/show.html.slime b/lib/philomena_web/templates/topic/show.html.slime index 6458dc66..b9811987 100644 --- a/lib/philomena_web/templates/topic/show.html.slime +++ b/lib/philomena_web/templates/topic/show.html.slime @@ -62,6 +62,9 @@ h1 = @topic.title / Post form = cond do + - @conn.assigns.current_ban -> + = render PhilomenaWeb.BanView, "_ban_reason.html", conn: @conn + - @topic.post_count < 200_000 and can?(@conn, :create_post, @topic) -> = render PhilomenaWeb.Topic.PostView, "_form.html", conn: @conn, forum: @forum, topic: @topic, changeset: @changeset diff --git a/lib/philomena_web/views/admin/user_ban_view.ex b/lib/philomena_web/views/admin/user_ban_view.ex new file mode 100644 index 00000000..fa54c658 --- /dev/null +++ b/lib/philomena_web/views/admin/user_ban_view.ex @@ -0,0 +1,14 @@ +defmodule PhilomenaWeb.Admin.UserBanView do + use PhilomenaWeb, :view + + import PhilomenaWeb.ProfileView, only: [user_abbrv: 2] + + defp ban_row_class(%{valid_until: until, enabled: enabled}) do + now = DateTime.utc_now() + + case enabled and DateTime.diff(until, now) > 0 do + true -> "success" + _false -> "danger" + end + end +end