diff --git a/config.example.py b/config.example.py index a4c8025..4ca314f 100644 --- a/config.example.py +++ b/config.example.py @@ -176,3 +176,15 @@ EDITING_TIME_LIMIT = 0 # Whether to use Gravatar or just always use the default avatar # (Useful if run as development instance behind NAT/firewall) ENABLE_GRAVATAR = True + +########################## +## Trusted Requirements ## +########################## + +# Minimum number of uploads the user needs to have in order to apply for trusted +TRUSTED_MIN_UPLOADS = 10 +# Minimum number of cumulative downloads the user needs to have across their +# torrents in order to apply for trusted +TRUSTED_MIN_DOWNLOADS = 10000 +# Number of days an applicant needs to wait before re-applying +TRUSTED_REAPPLY_COOLDOWN = 90 diff --git a/migrations/versions/5cbcee17bece_add_trusted_applications.py b/migrations/versions/5cbcee17bece_add_trusted_applications.py new file mode 100644 index 0000000..29167cb --- /dev/null +++ b/migrations/versions/5cbcee17bece_add_trusted_applications.py @@ -0,0 +1,47 @@ +"""Add trusted applications + +Revision ID: 5cbcee17bece +Revises: 8a6a7662eb37 +Create Date: 2018-11-05 15:16:07.497898 + +""" +from alembic import op +import sqlalchemy as sa +import sqlalchemy_utils + + +# revision identifiers, used by Alembic. +revision = '5cbcee17bece' +down_revision = '8a6a7662eb37' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table('trusted_applications', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('submitter_id', sa.Integer(), nullable=False, index=True), + sa.Column('created_time', sa.DateTime(), nullable=True), + sa.Column('closed_time', sa.DateTime(), nullable=True), + sa.Column('why_want', sa.String(length=4000), nullable=False), + sa.Column('why_give', sa.String(length=4000), nullable=False), + sa.Column('status', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['submitter_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('trusted_reviews', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('reviewer_id', sa.Integer(), nullable=False), + sa.Column('app_id', sa.Integer(), nullable=False), + sa.Column('created_time', sa.DateTime(), nullable=True), + sa.Column('comment', sa.String(length=4000), nullable=False), + sa.Column('recommendation', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['app_id'], ['trusted_applications.id'], ), + sa.ForeignKeyConstraint(['reviewer_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + + +def downgrade(): + op.drop_table('trusted_reviews') + op.drop_table('trusted_applications') diff --git a/nyaa/forms.py b/nyaa/forms.py index d9e2620..09c3926 100644 --- a/nyaa/forms.py +++ b/nyaa/forms.py @@ -485,6 +485,33 @@ class ReportActionForm(FlaskForm): report = HiddenField() +class TrustedForm(FlaskForm): + why_give_trusted = TextAreaField('Why do you think you should be given trusted status?', [ + Length(min=32, max=4000, + message='Please explain why you think you should be given trusted status in at ' + 'least %(min)d but less than %(max)d characters.'), + DataRequired('Please fill out all of the fields in the form.') + ]) + why_want_trusted = TextAreaField('Why do you want to become a trusted user?', [ + Length(min=32, max=4000, + message='Please explain why you want to become a trusted user in at least %(min)d ' + 'but less than %(max)d characters.'), + DataRequired('Please fill out all of the fields in the form.') + ]) + + +class TrustedReviewForm(FlaskForm): + comment = TextAreaField('Comment', + [Length(min=8, max=4000, message='Please provide a comment')]) + recommendation = SelectField(choices=[('abstain', 'Abstain'), ('reject', 'Reject'), + ('accept', 'Accept')]) + + +class TrustedDecisionForm(FlaskForm): + accept = SubmitField('Accept') + reject = SubmitField('Reject') + + def _validate_trackers(torrent_dict, tracker_to_check_for=None): announce = torrent_dict.get('announce') assert announce is not None, 'no tracker in torrent' diff --git a/nyaa/models.py b/nyaa/models.py index 687bd5c..44697a0 100644 --- a/nyaa/models.py +++ b/nyaa/models.py @@ -11,8 +11,9 @@ from urllib.parse import urlencode import flask from markupsafe import escape as escape_markup -from sqlalchemy import ForeignKeyConstraint, Index +from sqlalchemy import ForeignKeyConstraint, Index, func from sqlalchemy.ext import declarative +from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy_fulltext import FullText from sqlalchemy_utils import ChoiceType, EmailType, PasswordType @@ -642,6 +643,24 @@ class User(db.Model): ''' Returns a UTC POSIX timestamp, as seconds ''' return (self.created_time - UTC_EPOCH).total_seconds() + @property + def satisfies_trusted_reqs(self): + num_total = 0 + downloads_total = 0 + for ts_flavor, t_flavor in ((NyaaStatistic, NyaaTorrent), + (SukebeiStatistic, SukebeiTorrent)): + uploads = db.session.query(func.count(t_flavor.id)).\ + filter(t_flavor.user == self).\ + filter(t_flavor.flags.op('&')(int(TorrentFlags.REMAKE)).is_(False)).scalar() + dls = db.session.query(func.sum(ts_flavor.download_count)).\ + join(t_flavor).\ + filter(t_flavor.user == self).\ + filter(t_flavor.flags.op('&')(int(TorrentFlags.REMAKE)).is_(False)).scalar() + num_total += uploads or 0 + downloads_total += dls or 0 + return (num_total >= config['TRUSTED_MIN_UPLOADS'] and + downloads_total >= config['TRUSTED_MIN_DOWNLOADS']) + class UserPreferences(db.Model): __tablename__ = 'user_preferences' @@ -845,6 +864,77 @@ class RangeBan(db.Model): return q.count() > 0 +class TrustedApplicationStatus(IntEnum): + # If you change these, don't forget to change is_closed in TrustedApplication + NEW = 0 + REVIEWED = 1 + ACCEPTED = 2 + REJECTED = 3 + + +class TrustedApplication(db.Model): + __tablename__ = 'trusted_applications' + + id = db.Column(db.Integer, primary_key=True) + submitter_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + created_time = db.Column(db.DateTime(timezone=False), default=datetime.utcnow) + closed_time = db.Column(db.DateTime(timezone=False)) + why_want = db.Column(db.String(length=4000), nullable=False) + why_give = db.Column(db.String(length=4000), nullable=False) + status = db.Column(ChoiceType(TrustedApplicationStatus, impl=db.Integer()), nullable=False, + default=TrustedApplicationStatus.NEW) + reviews = db.relationship('TrustedReview', backref='trusted_applications') + submitter = db.relationship('User', uselist=False, lazy='joined', foreign_keys=[submitter_id]) + + @hybrid_property + def is_closed(self): + # We can't use the attribute names from TrustedApplicationStatus in an or here because of + # SQLAlchemy jank. It'll generate the wrong query. + return self.status > 1 + + @hybrid_property + def is_new(self): + return self.status == TrustedApplicationStatus.NEW + + @hybrid_property + def is_reviewed(self): + return self.status == TrustedApplicationStatus.REVIEWED + + @hybrid_property + def is_rejected(self): + return self.status == TrustedApplicationStatus.REJECTED + + @property + def created_utc_timestamp(self): + ''' Returns a UTC POSIX timestamp, as seconds ''' + return (self.created_time - UTC_EPOCH).total_seconds() + + @classmethod + def by_id(cls, id): + return cls.query.get(id) + + +class TrustedRecommendation(IntEnum): + ACCEPT = 0 + REJECT = 1 + ABSTAIN = 2 + + +class TrustedReview(db.Model): + __tablename__ = 'trusted_reviews' + + id = db.Column(db.Integer, primary_key=True) + reviewer_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + app_id = db.Column(db.Integer, db.ForeignKey('trusted_applications.id'), nullable=False) + created_time = db.Column(db.DateTime(timezone=False), default=datetime.utcnow) + comment = db.Column(db.String(length=4000), nullable=False) + recommendation = db.Column(ChoiceType(TrustedRecommendation, impl=db.Integer()), + nullable=False) + reviewer = db.relationship('User', uselist=False, lazy='joined', foreign_keys=[reviewer_id]) + application = db.relationship('TrustedApplication', uselist=False, lazy='joined', + foreign_keys=[app_id]) + + # Actually declare our site-specific classes # Torrent diff --git a/nyaa/static/css/main.css b/nyaa/static/css/main.css index 455f702..0ecd081 100644 --- a/nyaa/static/css/main.css +++ b/nyaa/static/css/main.css @@ -448,6 +448,9 @@ h6:hover .header-anchor { visibility: visible; display: inline-block; } +.trusted-form textarea { + height:12em; +} /* Dark theme */ diff --git a/nyaa/static/js/main.js b/nyaa/static/js/main.js index 8eb3624..efe41d9 100644 --- a/nyaa/static/js/main.js +++ b/nyaa/static/js/main.js @@ -251,6 +251,11 @@ document.addEventListener("DOMContentLoaded", function() { var target = markdownTargets[i]; var rendered; var markdownSource = htmlDecode(target.innerHTML); + if (target.attributes["markdown-no-images"]) { + markdown.disable('image'); + } else { + markdown.enable('image'); + } if (target.attributes["markdown-text-inline"]) { rendered = markdown.renderInline(markdownSource); } else { diff --git a/nyaa/templates/_formhelpers.html b/nyaa/templates/_formhelpers.html index 71673d1..9a8b00c 100644 --- a/nyaa/templates/_formhelpers.html +++ b/nyaa/templates/_formhelpers.html @@ -113,7 +113,7 @@ {% endmacro %} -{% macro render_menu_with_button(field) %} +{% macro render_menu_with_button(field, button_label='Apply') %} {% if field.errors %}
{% else %} @@ -123,7 +123,7 @@
{{ field(title=field.description, class_="form-control",**kwargs) | safe }}
- +
{% if field.errors %} diff --git a/nyaa/templates/admin_trusted.html b/nyaa/templates/admin_trusted.html new file mode 100644 index 0000000..a04b6bf --- /dev/null +++ b/nyaa/templates/admin_trusted.html @@ -0,0 +1,54 @@ +{% extends "layout.html" %} +{% macro render_filter_tab(name) %} + +{% endmacro %} +{% block title %}Trusted Applications :: {{ config.SITE_NAME }}{% endblock %} +{% block body %} + +
+ + + + + + + + + + + + + {% for app in apps.items %} + + + + + + + + {% endfor %} + +
List of {{ list_filter or 'open' }} applications
#SubmitterSubmitted onStatus
{{ app.id }} + + {{ app.submitter.username }} + + {{ app.created_time.strftime('%Y-%m-%d %H:%M') }}{{ app.status.name.capitalize() }}View
+
+ +{% endblock %} diff --git a/nyaa/templates/admin_trusted_view.html b/nyaa/templates/admin_trusted_view.html new file mode 100644 index 0000000..afccbf9 --- /dev/null +++ b/nyaa/templates/admin_trusted_view.html @@ -0,0 +1,114 @@ +{% extends "layout.html" %} +{% from "_formhelpers.html" import render_field, render_menu_with_button %} +{%- macro review_class(rec) -%} +{%- if rec.name == 'ACCEPT' -%} +{{ 'panel-success' -}} +{%- elif rec.name == 'REJECT' -%} +{{ 'panel-danger' -}} +{%- elif rec.name == 'ABSTAIN' -%} +{{ 'panel-default' -}} +{%- endif -%} +{%- endmacro -%} +{% block title %}{{ app.submitter.username }}'s Application :: {{ config.SITE_NAME }}{% endblock %} +{% block body %} +
+
+

