nyaa/nyaa/api_handler.py

336 lines
12 KiB
Python

import binascii
import functools
import json
import os.path
import re
import flask
from nyaa import backend, bencode, forms, models, utils
from nyaa.extensions import db
from nyaa.views.torrents import _create_upload_category_choices
api_blueprint = flask.Blueprint('api', __name__, url_prefix='/api')
# #################################### API HELPERS ####################################
def basic_auth_user(f):
''' A decorator that will try to validate the user into g.user from basic auth.
Note: this does not set user to None on failure, so users can also authorize
themselves with the cookie (handled in views.main.before_request). '''
@functools.wraps(f)
def decorator(*args, **kwargs):
auth = flask.request.authorization
if auth:
user = models.User.by_username_or_email(auth.get('username'))
if user and user.validate_authorization(auth.get('password')):
flask.g.user = user
return f(*args, **kwargs)
return decorator
def api_require_user(f):
''' Returns an error message if flask.g.user is None.
Remember to put after basic_auth_user. '''
@functools.wraps(f)
def decorator(*args, **kwargs):
if flask.g.user is None:
return flask.jsonify({'errors': ['Bad authorization']}), 403
return f(*args, **kwargs)
return decorator
# #################################### API ROUTES ####################################
# Map UploadForm fields to API keys
UPLOAD_API_FORM_KEYMAP = {
'torrent_file': 'torrent',
'display_name': 'name',
'is_anonymous': 'anonymous',
'is_hidden': 'hidden',
'is_complete': 'complete',
'is_remake': 'remake',
'is_trusted': 'trusted'
}
UPLOAD_API_FORM_KEYMAP_REVERSE = {v: k for k, v in UPLOAD_API_FORM_KEYMAP.items()}
UPLOAD_API_DEFAULTS = {
'name': '',
'category': '',
'anonymous': False,
'hidden': False,
'complete': False,
'remake': False,
'trusted': True,
'information': '',
'description': ''
}
@api_blueprint.route('/upload', methods=['POST'])
@api_blueprint.route('/v2/upload', methods=['POST'])
@basic_auth_user
@api_require_user
def v2_api_upload():
mapped_dict = {
'torrent_file': flask.request.files.get('torrent')
}
request_data_field = flask.request.form.get('torrent_data')
if request_data_field is None:
return flask.jsonify({'errors': ['missing torrent_data field']}), 400
try:
request_data = json.loads(request_data_field)
except json.decoder.JSONDecodeError:
return flask.jsonify({'errors': ['unable to parse valid JSON in torrent_data']}), 400
# Map api keys to upload form fields
for key, default in UPLOAD_API_DEFAULTS.items():
mapped_key = UPLOAD_API_FORM_KEYMAP_REVERSE.get(key, key)
value = request_data.get(key, default)
mapped_dict[mapped_key] = value if value is not None else default
# Flask-WTF (very helpfully!!) automatically grabs the request form, so force a None formdata
upload_form = forms.UploadForm(None, data=mapped_dict, meta={'csrf': False})
upload_form.category.choices = _create_upload_category_choices()
if upload_form.validate():
try:
torrent = backend.handle_torrent_upload(upload_form, flask.g.user)
# Create a response dict with relevant data
torrent_metadata = {
'url': flask.url_for('torrents.view', torrent_id=torrent.id, _external=True),
'id': torrent.id,
'name': torrent.display_name,
'hash': torrent.info_hash.hex(),
'magnet': torrent.magnet_uri
}
return flask.jsonify(torrent_metadata)
except backend.TorrentExtraValidationException:
pass
# Map errors back from form fields into the api keys
mapped_errors = {UPLOAD_API_FORM_KEYMAP.get(k, k): v for k, v in upload_form.errors.items()}
return flask.jsonify({'errors': mapped_errors}), 400
# #################################### TEMPORARY ####################################
from orderedset import OrderedSet # noqa: E402 isort:skip
@api_blueprint.route('/ghetto_import', methods=['POST'])
def ghetto_import():
if flask.request.remote_addr != '127.0.0.1':
return flask.error(403)
torrent_file = flask.request.files.get('torrent')
try:
torrent_dict = bencode.decode(torrent_file)
# field.data.close()
except (bencode.MalformedBencodeException, UnicodeError):
return 'Malformed torrent file', 500
try:
forms._validate_torrent_metadata(torrent_dict)
except AssertionError as e:
return 'Malformed torrent metadata ({})'.format(e.args[0]), 500
try:
tracker_found = forms._validate_trackers(torrent_dict) # noqa F841
except AssertionError as e:
return 'Malformed torrent trackers ({})'.format(e.args[0]), 500
bencoded_info_dict = bencode.encode(torrent_dict['info'])
info_hash = utils.sha1_hash(bencoded_info_dict)
# Check if the info_hash exists already in the database
torrent = models.Torrent.by_info_hash(info_hash)
if not torrent:
return 'This torrent does not exists', 500
if torrent.has_torrent:
return 'This torrent already has_torrent', 500
# Torrent is legit, pass original filename and dict along
torrent_data = forms.TorrentFileData(filename=os.path.basename(torrent_file.filename),
torrent_dict=torrent_dict,
info_hash=info_hash,
bencoded_info_dict=bencoded_info_dict)
# The torrent has been validated and is safe to access with ['foo'] etc - all relevant
# keys and values have been checked for (see UploadForm in forms.py for details)
info_dict = torrent_data.torrent_dict['info']
changed_to_utf8 = backend._replace_utf8_values(torrent_data.torrent_dict)
torrent_filesize = info_dict.get('length') or sum(
f['length'] for f in info_dict.get('files'))
# In case no encoding, assume UTF-8.
torrent_encoding = torrent_data.torrent_dict.get('encoding', b'utf-8').decode('utf-8')
# Store bencoded info_dict
torrent.info = models.TorrentInfo(info_dict=torrent_data.bencoded_info_dict)
torrent.has_torrent = True
# To simplify parsing the filelist, turn single-file torrent into a list
torrent_filelist = info_dict.get('files')
used_path_encoding = changed_to_utf8 and 'utf-8' or torrent_encoding
parsed_file_tree = dict()
if not torrent_filelist:
# If single-file, the root will be the file-tree (no directory)
file_tree_root = parsed_file_tree
torrent_filelist = [{'length': torrent_filesize, 'path': [info_dict['name']]}]
else:
# If multi-file, use the directory name as root for files
file_tree_root = parsed_file_tree.setdefault(
info_dict['name'].decode(used_path_encoding), {})
# Parse file dicts into a tree
for file_dict in torrent_filelist:
# Decode path parts from utf8-bytes
path_parts = [path_part.decode(used_path_encoding) for path_part in file_dict['path']]
filename = path_parts.pop()
current_directory = file_tree_root
for directory in path_parts:
current_directory = current_directory.setdefault(directory, {})
# Don't add empty filenames (BitComet directory)
if filename:
current_directory[filename] = file_dict['length']
parsed_file_tree = utils.sorted_pathdict(parsed_file_tree)
json_bytes = json.dumps(parsed_file_tree, separators=(',', ':')).encode('utf8')
torrent.filelist = models.TorrentFilelist(filelist_blob=json_bytes)
db.session.add(torrent)
db.session.flush()
# Store the users trackers
trackers = OrderedSet()
announce = torrent_data.torrent_dict.get('announce', b'').decode('ascii')
if announce:
trackers.add(announce)
# List of lists with single item
announce_list = torrent_data.torrent_dict.get('announce-list', [])
for announce in announce_list:
trackers.add(announce[0].decode('ascii'))
# Remove our trackers, maybe? TODO ?
# Search for/Add trackers in DB
db_trackers = OrderedSet()
for announce in trackers:
tracker = models.Trackers.by_uri(announce)
# Insert new tracker if not found
if not tracker:
tracker = models.Trackers(uri=announce)
db.session.add(tracker)
db.session.flush()
db_trackers.add(tracker)
# Store tracker refs in DB
for order, tracker in enumerate(db_trackers):
torrent_tracker = models.TorrentTrackers(torrent_id=torrent.id,
tracker_id=tracker.id, order=order)
db.session.add(torrent_tracker)
db.session.commit()
return 'success'
# ####################################### INFO #######################################
ID_PATTERN = '^[0-9]+$'
INFO_HASH_PATTERN = '^[0-9a-fA-F]{40}$' # INFO_HASH as string
@api_blueprint.route('/info/<torrent_id_or_hash>', methods=['GET'])
@basic_auth_user
@api_require_user
def v2_api_info(torrent_id_or_hash):
torrent_id_or_hash = torrent_id_or_hash.lower().strip()
id_match = re.match(ID_PATTERN, torrent_id_or_hash)
hex_hash_match = re.match(INFO_HASH_PATTERN, torrent_id_or_hash)
torrent = None
if id_match:
torrent = models.Torrent.by_id(int(torrent_id_or_hash))
elif hex_hash_match:
# Convert the string representation of a torrent hash back into a binary representation
a2b_hash = binascii.unhexlify(torrent_id_or_hash)
torrent = models.Torrent.by_info_hash(a2b_hash)
else:
return flask.jsonify({'errors': ['Query was not a valid id or hash.']}), 400
viewer = flask.g.user
if not torrent:
return flask.jsonify({'errors': ['Query was not a valid id or hash.']}), 400
# Only allow admins see deleted torrents
if torrent.deleted and not (viewer and viewer.is_superadmin):
return flask.jsonify({'errors': ['Query was not a valid id or hash.']}), 400
submitter = None
if not torrent.anonymous and torrent.user:
submitter = torrent.user.username
if torrent.user and (viewer == torrent.user or viewer.is_moderator):
submitter = torrent.user.username
files = {}
if torrent.filelist:
files = json.loads(torrent.filelist.filelist_blob.decode('utf-8'))
# Create a response dict with relevant data
torrent_metadata = {
'submitter': submitter,
'url': flask.url_for('torrents.view', torrent_id=torrent.id, _external=True),
'id': torrent.id,
'name': torrent.display_name,
'creation_date': torrent.created_time.strftime('%Y-%m-%d %H:%M UTC'),
'hash_b32': torrent.info_hash_as_b32, # as used in magnet uri
'hash_hex': torrent.info_hash_as_hex, # .hex(), #as shown in torrent client
'magnet': torrent.magnet_uri,
'main_category': torrent.main_category.name,
'main_category_id': torrent.main_category.id,
'sub_category': torrent.sub_category.name,
'sub_category_id': torrent.sub_category.id,
'information': torrent.information,
'description': torrent.description,
'stats': {
'seeders': torrent.stats.seed_count,
'leechers': torrent.stats.leech_count,
'downloads': torrent.stats.download_count
},
'filesize': torrent.filesize,
'files': files,
'is_trusted': torrent.trusted,
'is_complete': torrent.complete,
'is_remake': torrent.remake
}
return flask.jsonify(torrent_metadata), 200