Merge branch 'master' into reports

This commit is contained in:
nyaazi 2017-05-21 18:48:45 +03:00 committed by GitHub
commit 92a6074fa2
15 changed files with 528 additions and 332 deletions

11
config_es_sync.json Normal file
View File

@ -0,0 +1,11 @@
{
"save_loc": "/tmp/pos.json",
"mysql_host": "127.0.0.1",
"mysql_port": 13306,
"mysql_user": "root",
"mysql_password": "dunnolol",
"database": "nyaav2",
"internal_queue_depth": 10000,
"es_chunk_size": 10000,
"flush_interval": 5
}

View File

@ -130,7 +130,8 @@ UPLOAD_API_FORM_KEYMAP = {
'is_anonymous': 'anonymous',
'is_hidden': 'hidden',
'is_complete': 'complete',
'is_remake': 'remake'
'is_remake': 'remake',
'is_trusted': 'trusted'
}
UPLOAD_API_FORM_KEYMAP_REVERSE = {v: k for k, v in UPLOAD_API_FORM_KEYMAP.items()}
UPLOAD_API_KEYS = [
@ -140,6 +141,7 @@ UPLOAD_API_KEYS = [
'hidden',
'complete',
'remake',
'trusted',
'information',
'description'
]
@ -161,7 +163,7 @@ def v2_api_upload():
# Map api keys to upload form fields
for key in UPLOAD_API_KEYS:
mapped_key = UPLOAD_API_FORM_KEYMAP_REVERSE.get(key, key)
mapped_dict[mapped_key] = request_data.get(key)
mapped_dict[mapped_key] = request_data.get(key) or ''
# Flask-WTF (very helpfully!!) automatically grabs the request form, so force a None formdata
upload_form = forms.UploadForm(None, data=mapped_dict)

View File

@ -68,8 +68,8 @@ def handle_torrent_upload(upload_form, uploading_user=None, fromAPI=False):
torrent.remake = upload_form.is_remake.data
torrent.complete = upload_form.is_complete.data
# Copy trusted status from user if possible
torrent.trusted = (uploading_user.level >=
models.UserLevelType.TRUSTED) if uploading_user else False
can_mark_trusted = uploading_user and uploading_user.is_trusted
torrent.trusted = upload_form.is_trusted.data if can_mark_trusted else False
# Set category ids
torrent.main_category_id, torrent.sub_category_id = \
upload_form.category.parsed_data.get_category_ids()
@ -100,7 +100,9 @@ def handle_torrent_upload(upload_form, uploading_user=None, fromAPI=False):
for directory in path_parts:
current_directory = current_directory.setdefault(directory, {})
current_directory[filename] = file_dict['length']
# Don't add empty filenames (BitComet directory)
if filename:
current_directory[filename] = file_dict['length']
parsed_file_tree = utils.sorted_pathdict(parsed_file_tree)

View File

@ -153,6 +153,7 @@ class EditForm(FlaskForm):
is_remake = BooleanField('Remake')
is_anonymous = BooleanField('Anonymous')
is_complete = BooleanField('Complete')
is_trusted = BooleanField('Trusted')
information = StringField('Information', [
Length(max=255, message='Information must be at most %(max)d characters long.')
@ -200,6 +201,7 @@ class UploadForm(FlaskForm):
is_remake = BooleanField('Remake')
is_anonymous = BooleanField('Anonymous')
is_complete = BooleanField('Complete')
is_trusted = BooleanField('Trusted')
information = StringField('Information', [
Length(max=255, message='Information must be at most %(max)d characters long.')
@ -295,7 +297,7 @@ class ReportActionForm(FlaskForm):
def _validate_trackers(torrent_dict, tracker_to_check_for=None):
announce = torrent_dict.get('announce')
announce_string = _validate_bytes(announce, 'announce', 'utf-8')
announce_string = _validate_bytes(announce, 'announce', test_decode='utf-8')
tracker_found = tracker_to_check_for and (
announce_string.lower() == tracker_to_check_for.lower()) or False
@ -307,7 +309,7 @@ def _validate_trackers(torrent_dict, tracker_to_check_for=None):
for announce in announce_list:
_validate_list(announce, 'announce-list item')
announce_string = _validate_bytes(announce[0], 'announce-list item url', 'utf-8')
announce_string = _validate_bytes(announce[0], 'announce-list item url', test_decode='utf-8')
if tracker_to_check_for and announce_string.lower() == tracker_to_check_for.lower():
tracker_found = True
@ -323,7 +325,7 @@ def _validate_torrent_metadata(torrent_dict):
assert isinstance(info_dict, dict), 'info is not a dict'
encoding_bytes = torrent_dict.get('encoding', b'utf-8')
encoding = _validate_bytes(encoding_bytes, 'encoding', 'utf-8').lower()
encoding = _validate_bytes(encoding_bytes, 'encoding', test_decode='utf-8').lower()
name = info_dict.get('name')
_validate_bytes(name, 'name', test_decode=encoding)
@ -345,17 +347,21 @@ def _validate_torrent_metadata(torrent_dict):
path_list = file_dict.get('path')
_validate_list(path_list, 'path')
for path_part in path_list:
# Validate possible directory names
for path_part in path_list[:-1]:
_validate_bytes(path_part, 'path part', test_decode=encoding)
# Validate actual filename, allow b'' to specify an empty directory
_validate_bytes(path_list[-1], 'filename', check_empty=False, test_decode=encoding)
else:
length = info_dict.get('length')
_validate_number(length, 'length', check_positive=True)
def _validate_bytes(value, name='value', test_decode=None):
def _validate_bytes(value, name='value', check_empty=True, test_decode=None):
assert isinstance(value, bytes), name + ' is not bytes'
assert len(value) > 0, name + ' is empty'
if check_empty:
assert len(value) > 0, name + ' is empty'
if test_decode:
try:
return value.decode(test_decode)

View File

@ -8,6 +8,7 @@ from werkzeug.security import generate_password_hash, check_password_hash
from sqlalchemy_fulltext import FullText
import re
import base64
from markupsafe import escape as escape_markup
from urllib.parse import unquote as unquote_url
@ -88,10 +89,14 @@ class Torrent(db.Model):
primaryjoin=(
"and_(SubCategory.id == foreign(Torrent.sub_category_id), "
"SubCategory.main_category_id == Torrent.main_category_id)"))
info = db.relationship('TorrentInfo', uselist=False, back_populates='torrent')
filelist = db.relationship('TorrentFilelist', uselist=False, back_populates='torrent')
stats = db.relationship('Statistic', uselist=False, back_populates='torrent', lazy='joined')
trackers = db.relationship('TorrentTrackers', uselist=True, lazy='joined')
info = db.relationship('TorrentInfo', uselist=False,
cascade="all, delete-orphan", back_populates='torrent')
filelist = db.relationship('TorrentFilelist', uselist=False,
cascade="all, delete-orphan", back_populates='torrent')
stats = db.relationship('Statistic', uselist=False,
cascade="all, delete-orphan", back_populates='torrent', lazy='joined')
trackers = db.relationship('TorrentTrackers', uselist=True,
cascade="all, delete-orphan", lazy='joined')
def __repr__(self):
return '<{0} #{1.id} \'{1.display_name}\' {1.filesize}b>'.format(type(self).__name__, self)
@ -121,6 +126,14 @@ class Torrent(db.Model):
# Escaped
return escape_markup(self.information)
@property
def info_hash_as_b32(self):
return base64.b32encode(self.info_hash).decode('utf-8')
@property
def info_hash_as_hex(self):
return self.info_hash.hex()
@property
def magnet_uri(self):
return create_magnet(self)
@ -370,15 +383,15 @@ class User(db.Model):
@property
def is_admin(self):
return self.level is UserLevelType.ADMIN or self.level is UserLevelType.SUPERADMIN
return self.level >= UserLevelType.ADMIN
@property
def is_superadmin(self):
return self.level is UserLevelType.SUPERADMIN
return self.level == UserLevelType.SUPERADMIN
@property
def is_trusted(self):
return self.level is UserLevelType.TRUSTED
return self.level >= UserLevelType.TRUSTED
class ReportStatus(IntEnum):

View File

@ -134,24 +134,44 @@ def get_category_id_map():
app.register_blueprint(api_handler.api_blueprint, url_prefix='/api')
def chain_get(source, *args):
''' Tries to return values from source by the given keys.
Returns None if none match.
Note: can return a None from the source. '''
sentinel = object()
for key in args:
value = source.get(key, sentinel)
if value is not sentinel:
return value
return None
@app.route('/rss', defaults={'rss': True})
@app.route('/', defaults={'rss': False})
def home(rss):
if flask.request.args.get('page') == 'rss':
rss = True
render_as_rss = rss
req_args = flask.request.args
if req_args.get('page') == 'rss':
render_as_rss = True
term = flask.request.args.get('q', flask.request.args.get('term'))
sort = flask.request.args.get('s')
order = flask.request.args.get('o')
category = flask.request.args.get('c', flask.request.args.get('cats'))
quality_filter = flask.request.args.get('f', flask.request.args.get('filter'))
user_name = flask.request.args.get('u', flask.request.args.get('user'))
page = flask.request.args.get('p', flask.request.args.get('offset', 1, int), int)
search_term = chain_get(req_args, 'q', 'term')
per_page = app.config.get('RESULTS_PER_PAGE')
if not per_page:
per_page = DEFAULT_PER_PAGE
sort_key = req_args.get('s')
sort_order = req_args.get('o')
category = chain_get(req_args, 'c', 'cats')
quality_filter = chain_get(req_args, 'f', 'filter')
user_name = chain_get(req_args, 'u', 'user')
page_number = chain_get(req_args, 'p', 'page', 'offset')
try:
page_number = max(1, int(page_number))
except (ValueError, TypeError):
page_number = 1
# Check simply if the key exists
use_magnet_links = 'magnets' in req_args or 'm' in req_args
results_per_page = app.config.get('RESULTS_PER_PAGE', DEFAULT_PER_PAGE)
user_id = None
if user_name:
@ -162,13 +182,13 @@ def home(rss):
query_args = {
'user': user_id,
'sort': sort or 'id',
'order': order or 'desc',
'sort': sort_key or 'id',
'order': sort_order or 'desc',
'category': category or '0_0',
'quality_filter': quality_filter or '0',
'page': page,
'rss': rss,
'per_page': per_page
'page': page_number,
'rss': render_as_rss,
'per_page': results_per_page
}
if flask.g.user:
@ -178,28 +198,26 @@ def home(rss):
# If searching, we get results from elastic search
use_elastic = app.config.get('USE_ELASTIC_SEARCH')
if use_elastic and term:
query_args['term'] = term
if use_elastic and search_term:
query_args['term'] = search_term
max_search_results = app.config.get('ES_MAX_SEARCH_RESULT')
if not max_search_results:
max_search_results = DEFAULT_MAX_SEARCH_RESULT
max_search_results = app.config.get('ES_MAX_SEARCH_RESULT', DEFAULT_MAX_SEARCH_RESULT)
# Only allow up to (max_search_results / page) pages
max_page = min(query_args['page'], int(math.ceil(max_search_results / float(per_page))))
max_page = min(query_args['page'], int(math.ceil(max_search_results / results_per_page)))
query_args['page'] = max_page
query_args['max_search_results'] = max_search_results
query_results = search_elastic(**query_args)
if rss:
return render_rss('/', query_results, use_elastic=True)
if render_as_rss:
return render_rss('"{}"'.format(search_term), query_results, use_elastic=True, magnet_links=use_magnet_links)
else:
rss_query_string = _generate_query_string(term, category, quality_filter, user_name)
rss_query_string = _generate_query_string(search_term, category, quality_filter, user_name)
max_results = min(max_search_results, query_results['hits']['total'])
# change p= argument to whatever you change page_parameter to or pagination breaks
pagination = Pagination(p=query_args['page'], per_page=per_page,
pagination = Pagination(p=query_args['page'], per_page=results_per_page,
total=max_results, bs_version=3, page_parameter='p',
display_msg=SERACH_PAGINATE_DISPLAY_MSG)
return flask.render_template('home.html',
@ -213,13 +231,13 @@ def home(rss):
if use_elastic:
query_args['term'] = ''
else: # Otherwise, use db search for everything
query_args['term'] = term or ''
query_args['term'] = search_term or ''
query = search_db(**query_args)
if rss:
return render_rss('/', query, use_elastic=False)
if render_as_rss:
return render_rss('Home', query, use_elastic=False, magnet_links=use_magnet_links)
else:
rss_query_string = _generate_query_string(term, category, quality_filter, user_name)
rss_query_string = _generate_query_string(search_term, category, quality_filter, user_name)
# Use elastic is always false here because we only hit this section
# if we're browsing without a search term (which means we default to DB)
# or if ES is disabled
@ -256,39 +274,38 @@ def view_user(user_name):
db.session.add(user)
db.session.commit()
return flask.redirect('/user/' + user.username)
return flask.redirect(flask.url_for('view_user', user_name=user.username))
level = 'Regular'
if user.is_admin:
level = 'Moderator'
if user.is_superadmin: # check this second because user can be admin AND superadmin
level = 'Administrator'
elif user.is_trusted:
level = 'Trusted'
user_level = ['Regular', 'Trusted', 'Moderator', 'Administrator'][user.level]
term = flask.request.args.get('q')
sort = flask.request.args.get('s')
order = flask.request.args.get('o')
category = flask.request.args.get('c')
quality_filter = flask.request.args.get('f')
page = flask.request.args.get('p')
if page:
page = int(page)
req_args = flask.request.args
per_page = app.config.get('RESULTS_PER_PAGE')
if not per_page:
per_page = DEFAULT_PER_PAGE
search_term = chain_get(req_args, 'q', 'term')
sort_key = req_args.get('s')
sort_order = req_args.get('o')
category = chain_get(req_args, 'c', 'cats')
quality_filter = chain_get(req_args, 'f', 'filter')
page_number = chain_get(req_args, 'p', 'page', 'offset')
try:
page_number = max(1, int(page_number))
except (ValueError, TypeError):
page_number = 1
results_per_page = app.config.get('RESULTS_PER_PAGE', DEFAULT_PER_PAGE)
query_args = {
'term': term or '',
'term': search_term or '',
'user': user.id,
'sort': sort or 'id',
'order': order or 'desc',
'sort': sort_key or 'id',
'order': sort_order or 'desc',
'category': category or '0_0',
'quality_filter': quality_filter or '0',
'page': page or 1,
'page': page_number,
'rss': False,
'per_page': per_page
'per_page': results_per_page
}
if flask.g.user:
@ -297,17 +314,15 @@ def view_user(user_name):
query_args['admin'] = True
# Use elastic search for term searching
rss_query_string = _generate_query_string(term, category, quality_filter, user_name)
rss_query_string = _generate_query_string(search_term, category, quality_filter, user_name)
use_elastic = app.config.get('USE_ELASTIC_SEARCH')
if use_elastic and term:
query_args['term'] = term
if use_elastic and search_term:
query_args['term'] = search_term
max_search_results = app.config.get('ES_MAX_SEARCH_RESULT')
if not max_search_results:
max_search_results = DEFAULT_MAX_SEARCH_RESULT
max_search_results = app.config.get('ES_MAX_SEARCH_RESULT', DEFAULT_MAX_SEARCH_RESULT)
# Only allow up to (max_search_results / page) pages
max_page = min(query_args['page'], int(math.ceil(max_search_results / float(per_page))))
max_page = min(query_args['page'], int(math.ceil(max_search_results / results_per_page)))
query_args['page'] = max_page
query_args['max_search_results'] = max_search_results
@ -316,7 +331,7 @@ def view_user(user_name):
max_results = min(max_search_results, query_results['hits']['total'])
# change p= argument to whatever you change page_parameter to or pagination breaks
pagination = Pagination(p=query_args['page'], per_page=per_page,
pagination = Pagination(p=query_args['page'], per_page=results_per_page,
total=max_results, bs_version=3, page_parameter='p',
display_msg=SERACH_PAGINATE_DISPLAY_MSG)
return flask.render_template('user.html',
@ -327,7 +342,7 @@ def view_user(user_name):
user=user,
user_page=True,
rss_filter=rss_query_string,
level=level,
level=user_level,
admin=admin,
superadmin=superadmin,
form=form)
@ -336,7 +351,7 @@ def view_user(user_name):
if use_elastic:
query_args['term'] = ''
else:
query_args['term'] = term or ''
query_args['term'] = search_term or ''
query = search_db(**query_args)
return flask.render_template('user.html',
use_elastic=False,
@ -345,7 +360,7 @@ def view_user(user_name):
user=user,
user_page=True,
rss_filter=rss_query_string,
level=level,
level=user_level,
admin=admin,
superadmin=superadmin,
form=form)
@ -361,9 +376,10 @@ def _jinja2_filter_rfc822(datestr, fmt=None):
return formatdate(float(datetime.strptime(datestr, '%Y-%m-%dT%H:%M:%S').strftime('%s')))
def render_rss(label, query, use_elastic):
def render_rss(label, query, use_elastic, magnet_links=False):
rss_xml = flask.render_template('rss.xml',
use_elastic=use_elastic,
magnet_links=magnet_links,
term=label,
site_url=flask.request.url_root,
torrent_query=query)
@ -538,7 +554,8 @@ def _create_upload_category_choices():
cat_names = id_map[key]
is_main_cat = key.endswith('_0')
cat_name = is_main_cat and cat_names[0] or (' - ' + cat_names[1])
# cat_name = is_main_cat and cat_names[0] or (' - ' + cat_names[1])
cat_name = ' - '.join(cat_names)
choices.append((key, cat_name, is_main_cat))
return choices
@ -562,16 +579,17 @@ def upload():
def view_torrent(torrent_id):
torrent = models.Torrent.by_id(torrent_id)
viewer = flask.g.user
if not torrent:
flask.abort(404)
if torrent.deleted and (not flask.g.user or not flask.g.user.is_admin):
# Only allow admins see deleted torrents
if torrent.deleted and not (viewer and viewer.is_admin):
flask.abort(404)
if flask.g.user:
can_edit = flask.g.user is torrent.user or flask.g.user.is_admin
else:
can_edit = False
# Only allow owners and admins to edit torrents
can_edit = viewer and (viewer is torrent.user or viewer.is_admin)
files = None
if torrent.filelist:
@ -580,6 +598,7 @@ def view_torrent(torrent_id):
report_form = forms.ReportForm()
return flask.render_template('view.html', torrent=torrent,
files=files,
viewer=viewer,
can_edit=can_edit,
report_form=report_form)
@ -589,15 +608,18 @@ def edit_torrent(torrent_id):
torrent = models.Torrent.by_id(torrent_id)
form = forms.EditForm(flask.request.form)
form.category.choices = _create_upload_category_choices()
category = str(torrent.main_category_id) + "_" + str(torrent.sub_category_id)
editor = flask.g.user
if not torrent:
flask.abort(404)
if torrent.deleted and (not flask.g.user or not flask.g.user.is_admin):
# Only allow admins edit deleted torrents
if torrent.deleted and not (editor and editor.is_admin):
flask.abort(404)
if not flask.g.user or (flask.g.user is not torrent.user and not flask.g.user.is_admin):
# Only allow torrent owners or admins edit torrents
if not editor or not (editor is torrent.user or editor.is_admin):
flask.abort(403)
if flask.request.method == 'POST' and form.validate():
@ -607,36 +629,43 @@ def edit_torrent(torrent_id):
torrent.display_name = (form.display_name.data or '').strip()
torrent.information = (form.information.data or '').strip()
torrent.description = (form.description.data or '').strip()
if flask.g.user.is_admin:
torrent.deleted = form.is_deleted.data
torrent.hidden = form.is_hidden.data
torrent.remake = form.is_remake.data
torrent.complete = form.is_complete.data
torrent.anonymous = form.is_anonymous.data
if editor.is_trusted:
torrent.trusted = form.is_trusted.data
if editor.is_admin:
torrent.deleted = form.is_deleted.data
db.session.commit()
flask.flash(flask.Markup(
'Torrent has been successfully edited! Changes might take a few minutes to show up.'), 'info')
return flask.redirect('/view/' + str(torrent_id))
return flask.redirect(flask.url_for('view_torrent', torrent_id=torrent.id))
else:
# Setup form with pre-formatted form.
form.category.data = category
form.display_name.data = torrent.display_name
form.information.data = torrent.information
form.description.data = torrent.description
form.is_hidden.data = torrent.hidden
if flask.g.user.is_admin:
if flask.request.method != 'POST':
# Fill form data only if the POST didn't fail
form.category.data = torrent.sub_category.id_as_string
form.display_name.data = torrent.display_name
form.information.data = torrent.information
form.description.data = torrent.description
form.is_hidden.data = torrent.hidden
form.is_remake.data = torrent.remake
form.is_complete.data = torrent.complete
form.is_anonymous.data = torrent.anonymous
form.is_trusted.data = torrent.trusted
form.is_deleted.data = torrent.deleted
form.is_remake.data = torrent.remake
form.is_complete.data = torrent.complete
form.is_anonymous.data = torrent.anonymous
return flask.render_template('edit.html',
form=form,
torrent=torrent,
admin=flask.g.user.is_admin)
editor=editor)
@app.route('/view/<int:torrent_id>/magnet')

View File

@ -58,6 +58,10 @@ table.torrent-list thead th.sorting_desc:after {
content: "\f0dd";
}
table.torrent-list tbody tr td a:visited {
color: #1d4568;
}
#torrent-description img {
max-width: 100%;
}

View File

@ -1,10 +1,12 @@
{% macro render_field(field) %}
{% macro render_field(field, render_label=True) %}
{% if field.errors %}
<div class="form-group has-error">
{% else %}
<div class="form-group">
{% endif %}
{% if render_label %}
{{ field.label(class='control-label') }}
{% endif %}
{{ field(title=field.description,**kwargs) | safe }}
{% if field.errors %}
<div class="help-block">
@ -27,33 +29,33 @@
{% macro render_markdown_editor(field, field_name='') %}
{% if field.errors %}
<div class="form-group has-error">
<div class="form-group has-error">
{% else %}
<div class="form-group">
<div class="form-group">
{% endif %}
<div class="markdown-editor" id="{{ field_name }}-markdown-editor" data-field-name="{{ field_name }}">
<ul class="nav nav-tabs" role="tablist">
<li role="presentation" class="active">
<a href="#{{ field_name }}-tab" aria-controls="" role="tab" data-toggle="tab">
Write
</a>
</li>
<li role="presentation">
<a href="#{{ field_name }}-preview" id="{{ field_name }}-preview-tab" aria-controls="preview" role="tab" data-toggle="tab">
Preview
</a>
</li>
</ul>
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="{{ field_name }}-tab" data-markdown-target="#{{ field_name }}-markdown-target">
{{ render_field(field, class_='form-control markdown-source') }}
</div>
<div role="tabpanel" class="tab-pane" id="{{ field_name }}-preview">
{{ field.label(class='control-label') }}
<div class="well" id="{{ field_name }}-markdown-target"></div>
</div>
</div>
</div>
<div class="markdown-editor" id="{{ field_name }}-markdown-editor" data-field-name="{{ field_name }}">
{{ field.label(class='control-label') }}
<ul class="nav nav-tabs" role="tablist">
<li role="presentation" class="active">
<a href="#{{ field_name }}-tab" aria-controls="" role="tab" data-toggle="tab">
Write
</a>
</li>
<li role="presentation">
<a href="#{{ field_name }}-preview" id="{{ field_name }}-preview-tab" aria-controls="preview" role="tab" data-toggle="tab">
Preview
</a>
</li>
</ul>
<div class="tab-content">
<div role="tabpanel" class="tab-pane active" id="{{ field_name }}-tab" data-markdown-target="#{{ field_name }}-markdown-target">
{{ render_field(field, False, class_='form-control markdown-source') }}
</div>
<div role="tabpanel" class="tab-pane" id="{{ field_name }}-preview">
<div class="well" id="{{ field_name }}-markdown-target"></div>
</div>
</div>
</div>
</div>
{% endmacro %}

View File

@ -4,79 +4,73 @@
{% from "_formhelpers.html" import render_field %}
{% from "_formhelpers.html" import render_markdown_editor %}
<h1>Edit Torrent</h1>
{% set torrent_url = url_for('view_torrent', torrent_id=torrent.id) %}
<h1>
Edit Torrent <a href="{{ torrent_url }}">#{{torrent.id}}</a>
{% if (torrent.user != None) and (torrent.user != editor) %}
(by <a href="{{ url_for('view_user', user_name=torrent.user.username) }}">{{ torrent.user.username }}</a>)
{% endif %}
</h1>
<form method="POST" enctype="multipart/form-data">
{{ form.csrf_token }}
<div class="row">
<div class="form-group col-md-6">
{{ render_field(form.category, class_='form-control')}}
<div class="col-md-6">
{{ render_field(form.display_name, class_='form-control', placeholder='Display name') }}
</div>
<div class="col-md-4">
{{ render_field(form.category, class_='form-control')}}
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
{{ render_field(form.display_name, class_='form-control', placeholder='Display name') }}
<div class="col-md-6">
{{ render_field(form.information, class_='form-control', placeholder='Your website or IRC channel') }}
</div>
</div>
<div class="col-md-6">
<label class="control-label">Torrent flags</label>
<div>
{% if editor.is_admin %}
<label class="btn btn-primary">
{{ form.is_deleted }}
Deleted
</label>
{% endif %}
<div class="row">
<div class="form-group col-md-6">
{{ render_field(form.information, class_='form-control', placeholder='Your website or IRC channel') }}
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
{{ render_markdown_editor(form.description, field_name='description') }}
</div>
</div>
{% if admin %}
<div class="row">
<div class="form-group col-md-6">
<label>
{{ form.is_deleted }}
Deleted
</label>
</div>
</div>
{% endif %}
<div class="row">
<div class="form-group col-md-6">
<label>
<label class="btn btn-default" style="background-color: darkgray; border-color: #ccc;" title="Hide torrent from listing">
{{ form.is_hidden }}
Hidden
</label>
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
<label>
<label class="btn btn-danger" title="This torrent is derived from another release">
{{ form.is_remake }}
Remake
</label>
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
<label>
<label class="btn btn-primary" title="This torrent is a complete batch (eg. season)">
{{ form.is_complete }}
Complete
</label>
{# Only allow changing anonymous status when an uploader exists #}
{% if torrent.uploader_id %}
<label class="btn btn-primary" title="Upload torrent anonymously (don't display your username)">
{{ form.is_anonymous }}
Anonymous
</label>
{% endif %}
{% if editor.is_trusted %}
<label class="btn btn-success" title="Mark torrent trusted">
{{ form.is_trusted }}
Trusted
</label>
{% endif %}
</div>
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
<label>
{{ form.is_anonymous }}
Anonymous
</label>
<div class="col-md-12">
{{ render_markdown_editor(form.description, field_name='description') }}
</div>
</div>

View File

@ -1,37 +1,45 @@
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
<channel>
<title>{{ config.SITE_NAME }} Torrent File RSS (No magnets)</title>
<title>{{ config.SITE_NAME }} Torrent File RSS</title>
<description>RSS Feed for {{ term }}</description>
<link>{{ url_for('home', _external=True) }}</link>
<atom:link href="{{ url_for('home', page='rss', _external=True) }}" rel="self" type="application/rss+xml" />
{% for torrent in torrent_query %}
{% if torrent.has_torrent %}
<item>
<title>{{ torrent.display_name }}</title>
{# <description><![CDATA[{{ torrent.description }}]]></description> #}
{% if use_elastic %}
<link>{{ url_for('download_torrent', torrent_id=torrent.meta.id, _external=True) }}</link>
<guid isPermaLink="true">{{ url_for('view_torrent', torrent_id=torrent.meta.id, _external=True) }}</guid>
<pubDate>{{ torrent.created_time|rfc822_es }}</pubDate>
{% if torrent.has_torrent and not magnet_links %}
<link>{{ url_for('download_torrent', torrent_id=torrent.meta.id, _external=True) }}</link>
{% else %}
<link>{{ create_magnet_from_info(torrent.display_name, torrent.info_hash) }}</link>
{% endif %}
<guid isPermaLink="true">{{ url_for('view_torrent', torrent_id=torrent.meta.id, _external=True) }}</guid>
<pubDate>{{ torrent.created_time|rfc822_es }}</pubDate>
<seeders> {{- torrent.seed_count }}</seeders>
<leechers> {{- torrent.leech_count }}</leechers>
<downloads>{{- torrent.download_count }}</downloads>
<infoHash> {{- torrent.info_hash }}</infoHash>
{% else %}
<link>{{ url_for('download_torrent', torrent_id=torrent.id, _external=True) }}</link>
<guid isPermaLink="true">{{ url_for('view_torrent', torrent_id=torrent.id, _external=True) }}</guid>
<pubDate>{{ torrent.created_time|rfc822 }}</pubDate>
{% endif %}
</item>
{% else %}
<item>
<title>{{ torrent.display_name }}</title>
{% if use_elastic %}
<link>{{ create_magnet_from_info(torrent.display_name, torrent.info_hash) }}</link>
<guid isPermaLink="true">{{ url_for('view_torrent', torrent_id=torrent.meta.id, _external=True) }}</guid>
<pubDate>{{ torrent.created_time|rfc822_es }}</pubDate>
{% else %}
<link>{{ torrent.magnet_uri }}</link>
<guid isPermaLink="true">{{ url_for('view_torrent', torrent_id=torrent.id, _external=True) }}</guid>
<pubDate>{{ torrent.created_time|rfc822 }}</pubDate>
{% if torrent.has_torrent and not magnet_links %}
<link>{{ url_for('download_torrent', torrent_id=torrent.id, _external=True) }}</link>
{% else %}
<link>{{ torrent.magnet_uri }}</link>
{% endif %}
<guid isPermaLink="true">{{ url_for('view_torrent', torrent_id=torrent.id, _external=True) }}</guid>
<pubDate>{{ torrent.created_time|rfc822 }}</pubDate>
<seeders> {{- torrent.stats.seed_count }}</seeders>
<leechers> {{- torrent.stats.leech_count }}</leechers>
<downloads>{{- torrent.stats.download_count }}</downloads>
<infoHash> {{- torrent.info_hash_as_hex }}</infoHash>
{% endif %}
{% set cat_id = use_elastic and ((torrent.main_category_id|string) + '_' + (torrent.sub_category_id|string)) or torrent.sub_category.id_as_string %}
<categoryId>{{- cat_id }}</categoryId>
<category> {{- category_name(cat_id) }}</category>
<size> {{- torrent.filesize | filesizeformat(True) }}</size>
</item>
{% endif %}
{% endfor %}
</channel>
</rss>

View File

@ -16,68 +16,57 @@
<form method="POST" enctype="multipart/form-data">
{% if config.ENFORCE_MAIN_ANNOUNCE_URL %}<p><strong>Important:</strong> Please include <kbd>{{config.MAIN_ANNOUNCE_URL}}</kbd> in your trackers</p>{% endif %}
<div class="row">
<div class="form-group col-md-6">
{{ render_upload(form.torrent_file, accept=".torrent") }}
<div class="col-md-6">
{{ render_upload(form.torrent_file, accept=".torrent") }}
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
{{ render_field(form.category, class_='form-control')}}
<div class="col-md-6">
{{ render_field(form.display_name, class_='form-control', placeholder='Display name') }}
</div>
<div class="col-md-4">
{{ render_field(form.category, class_='form-control')}}
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
{{ render_field(form.display_name, class_='form-control', placeholder='Display name') }}
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
<div class="row form-group">
<div class="col-md-6">
{{ render_field(form.information, class_='form-control', placeholder='Your website or IRC channel') }}
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
{{ render_markdown_editor(form.description, field_name='description') }}
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
<label>
<div class="col-md-6">
<label class="control-label">Torrent flags</label>
<div>
<label class="btn btn-primary" title="Upload torrent anonymously (don't display your username)">
{{ form.is_anonymous(disabled=(False if user else ""), checked=(False if user else "")) }}
Anonymous
</label>
<label class="btn btn-default" style="background-color: darkgray; border-color: #ccc;" title="Hide torrent from listing">
{{ form.is_hidden }}
Hidden
</label>
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
<label>
<label class="btn btn-danger" title="This torrent is derived from another release">
{{ form.is_remake }}
Remake
</label>
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
<label>
<label class="btn btn-primary" title="This torrent is a complete batch (eg. season)">
{{ form.is_complete }}
Complete
</label>
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
<label>
{{ form.is_anonymous }}
Anonymous
{% if user.is_trusted %}
<label class="btn btn-success" title="Mark torrent trusted">
{{ form.is_trusted(checked="") }}
Trusted
</label>
{% endif %}
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
{{ render_markdown_editor(form.description, field_name='description') }}
</div>
</div>

View File

@ -6,7 +6,7 @@
<div class="panel-heading"{% if torrent.hidden %} style="background-color: darkgray;"{% endif %}>
<h3 class="panel-title">
{% if can_edit %}
<a href="{{ request.url }}/edit"><i class="fa fa-fw fa-pencil"></i></a>
<a href="{{ request.url }}/edit" title="Edit torrent"><i class="fa fa-fw fa-pencil"></i></a>
{% endif %}
{{ torrent.display_name }}
</h3>
@ -24,7 +24,14 @@
<div class="row">
<div class="col-md-1">Submitter:</div>
<div class="col-md-5">{% if not torrent.anonymous and torrent.user %}<a href="{{ url_for('view_user', user_name=torrent.user.username) }}">{{ torrent.user.username }}</a>{% else %}Anonymous{% endif %}</div>
<div class="col-md-5">
{% set user_url = torrent.user and url_for('view_user', user_name=torrent.user.username) %}
{%- if not torrent.anonymous and torrent.user -%}
<a href="{{ user_url }}">{{ torrent.user.username }}</a>
{%- else -%}
Anonymous {% if torrent.user and (viewer == torrent.user or viewer.is_admin) %}(<a href="{{ user_url }}">{{ torrent.user.username }}</a>){% endif %}
{%- endif -%}
</div>
<div class="col-md-1">Seeders:</div>
<div class="col-md-5"><span style="color: green;">{% if config.ENABLE_SHOW_STATS %}{{ torrent.stats.seed_count }}{% else %}Coming soon{% endif %}</span></div>

View File

@ -37,4 +37,5 @@ elasticsearch==5.3.0
elasticsearch-dsl==5.2.0
progressbar2==3.20.0
mysql-replication==0.13
flask-paginate==0.4.5
flask-paginate==0.4.5
statsd==3.2.1

View File

@ -21,9 +21,12 @@ when import_to_es and sync_es will be lost. Instead, you can run SHOW MASTER
STATUS _before_ you run import_to_es. That way you'll definitely pick up any
changes that happen while the import_to_es script is dumping stuff from the
database into es, at the expense of redoing a (small) amount of indexing.
This uses multithreading so we don't have to block on socket io (both binlog
reading and es POSTing). asyncio soon
"""
from elasticsearch import Elasticsearch
from elasticsearch.helpers import bulk
from elasticsearch.helpers import bulk, BulkIndexError
from pymysqlreplication import BinLogStreamReader
from pymysqlreplication.row_event import UpdateRowsEvent, DeleteRowsEvent, WriteRowsEvent
from datetime import datetime
@ -32,51 +35,39 @@ import sys
import json
import time
import logging
from statsd import StatsClient
from threading import Thread
from queue import Queue, Empty
logging.basicConfig()
logging.basicConfig(format='%(asctime)s %(levelname)s %(name)s - %(message)s')
log = logging.getLogger('sync_es')
log.setLevel(logging.INFO)
# config in json, 2lazy to argparse
if len(sys.argv) != 2:
print("need config.json location", file=sys.stderr)
sys.exit(-1)
with open(sys.argv[1]) as f:
config = json.load(f)
# goes to netdata or other statsd listener
stats = StatsClient('localhost', 8125, prefix="sync_es")
#logging.getLogger('elasticsearch').setLevel(logging.DEBUG)
# in prod want in /var/lib somewhere probably
SAVE_LOC = "/var/lib/sync_es_position.json"
MYSQL_HOST = '127.0.0.1'
MYSQL_PORT = 3306
MYSQL_USER = 'test'
MYSQL_PW = 'test123'
NT_DB = 'nyaav2'
with open(SAVE_LOC) as f:
pos = json.load(f)
es = Elasticsearch(timeout=30)
stream = BinLogStreamReader(
# TODO parse out from config.py or something
connection_settings = {
'host': MYSQL_HOST,
'port': MYSQL_PORT,
'user': MYSQL_USER,
'passwd': MYSQL_PW
},
server_id=10, # arbitrary
# only care about this database currently
only_schemas=[NT_DB],
# these tables in the database
only_tables=["nyaa_torrents", "nyaa_statistics", "sukebei_torrents", "sukebei_statistics"],
# from our save file
resume_stream=True,
log_file=pos['log_file'],
log_pos=pos['log_pos'],
# skip the other stuff like table mapping
only_events=[UpdateRowsEvent, DeleteRowsEvent, WriteRowsEvent],
# if we're at the head of the log, block until something happens
# note it'd be nice to block async-style instead, but the mainline
# binlogreader is synchronous. there is an (unmaintained?) fork
# using aiomysql if anybody wants to revive that.
blocking=True)
SAVE_LOC = config.get('save_loc', "/tmp/pos.json")
MYSQL_HOST = config.get('mysql_host', '127.0.0.1')
MYSQL_PORT = config.get('mysql_port', 3306)
MYSQL_USER = config.get('mysql_user', 'root')
MYSQL_PW = config.get('mysql_password', 'dunnolol')
NT_DB = config.get('database', 'nyaav2')
INTERNAL_QUEUE_DEPTH = config.get('internal_queue_depth', 10000)
ES_CHUNK_SIZE = config.get('es_chunk_size', 10000)
# seconds since no events happening to flush to es. remember this also
# interacts with es' refresh_interval setting.
FLUSH_INTERVAL = config.get('flush_interval', 5)
def reindex_torrent(t, index_name):
# XXX annoyingly different from import_to_es, and
@ -120,8 +111,8 @@ def reindex_torrent(t, index_name):
def reindex_stats(s, index_name):
# update the torrent at torrent_id, assumed to exist;
# this will always be the case if you're reading the binlog
# in order; the foreign key constraint on torrrent_id prevents
# the stats row rom existing if the torrent isn't around.
# in order; the foreign key constraint on torrent_id prevents
# the stats row from existing if the torrent isn't around.
return {
'_op_type': 'update',
'_index': index_name,
@ -141,45 +132,176 @@ def delet_this(row, index_name):
'_type': 'torrent',
'_id': str(row['values']['id'])}
n = 0
last_save = time.time()
for event in stream:
if event.table == "nyaa_torrents" or event.table == "sukebei_torrents":
if event.table == "nyaa_torrents":
index_name = "nyaa"
else:
index_name = "sukebei"
if type(event) is WriteRowsEvent:
bulk(es, (reindex_torrent(row['values'], index_name) for row in event.rows))
elif type(event) is UpdateRowsEvent:
# UpdateRowsEvent includes the old values too, but we don't care
bulk(es, (reindex_torrent(row['after_values'], index_name) for row in event.rows))
elif type(event) is DeleteRowsEvent:
# ok, bye
bulk(es, (delet_this(row, index_name) for row in event.rows))
else:
raise Exception(f"unknown event {type(event)}")
elif event.table == "nyaa_statistics" or event.table == "sukebei_statistics":
if event.table == "nyaa_statistics":
index_name = "nyaa"
else:
index_name = "sukebei"
if type(event) is WriteRowsEvent:
bulk(es, (reindex_stats(row['values'], index_name) for row in event.rows))
elif type(event) is UpdateRowsEvent:
bulk(es, (reindex_stats(row['after_values'], index_name) for row in event.rows))
elif type(event) is DeleteRowsEvent:
# uh ok. assume that the torrent row will get deleted later,
# which will clean up the entire es "torrent" document
pass
else:
raise Exception(f"unknown event {type(event)}")
else:
raise Exception(f"unknown table {s.table}")
class BinlogReader(Thread):
# write_buf is the Queue we communicate with
def __init__(self, write_buf):
Thread.__init__(self)
self.write_buf = write_buf
n += 1
if n % 100 == 0 or time.time() - last_save > 30:
log.info(f"saving position {stream.log_file}/{stream.log_pos}")
with open(SAVE_LOC, 'w') as f:
json.dump({"log_file": stream.log_file, "log_pos": stream.log_pos}, f)
def run(self):
with open(SAVE_LOC) as f:
pos = json.load(f)
stream = BinLogStreamReader(
# TODO parse out from config.py or something
connection_settings = {
'host': MYSQL_HOST,
'port': MYSQL_PORT,
'user': MYSQL_USER,
'passwd': MYSQL_PW
},
server_id=10, # arbitrary
# only care about this database currently
only_schemas=[NT_DB],
# these tables in the database
only_tables=["nyaa_torrents", "nyaa_statistics", "sukebei_torrents", "sukebei_statistics"],
# from our save file
resume_stream=True,
log_file=pos['log_file'],
log_pos=pos['log_pos'],
# skip the other stuff like table mapping
only_events=[UpdateRowsEvent, DeleteRowsEvent, WriteRowsEvent],
# if we're at the head of the log, block until something happens
# note it'd be nice to block async-style instead, but the mainline
# binlogreader is synchronous. there is an (unmaintained?) fork
# using aiomysql if anybody wants to revive that.
blocking=True)
log.info(f"reading binlog from {stream.log_file}/{stream.log_pos}")
for event in stream:
# save the pos of the stream and timestamp with each message, so we
# can commit in the other thread. and keep track of process latency
pos = (stream.log_file, stream.log_pos, event.timestamp)
with stats.pipeline() as s:
s.incr('total_events')
s.incr(f"event.{event.table}.{type(event).__name__}")
s.incr('total_rows', len(event.rows))
s.incr(f"rows.{event.table}.{type(event).__name__}", len(event.rows))
# XXX not a "timer", but we get a histogram out of it
s.timing(f"rows_per_event.{event.table}.{type(event).__name__}", len(event.rows))
if event.table == "nyaa_torrents" or event.table == "sukebei_torrents":
if event.table == "nyaa_torrents":
index_name = "nyaa"
else:
index_name = "sukebei"
if type(event) is WriteRowsEvent:
for row in event.rows:
self.write_buf.put(
(pos, reindex_torrent(row['values'], index_name)),
block=True)
elif type(event) is UpdateRowsEvent:
# UpdateRowsEvent includes the old values too, but we don't care
for row in event.rows:
self.write_buf.put(
(pos, reindex_torrent(row['after_values'], index_name)),
block=True)
elif type(event) is DeleteRowsEvent:
# ok, bye
for row in event.rows:
self.write_buf.put((pos, delet_this(row, index_name)), block=True)
else:
raise Exception(f"unknown event {type(event)}")
elif event.table == "nyaa_statistics" or event.table == "sukebei_statistics":
if event.table == "nyaa_statistics":
index_name = "nyaa"
else:
index_name = "sukebei"
if type(event) is WriteRowsEvent:
for row in event.rows:
self.write_buf.put(
(pos, reindex_stats(row['values'], index_name)),
block=True)
elif type(event) is UpdateRowsEvent:
for row in event.rows:
self.write_buf.put(
(pos, reindex_stats(row['after_values'], index_name)),
block=True)
elif type(event) is DeleteRowsEvent:
# uh ok. Assume that the torrent row will get deleted later,
# which will clean up the entire es "torrent" document
pass
else:
raise Exception(f"unknown event {type(event)}")
else:
raise Exception(f"unknown table {s.table}")
class EsPoster(Thread):
# read_buf is the queue of stuff to bulk post
def __init__(self, read_buf, chunk_size=1000, flush_interval=5):
Thread.__init__(self)
self.read_buf = read_buf
self.chunk_size = chunk_size
self.flush_interval = flush_interval
def run(self):
es = Elasticsearch(timeout=30)
last_save = time.time()
since_last = 0
while True:
actions = []
while len(actions) < self.chunk_size:
try:
# grab next event from queue with metadata that creepily
# updates, surviving outside the scope of the loop
((log_file, log_pos, timestamp), action) = \
self.read_buf.get(block=True, timeout=self.flush_interval)
actions.append(action)
except Empty:
# nothing new for the whole interval
break
if not actions:
# nothing to post
log.debug("no changes...")
continue
# XXX "time" to get histogram of no events per bulk
stats.timing('actions_per_bulk', len(actions))
try:
with stats.timer('post_bulk'):
bulk(es, actions, chunk_size=self.chunk_size)
except BulkIndexError as bie:
# in certain cases where we're really out of sync, we update a
# stat when the torrent doc is, causing a "document missing"
# error from es, with no way to suppress that server-side.
# Thus ignore that type of error if it's the only problem
for e in bie.errors:
try:
if e['update']['error']['type'] != 'document_missing_exception':
raise bie
except KeyError:
raise bie
# how far we're behind, wall clock
stats.gauge('process_latency', int((time.time() - timestamp) * 1000))
since_last += len(actions)
if since_last >= 10000 or (time.time() - last_save) > 10:
log.info(f"saving position {log_file}/{log_pos}, {time.time() - timestamp:,.3f} seconds behind")
with stats.timer('save_pos'):
with open(SAVE_LOC, 'w') as f:
json.dump({"log_file": log_file, "log_pos": log_pos}, f)
last_save = time.time()
since_last = 0
# in-memory queue between binlog and es. The bigger it is, the more events we
# can parse in memory while waiting for es to catch up, at the expense of heap.
buf = Queue(maxsize=INTERNAL_QUEUE_DEPTH)
reader = BinlogReader(buf)
reader.daemon = True
writer = EsPoster(buf, chunk_size=ES_CHUNK_SIZE, flush_interval=FLUSH_INTERVAL)
writer.daemon = True
reader.start()
writer.start()
# on the main thread, poll the queue size for monitoring
while True:
stats.gauge('queue_depth', buf.qsize())
time.sleep(1)

View File

@ -87,6 +87,11 @@ tor_group.add_argument('-H', '--hidden', default=False, action='store_true', hel
tor_group.add_argument('-C', '--complete', default=False, action='store_true', help='Mark torrent as complete (eg. season batch)')
tor_group.add_argument('-R', '--remake', default=False, action='store_true', help='Mark torrent as remake (derivative work from another release)')
trusted_group = tor_group.add_mutually_exclusive_group(required=False)
trusted_group.add_argument('-T', '--trusted', dest='trusted', action='store_true', help='Mark torrent as trusted, if possible. Defaults to true')
trusted_group.add_argument('--no-trusted', dest='trusted', action='store_false', help='Do not mark torrent as trusted')
parser.set_defaults(trusted=True)
tor_group.add_argument('torrent', metavar='TORRENT_FILE', help='The .torrent file to upload')
@ -145,6 +150,7 @@ if __name__ == "__main__":
'hidden' : args.hidden,
'complete' : args.complete,
'remake' : args.remake,
'trusted' : args.trusted,
}
encoded_data = {
'torrent_data' : json.dumps(data)