{{ app.submitter.username }}'s Application

+
+
+
+
+ +
+
Submitted on
+
+ {{ app.created_time.strftime('%Y-%m-%d %H:%M') }} +
+
+
+
Status
+
{{ app.status.name.capitalize() }}
+
+
+
+
+
+
+

Why do you think you should be given trusted status?

+
+
+ {{- app.why_give | escape | replace('\r\n', '\n') | replace('\n', ' '|safe) -}} +
+
+
+
+
+
+

Why do you want to become a trusted user?

+
+
+ {{- app.why_want | escape | replace('\r\n', '\n') | replace('\n', ' '|safe) -}} +
+
+
+
+
+ {%- if decision_form -%} + + {%- endif -%} +
+
+
+

Reviews - {{ app.reviews | length }}

+
+
+ {% for rev in app.reviews %} +
+
+

{{ rev.reviewer.username }}'s Review

+
+
+
+ {{- rev.comment | escape | replace('\r\n', '\n') | replace('\n', ' '|safe) -}} +
+
+ +
+ {% endfor %} +
+ {{ review_form.csrf_token }} +
+
+ {{ render_field(review_form.comment, class_="form-control") }} +
+
+
+
+ {{ render_menu_with_button(review_form.recommendation, 'Submit') }} +
+
+
+
+
+{% endblock %} diff --git a/nyaa/templates/email/trusted.html b/nyaa/templates/email/trusted.html new file mode 100644 index 0000000..2b41f81 --- /dev/null +++ b/nyaa/templates/email/trusted.html @@ -0,0 +1,14 @@ + + + Your {{ config.GLOBAL_SITE_NAME }} Trusted Application was {{ 'accepted' if is_accepted else 'rejected' }} + + + {% if is_accepted %} +

