diff --git a/app/blueprints/report/__init__.py b/app/blueprints/report/__init__.py index 21ab517d..de41513c 100644 --- a/app/blueprints/report/__init__.py +++ b/app/blueprints/report/__init__.py @@ -19,19 +19,21 @@ from flask_babel import lazy_gettext from flask_login import current_user from flask_wtf import FlaskForm from werkzeug.utils import redirect -from wtforms import TextAreaField, SubmitField -from wtforms.validators import InputRequired, Length +from wtforms import TextAreaField, SubmitField, URLField, StringField, RadioField +from wtforms.validators import InputRequired, Length, Optional -from app.models import User, UserRank -from app.tasks.emails import send_user_email +from app.models import User, UserRank, Report, db, AuditSeverity, Thread from app.tasks.webhooktasks import post_discord_webhook -from app.utils import is_no, abs_url_samesite, normalize_line_endings +from app.utils import is_no, abs_url_samesite, normalize_line_endings, rank_required, add_audit_log, abs_url_for, \ + add_replies bp = Blueprint("report", __name__) class ReportForm(FlaskForm): - message = TextAreaField(lazy_gettext("Message"), [InputRequired(), Length(10, 10000)], filters=[normalize_line_endings]) + url = URLField(lazy_gettext("URL"), [Optional()]) + title = StringField(lazy_gettext("Subject / Title"), [InputRequired(), Length(10, 300)]) + message = TextAreaField(lazy_gettext("Message"), [Optional(), Length(0, 10000)], filters=[normalize_line_endings]) submit = SubmitField(lazy_gettext("Report")) @@ -48,23 +50,68 @@ def report(): form = ReportForm(formdata=request.form) if current_user.is_authenticated else None if form and request.method == "GET": + form.url.data = url form.message.data = request.args.get("message", "") if form and form.validate_on_submit(): + report = Report() + report.user = current_user if current_user.is_authenticated else None + form.populate_obj(report) + + if not current_user.is_authenticated: + ip_addr = request.headers.get("X-Forwarded-For") or request.remote_addr + report.message = ip_addr + "\n\n" + report.message + + db.session.add(report) + db.session.flush() + if current_user.is_authenticated: - user_info = f"{current_user.username}" + add_audit_log(AuditSeverity.USER, current_user, f"New report: {report.title}", + url_for("report.view", rid=report.id)) + + db.session.commit() + + abs_url = abs_url_for("report.view", rid=report.id) + msg = f"**New Report**\nReport on `{report.url}`\n\n{report.title}\n\nView: {abs_url}" + post_discord_webhook.delay(None if is_anon else current_user.username, msg, True) + + return redirect(url_for("report.view", rid=report.id)) + + return render_template("report/report.html", form=form, url=url, is_anon=is_anon, noindex=url is not None) + + +@bp.route("/admin/reports/") +@rank_required(UserRank.MODERATOR) +def list_all(): + reports = Report.query.order_by(db.desc(Report.is_resolved), db.desc(Report.created_at)).all() + return render_template("report/list.html", reports=reports) + + +class ResolveForm(FlaskForm): + completed = SubmitField(lazy_gettext("Completed / resolved")) + removed = SubmitField(lazy_gettext("Content removed")) + invalid = SubmitField(lazy_gettext("Invalid / No action taken")) + + +@bp.route("/admin/reports//", methods=["GET", "POST"]) +@rank_required(UserRank.MODERATOR) +def view(rid: int): + report = Report.query.get_or_404(rid) + + resolve_form = ResolveForm(request.form) + if resolve_form.validate_on_submit(): + if resolve_form.completed.data: + outcome = "completed" + elif resolve_form.removed.data: + outcome = "removed" + elif resolve_form.invalid.data: + outcome = "invalid" else: - user_info = request.headers.get("X-Forwarded-For") or request.remote_addr + abort(400) - text = f"{url}\n\n{form.message.data}" + report.is_resolved = True + url = url_for("report.view", rid=report.id) + add_audit_log(AuditSeverity.MODERATION, current_user, f"Resolved report as {outcome} \"{report.title}\"", url) + db.session.commit() - task = None - for admin in User.query.filter_by(rank=UserRank.ADMIN).all(): - task = send_user_email.delay(admin.email, admin.locale or "en", - f"User report from {user_info}", text) - - post_discord_webhook.delay(None if is_anon else current_user.username, f"**New Report**\n{url}\n\n{form.message.data}", True) - - return redirect(url_for("tasks.check", id=task.id, r=url_for("homepage.home"))) - - return render_template("report/index.html", form=form, url=url, is_anon=is_anon, noindex=url is not None) + return render_template("report/view.html", report=report, resolve_form=resolve_form) diff --git a/app/models/__init__.py b/app/models/__init__.py index b0e36059..5126dca2 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -130,6 +130,24 @@ class AuditLogEntry(db.Model): raise Exception("Permission {} is not related to audit log entries".format(perm.name)) +class Report(db.Model): + id = db.Column(db.Integer, primary_key=True) + + created_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) + + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + user = db.relationship("User", foreign_keys=[user_id], back_populates="reports") + + thread_id = db.Column(db.Integer, db.ForeignKey("thread.id"), nullable=True) + thread = db.relationship("Thread", foreign_keys=[thread_id]) + + url = db.Column(db.String, nullable=True) + title = db.Column(db.Unicode(300), nullable=False) + message = db.Column(db.UnicodeText, nullable=False) + + is_resolved = db.Column(db.Boolean, nullable=False, default=False) + + REPO_BLACKLIST = [".zip", "mediafire.com", "dropbox.com", "weebly.com", "minetest.net", "luanti.org", "dropboxusercontent.com", "4shared.com", "digitalaudioconcepts.com", "hg.intevation.org", "www.wtfpl.net", diff --git a/app/models/users.py b/app/models/users.py index 7f80a4fc..aaf42b91 100644 --- a/app/models/users.py +++ b/app/models/users.py @@ -210,6 +210,7 @@ class User(db.Model, UserMixin): forum_topics = db.relationship("ForumTopic", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan") collections = db.relationship("Collection", back_populates="author", lazy="dynamic", cascade="all, delete, delete-orphan", order_by=db.asc("title")) clients = db.relationship("OAuthClient", back_populates="owner", lazy="dynamic", cascade="all, delete, delete-orphan") + reports = db.relationship("Report", back_populates="user", lazy="dynamic", cascade="all") ban = db.relationship("UserBan", foreign_keys="UserBan.user_id", back_populates="user", uselist=False) diff --git a/app/templates/report/list.html b/app/templates/report/list.html new file mode 100644 index 00000000..9d35cf7b --- /dev/null +++ b/app/templates/report/list.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} + +{% block title -%} + {{ _("Reports") }} +{%- endblock %} + + +{% block content %} + +

{{ self.title() }}

+ + + +{% endblock %} diff --git a/app/templates/report/index.html b/app/templates/report/report.html similarity index 56% rename from app/templates/report/index.html rename to app/templates/report/report.html index ee1d0d40..1e627e0a 100644 --- a/app/templates/report/index.html +++ b/app/templates/report/report.html @@ -4,6 +4,10 @@ {{ _("Report") }} {%- endblock %} +{% from "macros/forms.html" import render_field, render_submit_field, render_checkbox_field, easymde_scripts %} +{% block scriptextra %} + {{ easymde_scripts() }} +{% endblock %} {% block content %} @@ -22,23 +26,17 @@ {% else %} -{% from "macros/forms.html" import render_field, render_submit_field, render_checkbox_field %} -
{{ form.hidden_tag() }} - {% if url %} -

- URL: {{ url }} -

- {% endif %} - {{ render_field(form.message, hint=_("What are you reporting? Why are you reporting it?")) }} + {{ render_field(form.url) }} + {{ render_field(form.title) }} + {{ render_field(form.message, class_="m-0", fieldclass="form-control markdown", data_enter_submit="1") }} {{ render_submit_field(form.submit) }}

- {{ _("Reports will be shared with ContentDB staff.") }} - {% if is_anon %} - {{ _("Only the admin will be able to see who made the report.") }} - {% endif %} + {{ _("The full report will be visible to all ContentDB staff members, including editors and moderators.") }} + {{ _("If you are reporting content posted by another user, we may discuss the report with them but will not disclose who reported it.") }} + {{ _("If you are reporting content by an editor or moderator, then you should email the admin or a moderator directly to hide your identity.") }}

{{ _("Found a bug? Please report on the package's issue tracker or in a thread instead.") }} diff --git a/app/templates/report/view.html b/app/templates/report/view.html new file mode 100644 index 00000000..4387984b --- /dev/null +++ b/app/templates/report/view.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} + +{% block title -%} + {{ report.title }} +{%- endblock %} + +{% from "macros/forms.html" import render_field, render_submit_field, render_radio_field, easymde_scripts %} +{% block scriptextra %} + {{ easymde_scripts() }} +{% endblock %} + +{% block content %} + +

+ Back to reports +

+ +

+ {% if report.is_resolved %} + + Closed + + {% else %} + + Open + + {% endif %} + {{ self.title() }} +

+ +
+
+
+
+ {{ report.message | markdown }} +
+
+
+ + +
+ +{% if not report.is_resolved %} +
+

Quick resolve

+ + {{ resolve_form.hidden_tag() }} + {{ render_submit_field(resolve_form.completed) }} + {{ render_submit_field(resolve_form.removed) }} + {{ render_submit_field(resolve_form.invalid) }} + +
+{% endif %} + +{% endblock %} diff --git a/migrations/versions/3052712496e4_.py b/migrations/versions/3052712496e4_.py new file mode 100644 index 00000000..f07d446b --- /dev/null +++ b/migrations/versions/3052712496e4_.py @@ -0,0 +1,30 @@ +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '3052712496e4' +down_revision = '663521dfe86d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('report', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('thread_id', sa.Integer(), nullable=True), + sa.Column('url', sa.String(), nullable=True), + sa.Column('title', sa.Unicode(length=300), nullable=False), + sa.Column('message', sa.UnicodeText(), nullable=False), + sa.Column('is_resolved', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['thread_id'], ['thread.id'], ), + sa.PrimaryKeyConstraint('id') + ) + + +def downgrade(): + op.drop_table('report')