diff --git a/migrations/versions/f69d7fec88d6_add_rangebans.py b/migrations/versions/f69d7fec88d6_add_rangebans.py new file mode 100644 index 0000000..9011744 --- /dev/null +++ b/migrations/versions/f69d7fec88d6_add_rangebans.py @@ -0,0 +1,40 @@ +"""add rangebans + +Revision ID: f69d7fec88d6 +Revises: 6cc823948c5a +Create Date: 2018-06-01 14:01:49.596007 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f69d7fec88d6' +down_revision = '6cc823948c5a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('rangebans', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('cidr_string', sa.String(length=18), nullable=False), + sa.Column('masked_cidr', sa.BigInteger(), nullable=False), + sa.Column('mask', sa.BigInteger(), nullable=False), + sa.Column('enabled', sa.Boolean(), nullable=False), + sa.Column('temp', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_rangebans_mask'), 'rangebans', ['mask'], unique=False) + op.create_index(op.f('ix_rangebans_masked_cidr'), 'rangebans', ['masked_cidr'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_rangebans_masked_cidr'), table_name='rangebans') + op.drop_index(op.f('ix_rangebans_mask'), table_name='rangebans') + op.drop_table('rangebans') + # ### end Alembic commands ### diff --git a/nyaa/backend.py b/nyaa/backend.py index 8ed7cde..fc9b5a0 100644 --- a/nyaa/backend.py +++ b/nyaa/backend.py @@ -158,6 +158,12 @@ def handle_torrent_upload(upload_form, uploading_user=None, fromAPI=False): upload_form.ratelimit.errors = ["You've gone over the upload ratelimit."] raise TorrentExtraValidationException() + if not uploading_user: + if models.RangeBan.is_rangebanned(ip_address(flask.request.remote_addr).packed): + upload_form.rangebanned.errors = ["Your IP is banned from " + "uploading anonymously."] + raise TorrentExtraValidationException() + # Delete existing torrent which is marked as deleted if torrent_data.db_id is not None: old_torrent = models.Torrent.by_id(torrent_data.db_id) diff --git a/nyaa/forms.py b/nyaa/forms.py index 99b3883..662794b 100644 --- a/nyaa/forms.py +++ b/nyaa/forms.py @@ -349,6 +349,7 @@ class UploadForm(FlaskForm): ]) ratelimit = HiddenField() + rangebanned = HiddenField() def validate_torrent_file(form, field): # Decode and ensure data is bencoded data diff --git a/nyaa/models.py b/nyaa/models.py index 154e548..40515fd 100644 --- a/nyaa/models.py +++ b/nyaa/models.py @@ -775,6 +775,44 @@ class TrackerApiBase(DeclarativeHelperBase): self.method = method +class RangeBan(db.Model): + __tablename__ = 'rangebans' + + id = db.Column(db.Integer, primary_key=True) + _cidr_string = db.Column('cidr_string', db.String(length=18), nullable=False) + masked_cidr = db.Column(db.BigInteger, nullable=False, + index=True) + mask = db.Column(db.BigInteger, nullable=False, index=True) + enabled = db.Column(db.Boolean, nullable=False, default=True) + # If this rangeban may be automatically cleared once it becomes + # out of date, set this column to the creation time of the ban. + # None (or NULL in the db) is understood as the ban being permanent. + temp = db.Column(db.DateTime(timezone=False), nullable=True, default=None) + + @property + def cidr_string(self): + return self._cidr_string + + @cidr_string.setter + def cidr_string(self, s): + subnet, masked_bits = s.split('/') + subnet_b = ip_address(subnet).packed + self.mask = (1 << 32) - (1 << (32 - int(masked_bits))) + self.masked_cidr = int.from_bytes(subnet_b, 'big') & self.mask + self._cidr_string = s + + @classmethod + def is_rangebanned(cls, ip): + if len(ip) > 4: + raise NotImplementedError("IPv6 is unsupported.") + elif len(ip) < 4: + raise ValueError("Not an IP address.") + ip_int = int.from_bytes(ip, 'big') + q = cls.query.filter(cls.mask.op('&')(ip_int) == cls.masked_cidr, + cls.enabled) + return q.count() > 0 + + # Actually declare our site-specific classes # Torrent diff --git a/nyaa/templates/upload.html b/nyaa/templates/upload.html index 4a8595f..1ca14a7 100644 --- a/nyaa/templates/upload.html +++ b/nyaa/templates/upload.html @@ -37,6 +37,18 @@ {% endif %} + {% if upload_form.rangebanned.errors %} +
+
+ +
+
+ {% endif %} +
{{ render_upload(upload_form.torrent_file, accept=".torrent") }} diff --git a/nyaa/views/account.py b/nyaa/views/account.py index ca6b5a4..a530956 100644 --- a/nyaa/views/account.py +++ b/nyaa/views/account.py @@ -89,19 +89,27 @@ def register(): user.last_login_ip = ip_address(flask.request.remote_addr).packed db.session.add(user) db.session.commit() - - if app.config['USE_EMAIL_VERIFICATION']: # force verification, enable email - send_verification_email(user) - return flask.render_template('waiting.html') - else: # disable verification, set user as active and auto log in - user.status = models.UserStatusType.ACTIVE - db.session.add(user) - db.session.commit() - flask.g.user = user - flask.session['user_id'] = user.id - flask.session.permanent = True - flask.session.modified = True - return flask.redirect(redirect_url()) + if models.RangeBan.is_rangebanned(user.last_login_ip): + flask.flash(flask.Markup('Your IP is blocked from creating new accounts. ' + 'Please ask a moderator to manually ' + 'activate your account \'{}\'.' + .format(flask.url_for('site.help') + '#irchelp', + flask.url_for('users.view_user', + user_name=user.username), + user.username)), 'warning') + else: + if app.config['USE_EMAIL_VERIFICATION']: # force verification, enable email + send_verification_email(user) + return flask.render_template('waiting.html') + else: # disable verification, set user as active and auto log in + user.status = models.UserStatusType.ACTIVE + db.session.add(user) + db.session.commit() + flask.g.user = user + flask.session['user_id'] = user.id + flask.session.permanent = True + flask.session.modified = True + return flask.redirect(redirect_url()) return flask.render_template('register.html', form=form) diff --git a/rangeban.py b/rangeban.py new file mode 100755 index 0000000..81b37be --- /dev/null +++ b/rangeban.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 + +from datetime import datetime +from ipaddress import ip_address +import sys + +import click + +from nyaa import create_app, models +from nyaa.extensions import db + + +def is_cidr_valid(c): + '''Checks whether a CIDR range string is valid.''' + try: + subnet, mask = c.split('/') + except ValueError: + return False + if int(mask) < 1 or int(mask) > 32: + return False + try: + ip = ip_address(subnet) + except ValueError: + return False + return True + + +def check_str(b): + '''Returns a checkmark or cross depending on the condition.''' + return '\u2713' if b else '\u2717' + + +@click.group() +def rangeban(): + global app + app = create_app('config') + + +@rangeban.command() +@click.option('--temp/--no-temp', help='Mark this entry as one that may be ' + 'cleaned out occasionally.', default=False) +@click.argument('cidrrange') +def ban(temp, cidrrange): + if not is_cidr_valid(cidrrange): + click.secho('{} is not of the format xxx.xxx.xxx.xxx/xx.' + .format(cidrrange), err=True, fg='red') + sys.exit(1) + with app.app_context(): + ban = models.RangeBan(cidr_string=cidrrange, temp=datetime.utcnow() if temp else None) + db.session.add(ban) + db.session.commit() + click.echo('Added {} for {}.'.format('temp ban' if temp else 'ban', + cidrrange)) + + +@rangeban.command() +@click.argument('cidrrange') +def unban(cidrrange): + if not is_cidr_valid(cidrrange): + click.secho('{} is not of the format xxx.xxx.xxx.xxx/xx.' + .format(cidrrange), err=True, fg='red') + sys.exit(1) + with app.app_context(): + # Dunno why this wants _cidr_string and not cidr_string, probably + # due to this all being a janky piece of shit. + bans = models.RangeBan.query.filter( + models.RangeBan._cidr_string == cidrrange).all() + if len(bans) == 0: + click.echo('Ban not found.') + for b in bans: + click.echo('Unbanned {}'.format(b.cidr_string)) + db.session.delete(b) + db.session.commit() + + +@rangeban.command() +def list(): + with app.app_context(): + bans = models.RangeBan.query.all() + if len(bans) == 0: + click.echo('No bans.') + else: + click.secho('ID CIDR Range Enabled Temp', bold=True) + for b in bans: + click.echo('{0: <6} {1: <18} {2: <7} {3: <4}' + .format(b.id, b.cidr_string, + check_str(b.enabled), + check_str(b.temp is not None))) + +@rangeban.command() +@click.argument('banid', type=int) +@click.argument('status') +def enabled(banid, status): + yeses = ['true', '1', 'yes', '\u2713'] + noses = ['false', '0', 'no', '\u2717'] + if status.lower() in yeses: + set_to = True + elif status.lower() in noses: + set_to = False + else: + click.secho('Please choose one of {} or {}.' + .format(yeses, noses), err=True, fg='red') + sys.exit(1) + with app.app_context(): + ban = models.RangeBan.query.get(banid) + if not ban: + click.secho('No ban with id {} found.' + .format(banid), err=True, fg='red') + sys.exit(1) + ban.enabled = set_to + db.session.add(ban) + db.session.commit() + click.echo('{} ban {} on {}.'.format('Enabled' if set_to else 'Disabled', + banid, ban._cidr_string)) + + + +if __name__ == '__main__': + rangeban()