Congratulations! Your Trusted status application on {{ config.GLOBAL_SITE_NAME }} was accepted. You can now edit your torrents and set the Trusted flag on them.

+ {% else %} +

We're sorry to inform you that we've rejected your Trusted status application on {{ config.GLOBAL_SITE_NAME }}. You can re-apply for Trusted status in {{ config.TRUSTED_REAPPLY_COOLDOWN }} days if you wish to do so.

+ {% endif %} +

Regards
+ The {{ config.GLOBAL_SITE_NAME }} Moderation Team

+ + diff --git a/nyaa/templates/email/trusted.txt b/nyaa/templates/email/trusted.txt new file mode 100644 index 0000000..5bf9935 --- /dev/null +++ b/nyaa/templates/email/trusted.txt @@ -0,0 +1,8 @@ +{% if is_accepted %} +Congratulations! Your Trusted status application on {{ config.GLOBAL_SITE_NAME }} was accepted. You can now edit your torrents and set the Trusted flag on them. +{% else %} +We're sorry to inform you that we've rejected your Trusted status application on {{ config.GLOBAL_SITE_NAME }}. You can re-apply for Trusted status in {{ config.TRUSTED_REAPPLY_COOLDOWN }} days if you wish to do so. +{% endif %} + +Regards +The {{ config.GLOBAL_SITE_NAME }} Moderation Team diff --git a/nyaa/templates/layout.html b/nyaa/templates/layout.html index 2c8c1e5..029b1e3 100644 --- a/nyaa/templates/layout.html +++ b/nyaa/templates/layout.html @@ -90,6 +90,7 @@
  • RSS
  • @@ -108,6 +109,7 @@
  • Reports
  • Log
  • Bans
  • +
  • Trusted
  • {% endif %} diff --git a/nyaa/templates/trusted.html b/nyaa/templates/trusted.html new file mode 100644 index 0000000..f296769 --- /dev/null +++ b/nyaa/templates/trusted.html @@ -0,0 +1,17 @@ +{% extends "layout.html" %} +{% block title %}Trusted :: {{ config.SITE_NAME }}{% endblock %} +{% block body %} + +
    +
    +
    + {% include "trusted_rules.html" %} +
    +
    + +
    +{% endblock %} diff --git a/nyaa/templates/trusted_form.html b/nyaa/templates/trusted_form.html new file mode 100644 index 0000000..e052020 --- /dev/null +++ b/nyaa/templates/trusted_form.html @@ -0,0 +1,51 @@ +{% extends "layout.html" %} +{% from "_formhelpers.html" import render_field %} +{% block title %}Apply for Trusted :: {{ config.SITE_NAME }}{% endblock %} +{% block body %} +
    + {% if trusted_form %} +
    +
    +

    You are eligible to apply for trusted status

    +
    +
    +
    + {{ trusted_form.csrf_token }} +
    +
    + {{ render_field(trusted_form.why_give_trusted, class_='form-control') }} +
    +
    +
    +
    + {{ render_field(trusted_form.why_want_trusted, class_='form-control') }} +
    +
    +
    +
    + +
    +
    +
    + {% else %} +
    +
    +

    You are currently not eligible to apply for trusted status

    +
    +
    +
    +
    +

    + You currently are not eligible to apply for trusted status for the following + reason{% if deny_reasons|length > 1 %}s{% endif %}: +

    +
      + {% for reason in deny_reasons %} +
    • {{ reason }}
    • + {% endfor %} +
    +
    +
    + {% endif %} +
    +{% endblock %} diff --git a/nyaa/templates/trusted_rules.html b/nyaa/templates/trusted_rules.html new file mode 100644 index 0000000..954750d --- /dev/null +++ b/nyaa/templates/trusted_rules.html @@ -0,0 +1 @@ +

    Trusted rules go here

    diff --git a/nyaa/views/account.py b/nyaa/views/account.py index f4fe6a0..15c0619 100644 --- a/nyaa/views/account.py +++ b/nyaa/views/account.py @@ -233,6 +233,49 @@ def profile(): return flask.render_template('profile.html', form=form) +@bp.route('/trusted/request', methods=['GET', 'POST']) +def request_trusted(): + if not flask.g.user: + return flask.redirect(flask.url_for('account.login')) + trusted_form = None + deny_reasons = [] + if flask.g.user.is_trusted: + deny_reasons.append('You are already trusted.') + if not flask.g.user.satisfies_trusted_reqs: + deny_reasons.append('You do not satisfy the minimum requirements.') + if (models.TrustedApplication.query. + filter(models.TrustedApplication.submitter_id == flask.g.user.id). + filter_by(is_closed=False).first()): + deny_reasons.append('You already have an open application.') + last_app = models.TrustedApplication.query \ + .filter(models.TrustedApplication.submitter_id == flask.g.user.id) \ + .filter_by(is_rejected=True) \ + .order_by(models.TrustedApplication.closed_time.desc()) \ + .first() + if last_app: + if ((datetime.utcnow() - last_app.closed_time).days < + app.config['TRUSTED_REAPPLY_COOLDOWN']): + deny_reasons.append('Your last application was rejected less than {} days ago.' + .format(app.config['TRUSTED_REAPPLY_COOLDOWN'])) + if flask.request.method == 'POST': + trusted_form = forms.TrustedForm(flask.request.form) + if trusted_form.validate() and not deny_reasons: + ta = models.TrustedApplication() + ta.submitter_id = flask.g.user.id + ta.why_want = trusted_form.why_want_trusted.data.rstrip() + ta.why_give = trusted_form.why_give_trusted.data.rstrip() + db.session.add(ta) + db.session.commit() + flask.flash('Your trusted application has been submitted. ' + 'You will receive an email when a decision has been made.', 'success') + return flask.redirect(flask.url_for('site.trusted')) + else: + if len(deny_reasons) == 0: + trusted_form = forms.TrustedForm() + return flask.render_template('trusted_form.html', trusted_form=trusted_form, + deny_reasons=deny_reasons) + + def redirect_url(): next_url = flask.request.args.get('next', '') referrer = flask.request.referrer or '' diff --git a/nyaa/views/admin.py b/nyaa/views/admin.py index 27a58e3..e2f4c68 100644 --- a/nyaa/views/admin.py +++ b/nyaa/views/admin.py @@ -1,10 +1,12 @@ +from datetime import datetime from ipaddress import ip_address import flask -from nyaa import forms, models +from nyaa import email, forms, models from nyaa.extensions import db +app = flask.current_app bp = flask.Blueprint('admin', __name__, url_prefix='/admin') @@ -114,3 +116,84 @@ def view_reports(): return flask.render_template('reports.html', reports=reports, report_action=report_action) + + +@bp.route('/trusted/', endpoint='trusted', methods=['GET']) +@bp.route('/trusted', endpoint='trusted', methods=['GET']) +def view_trusted(list_filter=None): + if not flask.g.user or not flask.g.user.is_moderator: + flask.abort(403) + + page = flask.request.args.get('p', flask.request.args.get('offset', 1, int), int) + q = db.session.query(models.TrustedApplication) + if list_filter == 'closed': + q = q.filter_by(is_closed=True) + else: + q = q.filter_by(is_closed=False) + if list_filter == 'new': + q = q.filter_by(is_new=True) + elif list_filter == 'reviewed': + q = q.filter_by(is_reviewed=True) + elif list_filter is not None: + flask.abort(404) + apps = q.order_by(models.TrustedApplication.created_time.desc()) \ + .paginate(page=page, per_page=20) + + return flask.render_template('admin_trusted.html', apps=apps, + list_filter=list_filter) + + +@bp.route('/trusted/application/', endpoint='trusted_application', + methods=['GET', 'POST']) +def view_trusted_application(app_id): + if not flask.g.user or not flask.g.user.is_moderator: + flask.abort(403) + app = models.TrustedApplication.by_id(app_id) + if not app: + flask.abort(404) + decision_form = None + review_form = forms.TrustedReviewForm(flask.request.form) + if flask.g.user.is_superadmin and not app.is_closed: + decision_form = forms.TrustedDecisionForm() + if flask.request.method == 'POST': + do_decide = decision_form and (decision_form.accept.data or decision_form.reject.data) + if do_decide and decision_form.validate(): + app.closed_time = datetime.utcnow() + if decision_form.accept.data: + app.status = models.TrustedApplicationStatus.ACCEPTED + app.submitter.level = models.UserLevelType.TRUSTED + flask.flash(flask.Markup('Application has been accepted.'), 'success') + elif decision_form.reject.data: + app.status = models.TrustedApplicationStatus.REJECTED + flask.flash(flask.Markup('Application has been rejected.'), 'success') + _send_trusted_decision_email(app.submitter, bool(decision_form.accept.data)) + db.session.commit() + return flask.redirect(flask.url_for('admin.trusted_application', app_id=app_id)) + elif review_form.comment.data and review_form.validate(): + tr = models.TrustedReview() + tr.reviewer_id = flask.g.user.id + tr.app_id = app_id + tr.comment = review_form.comment.data + tr.recommendation = getattr(models.TrustedRecommendation, + review_form.recommendation.data.upper()) + if app.status == models.TrustedApplicationStatus.NEW: + app.status = models.TrustedApplicationStatus.REVIEWED + db.session.add(tr) + db.session.commit() + flask.flash('Review successfully posted.', 'success') + return flask.redirect(flask.url_for('admin.trusted_application', app_id=app_id)) + + return flask.render_template('admin_trusted_view.html', app=app, review_form=review_form, + decision_form=decision_form) + + +def _send_trusted_decision_email(user, is_accepted): + email_msg = email.EmailHolder( + subject='Your {} Trusted Application was {}.'.format(app.config['GLOBAL_SITE_NAME'], + ('rejected', 'accepted')[is_accepted]), + recipient=user, + text=flask.render_template('email/trusted.txt', is_accepted=is_accepted), + html=flask.render_template('email/trusted.html', is_accepted=is_accepted), + ) + + email.send_email(email_msg) diff --git a/nyaa/views/site.py b/nyaa/views/site.py index 6b1ba5e..b99cf4e 100644 --- a/nyaa/views/site.py +++ b/nyaa/views/site.py @@ -21,3 +21,8 @@ def help(): @bp.route('/xmlns/nyaa', methods=['GET']) def xmlns_nyaa(): return flask.render_template('xmlns.html') + + +@bp.route('/trusted', methods=['GET']) +def trusted(): + return flask.render_template('trusted.html')