From 6bfb65172c7174d2090352dcdd17820d22afc26c Mon Sep 17 00:00:00 2001 From: TheAMM Date: Thu, 18 May 2017 15:28:51 +0300 Subject: [PATCH 1/2] Add helper functions to models.User --- nyaa/models.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/nyaa/models.py b/nyaa/models.py index 9ae3597..d82a013 100644 --- a/nyaa/models.py +++ b/nyaa/models.py @@ -343,6 +343,16 @@ class User(db.Model): def __repr__(self): return '' % self.username + def validate_authorization(self, password): + ''' Returns a boolean for whether the user can be logged in ''' + checks = [ + # Password must match + password == self.password_hash, + # Reject inactive and banned users + self.status == UserStatusType.ACTIVE + ] + return all(checks) + @classmethod def by_id(cls, id): return cls.query.get(id) @@ -357,6 +367,10 @@ class User(db.Model): user = cls.query.filter_by(email=email).first() return user + @classmethod + def by_username_or_email(cls, username_or_email): + return cls.by_username(username_or_email) or cls.by_email(username_or_email) + @property def is_admin(self): return self.level is UserLevelType.ADMIN or self.level is UserLevelType.SUPERADMIN From e5fce168a09ee5706a0478c5bb39dc3ab32ec107 Mon Sep 17 00:00:00 2001 From: TheAMM Date: Thu, 18 May 2017 15:30:03 +0300 Subject: [PATCH 2/2] Upload API V2 --- nyaa/api_handler.py | 93 +++++++++++++++++++- nyaa/routes.py | 3 + utils/api_uploader_v2.py | 179 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 274 insertions(+), 1 deletion(-) create mode 100755 utils/api_uploader_v2.py diff --git a/nyaa/api_handler.py b/nyaa/api_handler.py index 8090a5a..5b9d4d9 100644 --- a/nyaa/api_handler.py +++ b/nyaa/api_handler.py @@ -6,13 +6,39 @@ from nyaa import models, forms from nyaa import bencode, backend, utils from nyaa import torrents +import functools import json import os.path #from orderedset import OrderedSet #from werkzeug import secure_filename -# #################################### API HELPERS #################################### +api_blueprint = flask.Blueprint('api', __name__) +# #################################### 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 routes.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 def validate_user(upload_request): auth_info = None @@ -90,3 +116,68 @@ def api_upload(upload_request, user): return_error_messages.extend(error_messages) return flask.make_response(flask.jsonify({'Failure': return_error_messages}), 400) + +# V2 below + +# 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' +} +UPLOAD_API_FORM_KEYMAP_REVERSE = {v:k for k,v in UPLOAD_API_FORM_KEYMAP.items()} +UPLOAD_API_KEYS = [ + 'name', + 'category', + 'anonymous', + 'hidden', + 'complete', + 'remake', + 'information', + 'description' +] + +@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 + request_data = json.loads(request_data_field) + + # 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) + + # Flask-WTF (very helpfully!!) automatically grabs the request form, so force a None formdata + upload_form = forms.UploadForm(None, data=mapped_dict) + upload_form.category.choices = _create_upload_category_choices() + + if upload_form.validate(): + torrent = backend.handle_torrent_upload(upload_form, flask.g.user) + + # Create a response dict with relevant data + torrent_metadata = { + 'url' : flask.url_for('view_torrent', 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) + else: + # 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 diff --git a/nyaa/routes.py b/nyaa/routes.py index 9627705..8e20773 100644 --- a/nyaa/routes.py +++ b/nyaa/routes.py @@ -110,6 +110,9 @@ def get_utc_timestamp(datetime_str): def get_display_time(datetime_str): return datetime.strptime(datetime_str, '%Y-%m-%dT%H:%M:%S').strftime('%Y-%m-%d %H:%M') +# Routes start here # + +app.register_blueprint(api_handler.api_blueprint, url_prefix='/api') @app.route('/rss', defaults={'rss': True}) @app.route('/', defaults={'rss': False}) diff --git a/utils/api_uploader_v2.py b/utils/api_uploader_v2.py new file mode 100755 index 0000000..70e956e --- /dev/null +++ b/utils/api_uploader_v2.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +import os +import requests +import json + +import argparse + +NYAA_HOST = 'https://nyaa.si' +SUKEBEI_HOST = 'https://sukebei.nyaa.si' + +API_BASE = '/api' +API_UPLOAD = API_BASE + '/upload' + +NYAA_CATS = '''1_1 - Anime - AMV +1_2 - Anime - English +1_3 - Anime - Non-English +1_4 - Anime - Raw +2_1 - Audio - Lossless +2_2 - Audio - Lossy +3_1 - Literature - English-translated +3_2 - Literature - Non-English +3_3 - Literature - Non-English-Translated +3_4 - Literature - Raw +4_1 - Live Action - English-translated +4_2 - Live Action - Idol/Promotional Video +4_3 - Live Action - Non-English-translated +4_4 - Live Action - Raw +5_1 - Pictures - Graphics +5_2 - Pictures - Photos +6_1 - Software - Applications +6_2 - Software - Games''' + +SUKEBEI_CATS = '''1_1 - Art - Anime +1_2 - Art - Doujinshi +1_3 - Art - Games +1_4 - Art - Manga +1_5 - Art - Pictures +2_1 - Real Life - Photobooks / Pictures +2_2 - Real Life - Videos''' + +class CategoryPrintAction(argparse.Action): + def __init__(self, option_strings, nargs='?', help=None, **kwargs): + super().__init__(option_strings=option_strings, + dest='site', + default=None, + nargs=nargs, + help=help) + + def __call__(self, parser, namespace, values, option_string=None): + if values and values.lower() == 'sukebei': + print("Sukebei categories") + print(SUKEBEI_CATS) + else: + print("Nyaa categories") + print(NYAA_CATS) + parser.exit() + +environment_epillog = '''You may also provide environment variables NYAA_API_HOST, NYAA_API_USERNAME and NYAA_API_PASSWORD for connection info.''' + +parser = argparse.ArgumentParser(description='Upload torrents to Nyaa.si', epilog=environment_epillog) + +parser.add_argument('--list-categories', default=False, action=CategoryPrintAction, nargs='?', help='List torrent categories. Include "sukebei" to show Sukebei categories') + +conn_group = parser.add_argument_group('Connection options') + +conn_group.add_argument('-s', '--sukebei', default=False, action='store_true', help='Upload to sukebei.nyaa.si') + +conn_group.add_argument('-u', '--user', help='Username or email') +conn_group.add_argument('-p', '--password', help='Password') +conn_group.add_argument('--host', help='Select another api host (for debugging purposes)') + +resp_group = parser.add_argument_group('Response options') + +resp_group.add_argument('--raw', default=False, action='store_true', help='Print only raw response (JSON)') +resp_group.add_argument('-m', '--magnet', default=False, action='store_true', help='Print magnet uri') + +tor_group = parser.add_argument_group('Torrent options') + +tor_group.add_argument('-c', '--category', required=True, help='Torrent category (see ). Required.') +tor_group.add_argument('-n', '--name', help='Display name for the torrent (optional)') +tor_group.add_argument('-i', '--information', help='Information field (optional)') +tor_group.add_argument('-d', '--description', help='Description for the torrent (optional)') +tor_group.add_argument('-D', '--description-file', metavar='FILE', help='Read description from a file (optional)') + +tor_group.add_argument('-A', '--anonymous', default=False, action='store_true', help='Upload torrent anonymously') +tor_group.add_argument('-H', '--hidden', default=False, action='store_true', help='Hide torrent from results') +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)') + +tor_group.add_argument('torrent', metavar='TORRENT_FILE', help='The .torrent file to upload') + + +def crude_torrent_check(file_object): + ''' Does a simple check to weed out accidentally picking a wrong file ''' + # Check if file seems to be a bencoded dictionary: starts with d and end with e + file_object.seek(0) + if file_object.read(1) != b'd': + return False + + file_object.seek(-1, os.SEEK_END) + if file_object.read(1) != b'e': + return False + + # Seek back to beginning + file_object.seek(0) + + return True + + +if __name__ == "__main__": + args = parser.parse_args() + + # Use debug host from args or environment, if set + debug_host = args.host or os.getenv('NYAA_API_HOST') + api_host = (debug_host or (args.sukebei and SUKEBEI_HOST or NYAA_HOST)).rstrip('/') + + api_upload_url = api_host + API_UPLOAD + + if args.description_file: + # Replace args.description with contents of the file + with open(args.description_file, 'r') as in_file: + args.description = in_file.read() + + torrent_file = open(args.torrent, 'rb') + # Check if the file even seems like a torrent + if not crude_torrent_check(torrent_file): + raise Exception("File '{}' doesn't seem to be a torrent file".format(args.torrent)) + + api_username = args.user or os.getenv('NYAA_API_USERNAME') + api_password = args.password or os.getenv('NYAA_API_PASSWORD') + + if not (api_username and api_password): + raise Exception('No authorization found from arguments or environment variables.') + + auth = (api_username, api_password) + + data = { + 'name' : args.name, + 'category' : args.category, + + 'information' : args.information, + 'description' : args.description, + + 'anonymous' : args.anonymous, + 'hidden' : args.hidden, + 'complete' : args.complete, + 'remake' : args.remake, + } + encoded_data = { + 'torrent_data' : json.dumps(data) + } + + files = { + 'torrent' : torrent_file + } + + # Go! + r = requests.post(api_upload_url, auth=auth, data=encoded_data, files=files) + torrent_file.close() + + if args.raw: + print(r.text) + + else: + try: + response = r.json() + except ValueError: + print('Bad response:') + print(r.text) + exit(1) + + errors = response.get('errors') + if errors: + print('Upload failed', errors) + exit(1) + else: + print("[Uploaded] {url} - '{name}'".format(**response)) + if args.magnet: + print(response['magnet']) \ No newline at end of file