1
0
Fork 0
mirror of https://gitlab.com/SIGBUS/nyaa.git synced 2024-12-22 11:40:01 +00:00

Initial commit.

This commit is contained in:
nyaadev 2017-05-12 20:51:49 +02:00
commit 00d65e312c
89 changed files with 5733 additions and 0 deletions

48
README.md Normal file
View file

@ -0,0 +1,48 @@
# NyaaV2
## Setup:
- Create your virtualenv, for example with `pyvenv venv`
- Enter your virtualenv with `source venv/bin/activate`
- Install dependencies with `pip install -r requirements.txt`
- Run `python db_create.py` to create the database
- Start the dev server with `python run.py`
## Updated Setup (python 3.6.1):
- Install dependencies https://github.com/pyenv/pyenv/wiki/Common-build-problems
- Install `pyenv` https://github.com/pyenv/pyenv/blob/master/README.md#installation
- Install `pyenv-virtualenv` https://github.com/pyenv/pyenv-virtualenv/blob/master/README.md
- `pyenv install 3.6.1`
- `pyenv virtualenv 3.6.1 nyaa`
- `pyenv activate nyaa`
- Install dependencies with `pip install -r requirements.txt`
- Copy `config.example.py` into `config.py`
- Change TALBE_PREFIX to `nyaa_` or `sukebei_` depending on the site
## Setting up MySQL/MariaDB database for advanced functionality
- Enable `USE_MYSQL` flag in config.py
- Install latest mariadb by following instructions here https://downloads.mariadb.org/mariadb/repositories/
- Tested versions: `mysql Ver 15.1 Distrib 10.0.30-MariaDB, for debian-linux-gnu (x86_64) using readline 5.2`
- Run the following commands logged in as your root db user:
- `CREATE USER 'test'@'localhost' IDENTIFIED BY 'test123';`
- `GRANT ALL PRIVILEGES ON * . * TO 'test'@'localhost';`
- `FLUSH PRIVILEGES;`
- `CREATE DATABASE nyaav2 DEFAULT CHARACTER SET utf8 COLLATE utf8_bin;`
- To setup and import nyaa_maria_vx.sql:
- `mysql -u <user> -p nyaav2`
- `DROP DATABASE nyaav2;`
- `CREATE DATABASE nyaav2 DEFAULT CHARACTER SET utf8 COLLATE utf8_bin;`
- `SOURCE ~/path/to/database/nyaa_maria_vx.sql`
## Finishing up
- Run `python db_create.py` to create the database
- Load the .sql file
- `mysql -u user -p nyaav2`
- `SOURCE cocks.sql`
- Remember to change the default user password to an empty string to disable logging in
- Start the dev server with `python run.py`
- Deactivate `source deactivate`
## Code Quality:
- Remember to follow PEP8 style guidelines and run `./lint.sh` before committing.

15
WSGI.py Normal file
View file

@ -0,0 +1,15 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
import gevent.monkey
gevent.monkey.patch_all()
from nyaa import app
if app.config["DEBUG"]:
from werkzeug.debug import DebuggedApplication
app.wsgi_app = DebuggedApplication(app.wsgi_app, True)
if __name__ == '__main__':
import gevent.pywsgi
gevent_server = gevent.pywsgi.WSGIServer(("localhost", 5000), app.wsgi_app)
gevent_server.serve_forever()

55
api_uploader.py Normal file
View file

@ -0,0 +1,55 @@
# api_uploader.py
# Uploads a single file
# I will create another script for batch uploading
import json
import requests
url = "http://127.0.0.1:5500/api/upload"
# Required for Auth
username = ""
password = ""
# Required
torrent_name = ""
# Required
main_cat = ""
# Required
sub_cat = ""
# May be blank
information = ""
# May be blank
description = ""
# flags = [Hidden, Remake, Complete, Anonymous]
# 0 for NOT SET / 1 for SET
# Required
flags = [0, 0, 0, 0]
metadata={
"username": username,
"password": password,
"display_name": torrent_name,
"main_cat": main_cat,
"sub_cat": sub_cat,
"information": information,
"description": description,
"flags": flags
}
# Required
file_name = ""
files = {
'json': (json.dumps(metadata)),
'torrent': ('{0}'.format(file_name), open(file_name, 'rb'), 'application/octet-stream')}
response = requests.post(url, files=files)
json_response = response.json()
print(json_response)

2
batch_upload_torrent.sh Executable file
View file

@ -0,0 +1,2 @@
up_t() { curl -F "category=1_2" -F "torrent_file=@$1" 'http://localhost:5500/upload'; }
for x in test_torrent_batch/*; do up_t "$x"; done

51
config.example.py Normal file
View file

@ -0,0 +1,51 @@
import os
DEBUG = True
USE_RECAPTCHA = False
USE_EMAIL_VERIFICATION = False
USE_MYSQL = True
# Enable this once stat integration is done
ENABLE_SHOW_STATS = False
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
if USE_MYSQL:
SQLALCHEMY_DATABASE_URI = ('mysql://test:test123@localhost/nyaav2')
else:
SQLALCHEMY_DATABASE_URI = (
'sqlite:///' + os.path.join(BASE_DIR, 'test.db') + '?check_same_thread=False')
CSRF_SESSION_KEY = '***'
SECRET_KEY = '***'
# Prefix for running multiple sites, user table will not be prefixed.
# For sukebei, change 'nyaa_' to 'sukebei_'
TABLE_PREFIX = 'nyaa_'
# for recaptcha and email verification:
# keys for localhost. Change as appropriate when actual domain is registered.
RECAPTCHA_PUBLIC_KEY = '***'
RECAPTCHA_PRIVATE_KEY = '***'
SMTP_SERVER = '***'
SMTP_PORT = 587
MAIL_FROM_ADDRESS = '***'
SMTP_USERNAME = '***'
SMTP_PASSWORD = '***'
RESULTS_PER_PAGE = 75
# What the site identifies itself as.
SITE_NAME = 'Nyaa'
# The maximum number of files a torrent can contain
# until the site says "Too many files to display."
MAX_FILES_VIEW = 1000
# """
# Setting to make sure main announce url is present in torrent
# """
ENFORCE_MAIN_ANNOUNCE_URL = False
MAIN_ANNOUNCE_URL = ''
BACKUP_TORRENT_FOLDER = 'torrents'

40
db_create.py Normal file
View file

@ -0,0 +1,40 @@
import sys
from nyaa import app, db, models
# Create tables
db.create_all()
# Insert categories
if app.config['TABLE_PREFIX'] == 'nyaa_':
CATEGORIES = [
('Anime', ['Anime Music Video', 'English-translated', 'Non-English-translated', 'Raw']),
('Audio', ['Lossless', 'Lossy']),
('Literature', ['English-translated', 'Non-English-translated', 'Raw']),
('Live Action', ['English-translated', 'Idol/Promotional Video', 'Non-English-translated', 'Raw']),
('Pictures', ['Graphics', 'Photos']),
('Software', ['Applications', 'Games']),
]
elif app.config['TABLE_PREFIX'] == 'sukebei_':
CATEGORIES = [
('Art', ['Anime', 'Doujinshi', 'Games', 'Manga', 'Pictures']),
('Real Life', ['Photobooks / Pictures', 'Videos']),
]
else:
CATEGORIES = []
for main_cat_name, sub_cat_names in CATEGORIES:
main_cat = models.MainCategory(name=main_cat_name)
for i, sub_cat_name in enumerate(sub_cat_names):
# Composite keys can't autoincrement, set sub_cat id manually (1-index)
sub_cat = models.SubCategory(id=i+1, name=sub_cat_name, main_category=main_cat)
db.session.add(main_cat)
db.session.commit()
# Create fulltext index
if app.config['USE_MYSQL']:
db.engine.execute('ALTER TABLE ' + app.config['TABLE_PREFIX'] + 'torrents ADD FULLTEXT KEY (display_name)')

6
lint.sh Executable file
View file

@ -0,0 +1,6 @@
autopep8 nyaa/ \
--recursive \
--in-place \
--pep8-passes 2000 \
--max-line-length 100 \
--verbose

6
my.cnf Normal file
View file

@ -0,0 +1,6 @@
[mysqld]
innodb_ft_min_token_size=2
ft_min_word_len=2
innodb_ft_cache_size = 80000000
innodb_ft_total_cache_size = 1600000000
max_allowed_packet = 100M

53
nyaa/__init__.py Normal file
View file

@ -0,0 +1,53 @@
import os
import logging
import flask
from flask_sqlalchemy import SQLAlchemy
from flask_assets import Environment, Bundle
from flask_debugtoolbar import DebugToolbarExtension
from nyaa import fix_paginate
app = flask.Flask(__name__)
app.config.from_object('config')
# Database
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
# Debugging
if app.config['DEBUG']:
app.config['DEBUG_TB_INTERCEPT_REDIRECTS'] = False
toolbar = DebugToolbarExtension(app)
app.logger.setLevel(logging.DEBUG)
else:
app.logger.setLevel(logging.WARNING)
# Logging
if 'LOG_FILE' in app.config:
from logging.handlers import RotatingFileHandler
app.log_handler = RotatingFileHandler(
app.config['LOG_FILE'], maxBytes=10000, backupCount=1)
app.logger.addHandler(app.log_handler)
# Log errors and display a message to the user in production mdode
if not app.config['DEBUG']:
@app.errorhandler(500)
def internal_error(exception):
app.logger.error(exception)
flask.flash(flask.Markup(
'<strong>An error occured!</strong> Debugging information has been logged.'), 'danger')
return flask.redirect('/')
# Enable the jinja2 do extension.
app.jinja_env.add_extension('jinja2.ext.do')
app.jinja_env.lstrip_blocks = True
app.jinja_env.trim_blocks = True
db = SQLAlchemy(app)
assets = Environment(app)
# css = Bundle('style.scss', filters='libsass',
# output='style.css', depends='**/*.scss')
# assets.register('style_all', css)
from nyaa import routes

317
nyaa/api_handler.py Normal file
View file

@ -0,0 +1,317 @@
import flask
from nyaa import app, db
from nyaa import models, forms
from nyaa import bencode, utils
from nyaa import torrents
import json
import os.path
from orderedset import OrderedSet
from werkzeug import secure_filename
DEBUG_API = False
#################################### API ROUTES ####################################
CATEGORIES = [
('Anime', ['Anime Music Video', 'English-translated', 'Non-English-translated', 'Raw']),
('Audio', ['Lossless', 'Lossy']),
('Literature', ['English-translated', 'Non-English-translated', 'Raw']),
('Live Action', ['English-translated',
'Idol/Promotional Video', 'Non-English-translated', 'Raw']),
('Pictures', ['Graphics', 'Photos']),
('Software', ['Applications', 'Games']),
]
def validate_main_sub_cat(main_cat_name, sub_cat_name):
for main_cat in models.MainCategory.query.order_by(models.MainCategory.id):
if main_cat_name == main_cat.name:
for sub_cat in main_cat.sub_categories:
if sub_cat_name == sub_cat.name:
cat_id = main_cat.id_as_string
sub_cat_id = sub_cat.id_as_string
cat_sub_cat = sub_cat_id.split('_')
#print('cat: {0} sub_cat: {1}'.format(cat_sub_cat[0], cat_sub_cat[1]))
return True, cat_sub_cat[0], cat_sub_cat[1]
return False, 0, 0
def _replace_utf8_values(dict_or_list):
''' Will replace 'property' with 'property.utf-8' and remove latter if it exists.
Thanks, bitcomet! :/ '''
did_change = False
if isinstance(dict_or_list, dict):
for key in [key for key in dict_or_list.keys() if key.endswith('.utf-8')]:
dict_or_list[key.replace('.utf-8', '')] = dict_or_list.pop(key)
did_change = True
for value in dict_or_list.values():
did_change = _replace_utf8_values(value) or did_change
elif isinstance(dict_or_list, list):
for item in dict_or_list:
did_change = _replace_utf8_values(item) or did_change
return did_change
def validate_torrent_flags(torrent_flags):
_torrent_flags = ['hidden', 'remake', 'complete', 'anonymous']
if len(torrent_flags) != 4:
return False
for flag in torrent_flags:
if int(flag) not in [0, 1]:
return False
return True
# It might be good to factor this out of forms UploadForm because the same code is
# used in both files.
def validate_torrent_file(torrent_file_name, torrent_file):
# Decode and ensure data is bencoded data
try:
torrent_dict = bencode.decode(torrent_file)
except (bencode.MalformedBencodeException, UnicodeError):
return False, 'Malformed torrent file'
# Uncomment for debug print of the torrent
# forms._debug_print_torrent_metadata(torrent_dict)
try:
forms._validate_torrent_metadata(torrent_dict)
except AssertionError as e:
return False, 'Malformed torrent metadata ({})'.format(e.args[0])
# Note! bencode will sort dict keys, as per the spec
# This may result in a different hash if the uploaded torrent does not match the
# spec, but it's their own fault for using broken software! Right?
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
existing_torrent = models.Torrent.by_info_hash(info_hash)
if existing_torrent:
return False, 'That torrent already exists (#{})'.format(existing_torrent.id)
# Torrent is legit, pass original filename and dict along
return True, forms.TorrentFileData(filename=os.path.basename(torrent_file_name),
torrent_dict=torrent_dict,
info_hash=info_hash,
bencoded_info_dict=bencoded_info_dict)
def api_upload(upload_request):
if upload_request.method == 'POST':
j = None
torrent_file = None
try:
if 'json' in upload_request.files:
f = upload_request.files['json']
j = json.loads(f.read().decode('utf-8'))
if DEBUG_API:
print(json.dumps(j, indent=4))
_json_keys = ['username', 'password',
'display_name', 'main_cat', 'sub_cat', 'flags'] # 'information' and 'description' are not required
# Check that required fields are present
for _k in _json_keys:
if _k not in j.keys():
return flask.make_response(flask.jsonify({"Error": "Missing JSON field: {0}.".format(_k)}), 400)
# Check that no extra fields are present
for k in j.keys():
if k not in ['username', 'password',
'display_name', 'main_cat', 'sub_cat', 'information', 'description', 'flags']:
return flask.make_response(flask.jsonify({"Error": "Incorrect JSON field(s)."}), 400)
else:
return flask.make_response(flask.jsonify({"Error": "No metadata."}), 400)
if 'torrent' in upload_request.files:
f = upload_request.files['torrent']
if DEBUG_API:
print(f.filename)
torrent_file = f
# print(f.read())
else:
return flask.make_response(flask.jsonify({"Error": "No torrent file."}), 400)
# 'username' and 'password' must have been provided as they are part of j.keys()
username = j['username']
password = j['password']
# Validate that the provided username and password belong to a valid user
user = models.User.by_username(username)
if not user:
user = models.User.by_email(username)
if not user or password != user.password_hash or user.status == models.UserStatusType.INACTIVE:
return flask.make_response(flask.jsonify({"Error": "Incorrect username or password"}), 403)
current_user = user
display_name = j['display_name']
if (len(display_name) < 3) or (len(display_name) > 1024):
return flask.make_response(flask.jsonify({"Error": "Torrent name must be between 3 and 1024 characters."}), 400)
main_cat_name = j['main_cat']
sub_cat_name = j['sub_cat']
cat_subcat_status, cat_id, sub_cat_id = validate_main_sub_cat(
main_cat_name, sub_cat_name)
if not cat_subcat_status:
return flask.make_response(flask.jsonify({"Error": "Incorrect Category / Sub-Category."}), 400)
# TODO Sanitize information
information = None
try:
information = j['information']
if len(information) > 255:
return flask.make_response(flask.jsonify({"Error": "Information is limited to 255 characters."}), 400)
except Exception as e:
information = ''
# TODO Sanitize description
description = None
try:
description = j['description']
if len(description) > (10 * 1024):
return flask.make_response(flask.jsonify({"Error": "Description is limited to {0} characters.".format(10 * 1024)}), 403)
except Exception as e:
description = ''
v_flags = validate_torrent_flags(j['flags'])
if v_flags:
torrent_flags = j['flags']
else:
return flask.make_response(flask.jsonify({"Error": "Incorrect torrent flags."}), 400)
torrent_status, torrent_data = validate_torrent_file(
torrent_file.filename, torrent_file.read()) # Needs validation
if not torrent_status:
return flask.make_response(flask.jsonify({"Error": "Invalid or Duplicate torrent file."}), 400)
# 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 = _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')
torrent = models.Torrent(info_hash=torrent_data.info_hash,
display_name=display_name,
torrent_name=torrent_data.filename,
information=information,
description=description,
encoding=torrent_encoding,
filesize=torrent_filesize,
user=current_user)
# Store bencoded info_dict
torrent.info = models.TorrentInfo(info_dict=torrent_data.bencoded_info_dict)
torrent.stats = models.Statistic()
torrent.has_torrent = True
# Fields with default value will be None before first commit, so set .flags
torrent.flags = 0
torrent.anonymous = True if torrent_flags[0] else False
torrent.hidden = True if torrent_flags[1] else False
torrent.remake = True if torrent_flags[2] else False
torrent.complete = True if torrent_flags[3] else False
# Copy trusted status from user if possible
torrent.trusted = (current_user.level >=
models.UserLevelType.TRUSTED) if current_user else False
# Set category ids
torrent.main_category_id = cat_id
torrent.sub_category_id = sub_cat_id
# 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, {})
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_trackers.add(tracker)
db.session.flush()
# 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()
if app.config.get('BACKUP_TORRENT_FOLDER'):
torrent_file.seek(0, 0)
torrent_path = os.path.join(app.config['BACKUP_TORRENT_FOLDER'], '{}.{}'.format(torrent.id, secure_filename(torrent_file.filename)))
torrent_file.save(torrent_path)
torrent_file.close()
#print('Success? {0}'.format(torrent.id))
return flask.make_response(flask.jsonify({"Success": "Request was processed {0}".format(torrent.id)}), 200)
except Exception as e:
print('Exception: {0}'.format(e))
return flask.make_response(flask.jsonify({"Error": "Incorrect JSON. Please see HELP page for examples."}), 400)
else:
return flask.make_response(flask.jsonify({"Error": "Bad request"}), 400)

163
nyaa/bencode.py Normal file
View file

@ -0,0 +1,163 @@
from io import BytesIO
def _pairwise(iterable):
""" Returns items from an iterable two at a time, ala
[0, 1, 2, 3, ...] -> [(0, 1), (2, 3), ...] """
iterable = iter(iterable)
return zip(iterable, iterable)
__all__ = ['encode', 'decode', 'BencodeException', 'MalformedBencodeException']
# https://wiki.theory.org/BitTorrentSpecification#Bencoding
class BencodeException(Exception):
pass
class MalformedBencodeException(BencodeException):
pass
# bencode types
_DIGITS = b'0123456789'
_B_INT = b'i'
_B_LIST = b'l'
_B_DICT = b'd'
_B_END = b'e'
# Decoding of bencoded data
def _bencode_decode(file_object, decode_keys_as_utf8=True):
""" Decodes a bencoded value, raising a MalformedBencodeException on errors.
decode_keys_as_utf8 controls decoding dict keys as utf8 (which they
almost always are) """
if isinstance(file_object, str):
file_object = file_object.encode('utf8')
if isinstance(file_object, bytes):
file_object = BytesIO(file_object)
def create_ex(msg):
return MalformedBencodeException(
'{0} at position {1} (0x{1:02X} hex)'.format(msg, file_object.tell()))
def _read_list():
""" Decodes values from stream until a None is returned ('e') """
items = []
while True:
value = _bencode_decode(file_object, decode_keys_as_utf8=decode_keys_as_utf8)
if value is None:
break
items.append(value)
return items
kind = file_object.read(1)
if kind == _B_INT: # Integer
int_bytes = b''
while True:
c = file_object.read(1)
if not c:
raise create_ex('Unexpected end while reading an integer')
elif c == _B_END:
try:
return int(int_bytes.decode('utf8'))
except Exception as e:
raise create_ex('Unable to parse int')
# not a digit OR '-' in the middle of the int
if (c not in _DIGITS + b'-') or (c == b'-' and int_bytes):
raise create_ex('Unexpected input while reading an integer: ' + repr(c))
else:
int_bytes += c
elif kind == _B_LIST: # List
return _read_list()
elif kind == _B_DICT: # Dictionary
keys_and_values = _read_list()
if len(keys_and_values) % 2 != 0:
raise MalformedBencodeException('Uneven amount of key/value pairs')
# "Technically" the bencode dictionary keys are bytestrings,
# but real-world they're always(?) UTF-8.
decoded_dict = dict((decode_keys_as_utf8 and k.decode('utf8') or k, v)
for k, v in _pairwise(keys_and_values))
return decoded_dict
# List/dict end, but make sure input is not just 'e'
elif kind == _B_END and file_object.tell() > 0:
return None
elif kind in _DIGITS: # Bytestring
str_len_bytes = kind # keep first digit
# Read string length until a ':'
while True:
c = file_object.read(1)
if c in _DIGITS:
str_len_bytes += c
elif c == b':':
break
else:
raise create_ex('Unexpected input while reading string length: ' + repr(c))
try:
str_len = int(str_len_bytes.decode())
except Exception as e:
raise create_ex('Unable to parse bytestring length')
bytestring = file_object.read(str_len)
if len(bytestring) != str_len:
raise create_ex('Read only {} bytes, {} wanted'.format(len(bytestring), str_len))
return bytestring
else:
raise create_ex('Unexpected data type ({})'.format(repr(kind)))
# Bencoding
def _bencode_int(value):
""" Encode an integer, eg 64 -> i64e """
return _B_INT + str(value).encode('utf8') + _B_END
def _bencode_bytes(value):
""" Encode a bytestring (strings as UTF-8), eg 'hello' -> 5:hello """
if isinstance(value, str):
value = value.encode('utf8')
return str(len(value)).encode('utf8') + b':' + value
def _bencode_list(value):
""" Encode a list, eg [64, "hello"] -> li64e5:helloe """
return _B_LIST + b''.join(_bencode(item) for item in value) + _B_END
def _bencode_dict(value):
""" Encode a dict, which is keys and values interleaved as a list,
eg {"hello":123}-> d5:helloi123ee """
dict_keys = sorted(value.keys()) # Sort keys as per spec
return _B_DICT + b''.join(
_bencode_bytes(key) + _bencode(value[key]) for key in dict_keys) + _B_END
def _bencode(value):
""" Bencode any supported value (int, bytes, str, list, dict) """
if isinstance(value, int):
return _bencode_int(value)
elif isinstance(value, (str, bytes)):
return _bencode_bytes(value)
elif isinstance(value, list):
return _bencode_list(value)
elif isinstance(value, dict):
return _bencode_dict(value)
raise BencodeException('Unsupported type ' + str(type(value)))
# The functions call themselves
encode = _bencode
decode = _bencode_decode

28
nyaa/fix_paginate.py Normal file
View file

@ -0,0 +1,28 @@
from flask_sqlalchemy import Pagination, BaseQuery
from flask import abort
def paginate_faste(self, page=1, per_page=50, max_page=None, step=5):
if page < 1:
abort(404)
if max_page and page > max_page:
abort(404)
items = self.limit(per_page).offset((page - 1) * per_page).all()
if not items and page != 1:
abort(404)
# No need to count if we're on the first page and there are fewer
# items than we expected.
if page == 1 and len(items) < per_page:
total = len(items)
else:
if max_page:
total = self.order_by(None).limit(per_page * min((page + step), max_page)).count()
else:
total = self.order_by(None).limit(per_page * (page + step)).count()
return Pagination(self, page, per_page, total, items)
BaseQuery.paginate_faste = paginate_faste

352
nyaa/forms.py Normal file
View file

@ -0,0 +1,352 @@
from nyaa import db, app
from nyaa.models import User
from nyaa import bencode, utils, models
import os
import re
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileRequired
from wtforms import TextField, PasswordField, BooleanField, TextAreaField, SelectField
from wtforms.validators import Required, Optional, Email, Length, EqualTo, ValidationError, Regexp
# For DisabledSelectField
from wtforms.widgets import Select as SelectWidget
from wtforms.widgets import html_params, HTMLString
from flask_wtf.recaptcha import RecaptchaField
class Unique(object):
""" validator that checks field uniqueness """
def __init__(self, model, field, message=None):
self.model = model
self.field = field
if not message:
message = 'This element already exists'
self.message = message
def __call__(self, form, field):
check = self.model.query.filter(self.field == field.data).first()
if check:
raise ValidationError(self.message)
_username_validator = Regexp(
r'[a-zA-Z0-9_\-]+',
message='Your username must only consist of alphanumerics and _- (a-zA-Z0-9_-)')
class LoginForm(FlaskForm):
username = TextField('Username or email address', [Required(), _username_validator])
password = PasswordField('Password', [Required()])
class RegisterForm(FlaskForm):
username = TextField('Username', [
Required(),
Length(min=3, max=32),
_username_validator,
Unique(User, User.username, 'Username not availiable')
])
email = TextField('Email address', [
Email(),
Required(),
Length(min=5, max=128),
Unique(User, User.email, 'Email already in use by another account')
])
password = PasswordField('Password', [
Required(),
EqualTo('password_confirm', message='Passwords must match'),
Length(min=6, max=1024,
message='Password must be at least %(min)d characters long.')
])
password_confirm = PasswordField('Password (confirm)')
if app.config['USE_RECAPTCHA']:
recaptcha = RecaptchaField()
class ProfileForm(FlaskForm):
email = TextField('New email address', [
Email(),
Optional(),
Length(min=5, max=128),
Unique(User, User.email, 'Email is taken')
])
current_password = PasswordField('Current password', [Optional()])
new_password = PasswordField('New password (confirm)', [
Optional(),
EqualTo('password_confirm', message='Passwords must match'),
Length(min=6, max=1024,
message='Password must be at least %(min)d characters long.')
])
password_confirm = PasswordField('Repeat Password')
# Classes for a SelectField that can be set to disable options (id, name, disabled)
# TODO: Move to another file for cleaner look
class DisabledSelectWidget(SelectWidget):
def __call__(self, field, **kwargs):
kwargs.setdefault('id', field.id)
if self.multiple:
kwargs['multiple'] = True
html = ['<select %s>' % html_params(name=field.name, **kwargs)]
for val, label, selected, disabled in field.iter_choices():
extra = disabled and {'disabled': ''} or {}
html.append(self.render_option(val, label, selected, **extra))
html.append('</select>')
return HTMLString(''.join(html))
class DisabledSelectField(SelectField):
widget = DisabledSelectWidget()
def iter_choices(self):
for choice_tuple in self.choices:
value, label = choice_tuple[:2]
disabled = len(choice_tuple) == 3 and choice_tuple[2] or False
yield (value, label, self.coerce(value) == self.data, disabled)
def pre_validate(self, form):
for v in self.choices:
if self.data == v[0]:
break
else:
raise ValueError(self.gettext('Not a valid choice'))
class EditForm(FlaskForm):
display_name = TextField('Display name', [
Length(min=3, max=255,
message='Torrent name must be at least %(min)d characters and %(max)d at most.')
])
category = DisabledSelectField('Category')
def validate_category(form, field):
cat_match = re.match(r'^(\d+)_(\d+)$', field.data)
if not cat_match:
raise ValidationError('You must select a category')
main_cat_id = int(cat_match.group(1))
sub_cat_id = int(cat_match.group(2))
cat = models.SubCategory.by_category_ids(main_cat_id, sub_cat_id)
if not cat:
raise ValidationError('You must select a proper category')
field.parsed_data = cat
is_hidden = BooleanField('Hidden')
is_deleted = BooleanField('Deleted')
is_remake = BooleanField('Remake')
is_anonymous = BooleanField('Anonymous')
is_complete = BooleanField('Complete')
information = TextField('Information', [
Length(max=255, message='Information must be at most %(max)d characters long.')
])
description = TextAreaField('Description (markdown supported)', [
Length(max=10 * 1024, message='Description must be at most %(max)d characters long.')
])
class UploadForm(FlaskForm):
class Meta:
csrf = False
torrent_file = FileField('Torrent file', [
FileRequired()
])
display_name = TextField('Display name (optional)', [
Optional(),
Length(min=3, max=255,
message='Torrent name must be at least %(min)d characters long and %(max)d at most.')
])
# category = SelectField('Category')
category = DisabledSelectField('Category')
def validate_category(form, field):
cat_match = re.match(r'^(\d+)_(\d+)$', field.data)
if not cat_match:
raise ValidationError('You must select a category')
main_cat_id = int(cat_match.group(1))
sub_cat_id = int(cat_match.group(2))
cat = models.SubCategory.by_category_ids(main_cat_id, sub_cat_id)
if not cat:
raise ValidationError('You must select a proper category')
field.parsed_data = cat
is_hidden = BooleanField('Hidden')
is_remake = BooleanField('Remake')
is_anonymous = BooleanField('Anonymous')
is_complete = BooleanField('Complete')
information = TextField('Information', [
Length(max=255, message='Information must be at most %(max)d characters long.')
])
description = TextAreaField('Description (markdown supported)', [
Length(max=10 * 1024, message='Description must be at most %(max)d characters long.')
])
def validate_torrent_file(form, field):
# Decode and ensure data is bencoded data
try:
torrent_dict = bencode.decode(field.data)
#field.data.close()
except (bencode.MalformedBencodeException, UnicodeError):
raise ValidationError('Malformed torrent file')
# Uncomment for debug print of the torrent
# _debug_print_torrent_metadata(torrent_dict)
try:
_validate_torrent_metadata(torrent_dict)
except AssertionError as e:
raise ValidationError('Malformed torrent metadata ({})'.format(e.args[0]))
try:
_validate_trackers(torrent_dict)
except AssertionError as e:
raise ValidationError('Malformed torrent trackers ({})'.format(e.args[0]))
if app.config.get('ENFORCE_MAIN_ANNOUNCE_URL'):
main_announce_url = app.config.get('MAIN_ANNOUNCE_URL')
if not main_announce_url:
raise Exception('Config MAIN_ANNOUNCE_URL not set!')
announce = torrent_dict.get('announce', b'').decode('utf-8')
if announce != main_announce_url:
raise ValidationError('Please set {} as the first tracker in the torrent'.format(main_announce_url))
# Note! bencode will sort dict keys, as per the spec
# This may result in a different hash if the uploaded torrent does not match the
# spec, but it's their own fault for using broken software! Right?
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
existing_torrent = models.Torrent.by_info_hash(info_hash)
if existing_torrent:
raise ValidationError('That torrent already exists (#{})'.format(existing_torrent.id))
# Torrent is legit, pass original filename and dict along
field.parsed_data = TorrentFileData(filename=os.path.basename(field.data.filename),
torrent_dict=torrent_dict,
info_hash=info_hash,
bencoded_info_dict=bencoded_info_dict)
class TorrentFileData(object):
"""Quick and dirty class to pass data from the validator"""
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
# https://wiki.theory.org/BitTorrentSpecification#Metainfo_File_Structure
def _validate_trackers(torrent_dict):
announce = torrent_dict.get('announce')
_validate_bytes(announce)
announce_list = torrent_dict.get('announce-list')
if announce_list is not None:
_validate_list(announce_list, 'announce-list')
for announce in announce_list:
_validate_list(announce, 'announce-list item')
_validate_bytes(announce[0], 'announce-list item item')
def _validate_torrent_metadata(torrent_dict):
''' Validates a torrent metadata dict, raising AssertionError on errors '''
assert isinstance(torrent_dict, dict), 'torrent metadata is not a dict'
info_dict = torrent_dict.get('info')
assert info_dict is not None, 'no info_dict in torrent'
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()
name = info_dict.get('name')
_validate_bytes(name, 'name', test_decode=encoding)
piece_length = info_dict.get('piece length')
_validate_number(piece_length, 'piece length', check_positive=True)
pieces = info_dict.get('pieces')
_validate_bytes(pieces, 'pieces')
assert len(pieces) % 20 == 0, 'pieces length is not a multiple of 20'
files = info_dict.get('files')
if files is not None:
_validate_list(files, 'filelist')
for file_dict in files:
file_length = file_dict.get('length')
_validate_number(file_length, 'file length', check_positive_or_zero=True)
path_list = file_dict.get('path')
_validate_list(path_list, 'path')
for path_part in path_list:
_validate_bytes(path_part, 'path part', 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):
assert isinstance(value, bytes), name + ' is not bytes'
assert len(value) > 0, name + ' is empty'
if test_decode:
try:
return value.decode(test_decode)
except UnicodeError:
raise AssertionError(name + ' could not be decoded from ' + repr(test_decode))
def _validate_number(value, name='value', check_positive=False, check_positive_or_zero=False):
assert isinstance(value, int), name + ' is not an int'
if check_positive_or_zero:
assert value >= 0, name + ' is less than 0'
elif check_positive:
assert value > 0, name + ' is not positive'
def _validate_list(value, name='value', check_empty=False):
assert isinstance(value, list), name + ' is not a list'
if check_empty:
assert len(value) > 0, name + ' is empty'
def _debug_print_torrent_metadata(torrent_dict):
from pprint import pprint
# Temporarily remove 'pieces' from infodict for clean debug prints
info_dict = torrent_dict.get('info', {})
orig_pieces = info_dict.get('pieces')
info_dict['pieces'] = '<piece data>'
pprint(torrent_dict)
info_dict['pieces'] = orig_pieces

332
nyaa/models.py Normal file
View file

@ -0,0 +1,332 @@
from enum import Enum, IntEnum
from datetime import datetime
from nyaa import app, db
from nyaa.torrents import create_magnet
from sqlalchemy import func, ForeignKeyConstraint, Index
from sqlalchemy_utils import ChoiceType, EmailType, PasswordType
from werkzeug.security import generate_password_hash, check_password_hash
from sqlalchemy_fulltext import FullText
if app.config['USE_MYSQL']:
from sqlalchemy.dialects import mysql
BinaryType = mysql.BINARY
DescriptionTextType = mysql.TEXT
MediumBlobType = mysql.MEDIUMBLOB
COL_UTF8_GENERAL_CI = 'utf8_general_ci'
COL_UTF8MB4_BIN = 'utf8mb4_bin'
COL_ASCII_GENERAL_CI = 'ascii_general_ci'
else:
BinaryType = db.Binary
DescriptionTextType = db.String
MediumBlobType = db.BLOB
COL_UTF8_GENERAL_CI = 'NOCASE'
COL_UTF8MB4_BIN = None
COL_ASCII_GENERAL_CI = 'NOCASE'
class TorrentFlags(IntEnum):
NONE = 0
ANONYMOUS = 1
HIDDEN = 2
TRUSTED = 4
REMAKE = 8
COMPLETE = 16
DELETED = 32
class Torrent(db.Model):
__tablename__ = app.config['TABLE_PREFIX'] + 'torrents'
id = db.Column(db.Integer, primary_key=True)
info_hash = db.Column(BinaryType(length=20), unique=True, nullable=False, index=True)
display_name = db.Column(
db.String(length=255, collation=COL_UTF8_GENERAL_CI), nullable=False, index=True)
torrent_name = db.Column(db.String(length=255), nullable=False)
information = db.Column(db.String(length=255), nullable=False)
description = db.Column(DescriptionTextType(collation=COL_UTF8MB4_BIN), nullable=False)
filesize = db.Column(db.BIGINT, default=0, nullable=False, index=True)
encoding = db.Column(db.String(length=32), nullable=False)
flags = db.Column(db.Integer, default=0, nullable=False, index=True)
uploader_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
has_torrent = db.Column(db.Boolean, nullable=False, default=False)
created_time = db.Column(db.DateTime(timezone=False), default=datetime.utcnow, nullable=False)
updated_time = db.Column(db.DateTime(timezone=False),
default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
main_category_id = db.Column(db.Integer, db.ForeignKey(
app.config['TABLE_PREFIX'] + 'main_categories.id'), nullable=False)
sub_category_id = db.Column(db.Integer, nullable=False)
redirect = db.Column(db.Integer, db.ForeignKey(
app.config['TABLE_PREFIX'] + 'torrents.id'), nullable=True)
__table_args__ = (
Index('uploader_flag_idx', 'uploader_id', 'flags'),
ForeignKeyConstraint(
['main_category_id', 'sub_category_id'],
[app.config['TABLE_PREFIX'] + 'sub_categories.main_category_id',
app.config['TABLE_PREFIX'] + 'sub_categories.id']
), {}
)
user = db.relationship('User', uselist=False, back_populates='torrents')
main_category = db.relationship('MainCategory', uselist=False,
back_populates='torrents', lazy="joined")
sub_category = db.relationship('SubCategory', uselist=False, backref='torrents', lazy="joined",
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')
def __repr__(self):
return '<{0} #{1.id} \'{1.display_name}\' {1.filesize}b>'.format(type(self).__name__, self)
@property
def magnet_uri(self):
return create_magnet(self)
@property
def anonymous(self):
return self.flags & TorrentFlags.ANONYMOUS
@anonymous.setter
def anonymous(self, value):
self.flags = (self.flags & ~TorrentFlags.ANONYMOUS) | (value and TorrentFlags.ANONYMOUS)
@property
def hidden(self):
return self.flags & TorrentFlags.HIDDEN
@hidden.setter
def hidden(self, value):
self.flags = (self.flags & ~TorrentFlags.HIDDEN) | (value and TorrentFlags.HIDDEN)
@property
def deleted(self):
return self.flags & TorrentFlags.DELETED
@deleted.setter
def deleted(self, value):
self.flags = (self.flags & ~TorrentFlags.DELETED) | (value and TorrentFlags.DELETED)
@property
def trusted(self):
return self.flags & TorrentFlags.TRUSTED
@trusted.setter
def trusted(self, value):
self.flags = (self.flags & ~TorrentFlags.TRUSTED) | (value and TorrentFlags.TRUSTED)
@property
def remake(self):
return self.flags & TorrentFlags.REMAKE
@remake.setter
def remake(self, value):
self.flags = (self.flags & ~TorrentFlags.REMAKE) | (value and TorrentFlags.REMAKE)
@property
def complete(self):
return self.flags & TorrentFlags.COMPLETE
@complete.setter
def complete(self, value):
self.flags = (self.flags & ~TorrentFlags.COMPLETE) | (value and TorrentFlags.COMPLETE)
@classmethod
def by_id(cls, id):
return cls.query.get(id)
@classmethod
def by_info_hash(cls, info_hash):
return cls.query.filter_by(info_hash=info_hash).first()
class TorrentNameSearch(FullText, Torrent):
__fulltext_columns__ = ('display_name',)
class TorrentFilelist(db.Model):
__tablename__ = app.config['TABLE_PREFIX'] + 'torrents_filelist'
__table_args__ = {'mysql_row_format': 'COMPRESSED'}
torrent_id = db.Column(db.Integer, db.ForeignKey(
app.config['TABLE_PREFIX'] + 'torrents.id', ondelete="CASCADE"), primary_key=True)
filelist_blob = db.Column(MediumBlobType, nullable=True)
torrent = db.relationship('Torrent', uselist=False, back_populates='filelist')
class TorrentInfo(db.Model):
__tablename__ = app.config['TABLE_PREFIX'] + 'torrents_info'
__table_args__ = {'mysql_row_format': 'COMPRESSED'}
torrent_id = db.Column(db.Integer, db.ForeignKey(
app.config['TABLE_PREFIX'] + 'torrents.id', ondelete="CASCADE"), primary_key=True)
info_dict = db.Column(MediumBlobType, nullable=True)
torrent = db.relationship('Torrent', uselist=False, back_populates='info')
class Statistic(db.Model):
__tablename__ = app.config['TABLE_PREFIX'] + 'statistics'
torrent_id = db.Column(db.Integer, db.ForeignKey(
app.config['TABLE_PREFIX'] + 'torrents.id', ondelete="CASCADE"), primary_key=True)
seed_count = db.Column(db.Integer, default=0, nullable=False, index=True)
leech_count = db.Column(db.Integer, default=0, nullable=False, index=True)
download_count = db.Column(db.Integer, default=0, nullable=False, index=True)
last_updated = db.Column(db.DateTime(timezone=False))
torrent = db.relationship('Torrent', uselist=False, back_populates='stats')
class Trackers(db.Model):
__tablename__ = 'trackers'
id = db.Column(db.Integer, primary_key=True)
uri = db.Column(db.String(length=255, collation=COL_UTF8_GENERAL_CI), nullable=False, unique=True)
disabled = db.Column(db.Boolean, nullable=False, default=False)
@classmethod
def by_uri(cls, uri):
return cls.query.filter_by(uri=uri).first()
class TorrentTrackers(db.Model):
__tablename__ = app.config['TABLE_PREFIX'] + 'torrent_trackers'
torrent_id = db.Column(db.Integer, db.ForeignKey(app.config['TABLE_PREFIX'] + 'torrents.id', ondelete="CASCADE"), primary_key=True)
tracker_id = db.Column(db.Integer, db.ForeignKey('trackers.id', ondelete="CASCADE"), primary_key=True)
order = db.Column(db.Integer, nullable=False, index=True)
tracker = db.relationship('Trackers', uselist=False, lazy='joined')
@classmethod
def by_torrent_id(cls, torrent_id):
return cls.query.filter_by(torrent_id=torrent_id).order_by(cls.order.desc())
class MainCategory(db.Model):
__tablename__ = app.config['TABLE_PREFIX'] + 'main_categories'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(length=64), nullable=False)
sub_categories = db.relationship('SubCategory', back_populates='main_category')
torrents = db.relationship('Torrent', back_populates='main_category')
def get_category_ids(self):
return (self.id, 0)
@property
def id_as_string(self):
return '_'.join(str(x) for x in self.get_category_ids())
@classmethod
def by_id(cls, id):
return cls.query.get(id)
class SubCategory(db.Model):
__tablename__ = app.config['TABLE_PREFIX'] + 'sub_categories'
id = db.Column(db.Integer, primary_key=True)
main_category_id = db.Column(db.Integer, db.ForeignKey(
app.config['TABLE_PREFIX'] + 'main_categories.id'), primary_key=True)
name = db.Column(db.String(length=64), nullable=False)
main_category = db.relationship('MainCategory', uselist=False, back_populates='sub_categories')
# torrents = db.relationship('Torrent', back_populates='sub_category'),
# primaryjoin="and_(Torrent.sub_category_id == foreign(SubCategory.id), "
# "Torrent.main_category_id == SubCategory.main_category_id)")
def get_category_ids(self):
return (self.main_category_id, self.id)
@property
def id_as_string(self):
return '_'.join(str(x) for x in self.get_category_ids())
@classmethod
def by_id(cls, id):
return cls.query.get(id)
@classmethod
def by_category_ids(cls, main_cat_id, sub_cat_id):
return cls.query.filter(cls.id == sub_cat_id, cls.main_category_id == main_cat_id).first()
class UserLevelType(IntEnum):
REGULAR = 0
TRUSTED = 1
ADMIN = 2
SUPERADMIN = 3
class UserStatusType(Enum):
INACTIVE = 0
ACTIVE = 1
BANNED = 2
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(length=32, collation=COL_ASCII_GENERAL_CI),
unique=True, nullable=False)
email = db.Column(EmailType(length=255, collation=COL_ASCII_GENERAL_CI),
unique=True, nullable=True)
password_hash = db.Column(PasswordType(max_length=255, schemes=['argon2']), nullable=False)
status = db.Column(ChoiceType(UserStatusType, impl=db.Integer()), nullable=False)
level = db.Column(ChoiceType(UserLevelType, impl=db.Integer()), nullable=False)
created_time = db.Column(db.DateTime(timezone=False), default=datetime.utcnow)
last_login_date = db.Column(db.DateTime(timezone=False), default=None, nullable=True)
last_login_ip = db.Column(db.Binary(length=16), default=None, nullable=True)
torrents = db.relationship('Torrent', back_populates='user', lazy="dynamic")
# session = db.relationship('Session', uselist=False, back_populates='user')
def __init__(self, username, email, password):
self.username = username
self.email = email
self.password_hash = password
self.status = UserStatusType.INACTIVE
self.level = UserLevelType.REGULAR
def __repr__(self):
return '<User %r>' % self.username
@classmethod
def by_id(cls, id):
return cls.query.get(id)
@classmethod
def by_username(cls, username):
user = cls.query.filter_by(username=username).first()
return user
@classmethod
def by_email(cls, email):
user = cls.query.filter_by(email=email).first()
return user
@property
def is_admin(self):
return self.level is UserLevelType.ADMIN or self.level is UserLevelType.SUPERADMIN
# class Session(db.Model):
# __tablename__ = 'sessions'
#
# session_id = db.Column(db.Integer, primary_key=True)
# user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
# login_ip = db.Column(db.Binary(length=16), nullable=True)
# login_date = db.Column(db.DateTime(timezone=False), nullable=True)
#
# user = db.relationship('User', back_populates='session')

767
nyaa/routes.py Normal file
View file

@ -0,0 +1,767 @@
import flask
from werkzeug.datastructures import CombinedMultiDict
from nyaa import app, db
from nyaa import models, forms
from nyaa import bencode, utils
from nyaa import torrents
from nyaa import api_handler
import config
import json
import re
from datetime import datetime
from collections import OrderedDict
import ipaddress
import os.path
import base64
from urllib.parse import quote
import sqlalchemy_fulltext.modes as FullTextMode
from sqlalchemy_fulltext import FullTextSearch
import shlex
from werkzeug import url_encode, secure_filename
from orderedset import OrderedSet
from itsdangerous import URLSafeSerializer, BadSignature
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formatdate
DEBUG_API = False
def redirect_url():
url = flask.request.args.get('next') or \
flask.request.referrer or \
'/'
if url == flask.request.url:
return '/'
return url
@app.template_global()
def modify_query(**new_values):
args = flask.request.args.copy()
for key, value in new_values.items():
args[key] = value
return '{}?{}'.format(flask.request.path, url_encode(args))
def search(term='', user=None, sort='id', order='desc', category='0_0', filter='0', page=1, rss=False, admin=False):
sort_keys = {
'id': models.Torrent.id,
'size': models.Torrent.filesize,
'name': models.Torrent.display_name,
'seeders': models.Statistic.seed_count,
'leechers': models.Statistic.leech_count,
'downloads': models.Statistic.download_count
}
sort_ = sort.lower()
if sort_ not in sort_keys:
flask.abort(400)
sort = sort_keys[sort]
order_keys = {
'desc': 'desc',
'asc': 'asc'
}
order_ = order.lower()
if order_ not in order_keys:
flask.abort(400)
filter_keys = {
'0': None,
'1': (models.TorrentFlags.REMAKE, False),
'2': (models.TorrentFlags.TRUSTED, True),
'3': (models.TorrentFlags.COMPLETE, True)
}
filter_ = filter.lower()
if filter_ not in filter_keys:
flask.abort(400)
filter = filter_keys[filter_]
if user:
user = models.User.by_id(user)
if not user:
flask.abort(404)
user = user.id
main_category = None
sub_category = None
main_cat_id = 0
sub_cat_id = 0
if category:
cat_match = re.match(r'^(\d+)_(\d+)$', category)
if not cat_match:
flask.abort(400)
main_cat_id = int(cat_match.group(1))
sub_cat_id = int(cat_match.group(2))
if main_cat_id > 0:
if sub_cat_id > 0:
sub_category = models.SubCategory.by_category_ids(main_cat_id, sub_cat_id)
else:
main_category = models.MainCategory.by_id(main_cat_id)
if not category:
flask.abort(400)
# Force sort by id desc if rss
if rss:
sort = sort_keys['id']
order = 'desc'
page = 1
same_user = False
if flask.g.user:
same_user = flask.g.user.id == user
if term:
query = db.session.query(models.TorrentNameSearch)
else:
query = models.Torrent.query
# Filter by user
if user:
query = query.filter(models.Torrent.uploader_id == user)
# If admin, show everything
if not admin:
# If user is not logged in or the accessed feed doesn't belong to user,
# hide anonymous torrents belonging to the queried user
if not same_user:
query = query.filter(models.Torrent.flags.op('&')(
int(models.TorrentFlags.ANONYMOUS | models.TorrentFlags.DELETED)).is_(False))
if main_category:
query = query.filter(models.Torrent.main_category_id == main_cat_id)
elif sub_category:
query = query.filter((models.Torrent.main_category_id == main_cat_id) &
(models.Torrent.sub_category_id == sub_cat_id))
if filter:
query = query.filter(models.Torrent.flags.op('&')(int(filter[0])).is_(filter[1]))
# If admin, show everything
if not admin:
query = query.filter(models.Torrent.flags.op('&')(
int(models.TorrentFlags.HIDDEN | models.TorrentFlags.DELETED)).is_(False))
if term:
for item in shlex.split(term, posix=False):
if len(item) >= 2:
query = query.filter(FullTextSearch(
item, models.TorrentNameSearch, FullTextMode.NATURAL))
# Sort and order
if sort.class_ != models.Torrent:
query = query.join(sort.class_)
query = query.order_by(getattr(sort, order)())
if rss:
query = query.limit(app.config['RESULTS_PER_PAGE'])
else:
query = query.paginate_faste(page, per_page=app.config['RESULTS_PER_PAGE'], step=5)
return query
@app.errorhandler(404)
def not_found(error):
return flask.render_template('404.html'), 404
@app.before_request
def before_request():
flask.g.user = None
if 'user_id' in flask.session:
user = models.User.by_id(flask.session['user_id'])
if not user:
return logout()
flask.g.user = user
flask.session.permanent = True
flask.session.modified = True
if flask.g.user.status == models.UserStatusType.BANNED:
return 'You are banned.', 403
def _generate_query_string(term, category, filter, user):
params = {}
if term:
params['q'] = str(term)
if category:
params['c'] = str(category)
if filter:
params['f'] = str(filter)
if user:
params['u'] = str(user)
return params
@app.route('/rss', defaults={'rss': True})
@app.route('/', defaults={'rss': False})
def home(rss):
if flask.request.args.get('page') == 'rss':
rss = True
term = flask.request.args.get('q')
sort = flask.request.args.get('s')
order = flask.request.args.get('o')
category = flask.request.args.get('c')
filter = flask.request.args.get('f')
user_name = flask.request.args.get('u')
page = flask.request.args.get('p')
if page:
page = int(page)
user_id = None
if user_name:
user = models.User.by_username(user_name)
if not user:
flask.abort(404)
user_id = user.id
query_args = {
'term': term or '',
'user': user_id,
'sort': sort or 'id',
'order': order or 'desc',
'category': category or '0_0',
'filter': filter or '0',
'page': page or 1,
'rss': rss
}
# God mode
if flask.g.user and flask.g.user.is_admin:
query_args['admin'] = True
query = search(**query_args)
if rss:
return render_rss('/', query)
else:
rss_query_string = _generate_query_string(term, category, filter, user_name)
return flask.render_template('home.html',
torrent_query=query,
search=query_args,
rss_filter=rss_query_string)
@app.route('/user/<user_name>')
def view_user(user_name):
user = models.User.by_username(user_name)
if not user:
flask.abort(404)
term = flask.request.args.get('q')
sort = flask.request.args.get('s')
order = flask.request.args.get('o')
category = flask.request.args.get('c')
filter = flask.request.args.get('f')
page = flask.request.args.get('p')
if page:
page = int(page)
query_args = {
'term': term or '',
'user': user.id,
'sort': sort or 'id',
'order': order or 'desc',
'category': category or '0_0',
'filter': filter or '0',
'page': page or 1,
'rss': False
}
# God mode
if flask.g.user and flask.g.user.is_admin:
query_args['admin'] = True
query = search(**query_args)
rss_query_string = _generate_query_string(term, category, filter, user_name)
return flask.render_template('user.html',
torrent_query=query,
search=query_args,
user=user,
user_page=True,
rss_filter=rss_query_string)
@app.template_filter('rfc822')
def _jinja2_filter_rfc822(date, fmt=None):
return formatdate(float(date.strftime('%s')))
def render_rss(label, query):
rss_xml = flask.render_template('rss.xml',
term=label,
site_url=flask.request.url_root,
query=query)
response = flask.make_response(rss_xml)
response.headers['Content-Type'] = 'application/xml'
return response
#@app.route('/about', methods=['GET'])
# def about():
# return flask.render_template('about.html')
@app.route('/login', methods=['GET', 'POST'])
def login():
if flask.g.user:
return flask.redirect(redirect_url())
form = forms.LoginForm(flask.request.form)
if flask.request.method == 'POST' and form.validate():
username = form.username.data.strip()
password = form.password.data
user = models.User.by_username(username)
if not user:
user = models.User.by_email(username)
if not user or password != user.password_hash or user.status == models.UserStatusType.INACTIVE:
flask.flash(flask.Markup(
'<strong>Login failed!</strong> Incorrect username or password.'), 'danger')
return flask.redirect(flask.url_for('login'))
user.last_login_date = datetime.utcnow()
user.last_login_ip = ipaddress.ip_address(flask.request.remote_addr).packed
db.session.add(user)
db.session.commit()
flask.g.user = user
flask.session['user_id'] = user.id
return flask.redirect(redirect_url())
return flask.render_template('login.html', form=form)
@app.route('/logout')
def logout():
flask.g.user = None
flask.session.permanent = False
flask.session.modified = False
response = flask.make_response(flask.redirect(redirect_url()))
response.set_cookie(app.session_cookie_name, expires=0)
return response
@app.route('/register', methods=['GET', 'POST'])
def register():
if flask.g.user:
return flask.redirect(redirect_url())
form = forms.RegisterForm(flask.request.form)
if flask.request.method == 'POST' and form.validate():
user = models.User(username=form.username.data.strip(),
email=form.email.data.strip(), password=form.password.data)
user.last_login_ip = ipaddress.ip_address(flask.request.remote_addr).packed
db.session.add(user)
db.session.commit()
if config.USE_EMAIL_VERIFICATION: # force verification, enable email
activ_link = get_activation_link(user)
send_verification_email(user.email, activ_link)
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
return flask.redirect(redirect_url())
return flask.render_template('register.html', form=form)
@app.route('/profile', methods=['GET', 'POST'])
def profile():
if not flask.g.user:
return flask.redirect('/') # so we dont get stuck in infinite loop when signing out
form = forms.ProfileForm(flask.request.form)
if flask.request.method == 'POST' and form.validate():
user = flask.g.user
new_email = form.email.data
new_password = form.new_password.data
if new_email:
user.email = form.email.data
if new_password:
if form.current_password.data != user.password_hash:
flask.flash(flask.Markup(
'<strong>Password change failed!</strong> Incorrect password.'), 'danger')
return flask.redirect('/profile')
user.password_hash = form.new_password.data
db.session.add(user)
db.session.commit()
flask.g.user = user
flask.session['user_id'] = user.id
return flask.render_template('profile.html', form=form)
@app.route('/user/activate/<payload>')
def activate_user(payload):
s = get_serializer()
try:
user_id = s.loads(payload)
except BadSignature:
flask.abort(404)
user = models.User.by_id(user_id)
if not user:
flask.abort(404)
user.status = models.UserStatusType.ACTIVE
db.session.add(user)
db.session.commit()
return flask.redirect('/login')
@utils.cached_function
def _create_upload_category_choices():
''' Turns categories in the database into a list of (id, name)s '''
choices = [('', '[Select a category]')]
for main_cat in models.MainCategory.query.order_by(models.MainCategory.id):
choices.append((main_cat.id_as_string, main_cat.name, True))
for sub_cat in main_cat.sub_categories:
choices.append((sub_cat.id_as_string, ' - ' + sub_cat.name))
return choices
def _replace_utf8_values(dict_or_list):
''' Will replace 'property' with 'property.utf-8' and remove latter if it exists.
Thanks, bitcomet! :/ '''
did_change = False
if isinstance(dict_or_list, dict):
for key in [key for key in dict_or_list.keys() if key.endswith('.utf-8')]:
dict_or_list[key.replace('.utf-8', '')] = dict_or_list.pop(key)
did_change = True
for value in dict_or_list.values():
did_change = _replace_utf8_values(value) or did_change
elif isinstance(dict_or_list, list):
for item in dict_or_list:
did_change = _replace_utf8_values(item) or did_change
return did_change
@app.route('/upload', methods=['GET', 'POST'])
def upload():
current_user = flask.g.user
form = forms.UploadForm(CombinedMultiDict((flask.request.files, flask.request.form)))
form.category.choices = _create_upload_category_choices()
if flask.request.method == 'POST' and form.validate():
torrent_data = form.torrent_file.parsed_data
# 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 = _replace_utf8_values(torrent_data.torrent_dict)
# Use uploader-given name or grab it from the torrent
display_name = form.display_name.data.strip() or info_dict['name'].decode('utf8').strip()
information = (form.information.data or '').strip()
description = (form.description.data or '').strip()
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')
torrent = models.Torrent(info_hash=torrent_data.info_hash,
display_name=display_name,
torrent_name=torrent_data.filename,
information=information,
description=description,
encoding=torrent_encoding,
filesize=torrent_filesize,
user=current_user)
# Store bencoded info_dict
torrent.info = models.TorrentInfo(info_dict=torrent_data.bencoded_info_dict)
torrent.stats = models.Statistic()
torrent.has_torrent = True
# Fields with default value will be None before first commit, so set .flags
torrent.flags = 0
torrent.anonymous = form.is_anonymous.data if current_user else True
torrent.hidden = form.is_hidden.data
torrent.remake = form.is_remake.data
torrent.complete = form.is_complete.data
# Copy trusted status from user if possible
torrent.trusted = (current_user.level >=
models.UserLevelType.TRUSTED) if current_user else False
# Set category ids
torrent.main_category_id, torrent.sub_category_id = form.category.parsed_data.get_category_ids()
# print('Main cat id: {0}, Sub cat id: {1}'.format(
# torrent.main_category_id, torrent.sub_category_id))
# 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, {})
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_trackers.add(tracker)
db.session.flush()
# 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()
torrent_file = form.torrent_file.data
if app.config.get('BACKUP_TORRENT_FOLDER'):
torrent_file.seek(0, 0)
torrent_path = os.path.join(app.config['BACKUP_TORRENT_FOLDER'], '{}.{}'.format(torrent.id, secure_filename(torrent_file.filename)))
torrent_file.save(torrent_path)
torrent_file.close()
return flask.redirect('/view/' + str(torrent.id))
else:
return flask.render_template('upload.html', form=form, user=flask.g.user)
@app.route('/view/<int:torrent_id>')
def view_torrent(torrent_id):
torrent = models.Torrent.by_id(torrent_id)
if not torrent:
flask.abort(404)
if torrent.deleted and (not flask.g.user or not flask.g.user.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
files = None
if torrent.filelist:
files = utils.flattenDict(json.loads(torrent.filelist.filelist_blob.decode('utf-8')))
return flask.render_template('view.html', torrent=torrent,
files=files,
can_edit=can_edit)
@app.route('/view/<int:torrent_id>/edit', methods=['GET', 'POST'])
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)
if not torrent:
flask.abort(404)
if torrent.deleted and (not flask.g.user or not flask.g.user.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):
flask.abort(403)
if flask.request.method == 'POST' and form.validate():
# Form has been sent, edit torrent with data.
torrent.main_category_id, torrent.sub_category_id = form.category.parsed_data.get_category_ids()
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
db.session.commit()
return flask.redirect('/view/' + str(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:
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)
@app.route('/view/<int:torrent_id>/magnet')
def redirect_magnet(torrent_id):
torrent = models.Torrent.by_id(torrent_id)
if not torrent:
flask.abort(404)
return flask.redirect(torrents.create_magnet(torrent))
@app.route('/view/<int:torrent_id>/torrent')
def download_torrent(torrent_id):
torrent = models.Torrent.by_id(torrent_id)
if not torrent:
flask.abort(404)
resp = flask.Response(_get_cached_torrent_file(torrent))
resp.headers['Content-Type'] = 'application/x-bittorrent'
resp.headers['Content-Disposition'] = 'inline; filename*=UTF-8\'\'{}'.format(
quote(torrent.torrent_name.encode('utf-8')))
return resp
def _get_cached_torrent_file(torrent):
# Note: obviously temporary
cached_torrent = os.path.join(app.config['BASE_DIR'],
'torrent_cache', str(torrent.id) + '.torrent')
if not os.path.exists(cached_torrent):
with open(cached_torrent, 'wb') as out_file:
out_file.write(torrents.create_bencoded_torrent(torrent))
return open(cached_torrent, 'rb')
def get_serializer(secret_key=None):
if secret_key is None:
secret_key = app.secret_key
return URLSafeSerializer(secret_key)
def get_activation_link(user):
s = get_serializer()
payload = s.dumps(user.id)
return flask.url_for('activate_user', payload=payload, _external=True)
def send_verification_email(to_address, activ_link):
''' this is until we have our own mail server, obviously. This can be greatly cut down if on same machine.
probably can get rid of all but msg formatting/building, init line and sendmail line if local SMTP server '''
msg_body = 'Please click on: ' + activ_link + ' to activate your account.\n\n\nUnsubscribe:'
msg = MIMEMultipart()
msg['Subject'] = 'Verification Link'
msg['From'] = config.MAIL_FROM_ADDRESS
msg['To'] = to_address
msg.attach(MIMEText(msg_body, 'plain'))
server = smtplib.SMTP(config.SMTP_SERVER, config.SMTP_PORT)
server.set_debuglevel(1)
server.ehlo()
server.starttls()
server.ehlo()
server.login(config.SMTP_USERNAME, config.SMTP_PASSWORD)
server.sendmail(config.SMTP_USERNAME, to_address, msg.as_string())
server.quit()
#################################### STATIC PAGES ####################################
@app.route('/rules', methods=['GET'])
def site_rules():
return flask.render_template('rules.html')
@app.route('/help', methods=['GET'])
def site_help():
return flask.render_template('help.html')
#################################### API ROUTES ####################################
# DISABLED FOR NOW
@app.route('/api/upload', methods = ['POST'])
def api_upload():
api_response = api_handler.api_upload(flask.request)
return api_response

BIN
nyaa/static/css/bootstrap-dark.min.css vendored Normal file

Binary file not shown.

BIN
nyaa/static/css/bootstrap-select.min.css vendored Normal file

Binary file not shown.

BIN
nyaa/static/css/bootstrap-theme.min.css vendored Normal file

Binary file not shown.

BIN
nyaa/static/css/bootstrap.min.css vendored Normal file

Binary file not shown.

BIN
nyaa/static/css/font-awesome.min.css vendored Normal file

Binary file not shown.

85
nyaa/static/css/main.css Normal file
View file

@ -0,0 +1,85 @@
.panel-heading-collapse a:after {
font-family:'Glyphicons Halflings';
content:"\e114";
float: right;
color: grey;
}
.panel-heading-collapse a.collapsed:after {
content:"\e080";
}
.torrent-list > tbody > tr > td {
vertical-align: middle;
}
table.torrent-list thead th {
position: relative;
background-image: none !important;
}
table.torrent-list thead th a {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
text-decoration: none;
z-index: 10;
/* IE Workaround */
background-color: white;
opacity: 0;
filter: alpha(opacity=1);
}
table.torrent-list thead th.sorting:after,
table.torrent-list thead th.sorting_asc:after,
table.torrent-list thead th.sorting_desc:after {
position: absolute;
top: 12px;
right: 8px;
display: block;
font-family: FontAwesome;
}
table.torrent-list thead th.sorting:after {
content: "\f0dc";
color: #808080;
font-size: 0.85em;
}
table.torrent-list thead th.sorting_asc:after {
content: "\f0de";
}
table.torrent-list thead th.sorting_desc:after {
content: "\f0dd";
}
#torrent-description img {
max-width: 100%;
}
.table > tbody > tr.deleted > td, .table > tbody > tr.deleted > th, .table > tbody > tr > td.deleted, .table > tbody > tr > th.deleted, .table > tfoot > tr.deleted > td, .table > tfoot > tr.deleted > th, .table > tfoot > tr > td.deleted, .table > tfoot > tr > th.deleted, .table > thead > tr.deleted > td, .table > thead > tr.deleted > th, .table > thead > tr > td.deleted, .table > thead > tr > th.deleted {
background-color:#9e9e9e;
}
.table-hover > tbody > tr.deleted:hover > td, .table-hover > tbody > tr.deleted:hover > th, .table-hover > tbody > tr:hover > .deleted, .table-hover > tbody > tr > td.deleted:hover, .table-hover > tbody > tr > th.deleted:hover {
background-color:#bdbdbd;
}
.panel-deleted {
border-color:#757575;
}
.panel-deleted > .panel-heading {
color:#212121;
background-color:#9e9e9e;
border-color:#757575;
}
.panel-deleted > .panel-heading + .panel-collapse > .panel-body {
border-top-color:#757575;
}
.panel-deleted > .panel-heading .badge {
color:#9e9e9e;
background-color:#212121;
}
.panel-deleted > .panel-footer + .panel-collapse > .panel-body {
border-bottom-color:#757575;
}

BIN
nyaa/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

1895
nyaa/static/js/bootstrap-select.js vendored Normal file

File diff suppressed because it is too large Load diff

BIN
nyaa/static/js/bootstrap.min.js vendored Normal file

Binary file not shown.

BIN
nyaa/static/js/jquery.min.js vendored Normal file

Binary file not shown.

62
nyaa/static/js/main.js Normal file
View file

@ -0,0 +1,62 @@
document.addEventListener("DOMContentLoaded", function(event) { // wait for content to load because this script is above the link
document.getElementById('themeToggle').addEventListener('click', function(e) { // listen for click event
e.preventDefault(); // keep link from default action, which going to top of the page
toggleDarkMode(); // toggle theme
});
});
// Credit: https://www.abeautifulsite.net/whipping-file-inputs-into-shape-with-bootstrap-3
// We can attach the `fileselect` event to all file inputs on the page
$(document).on('change', ':file', function() {
var input = $(this),
numFiles = input.get(0).files ? input.get(0).files.length : 1,
label = input.val().replace(/\\/g, '/').replace(/.*\//, '');
input.trigger('fileselect', [numFiles, label]);
});
// We can watch for our custom `fileselect` event like this
$(document).ready(function() {
$(':file').on('fileselect', function(event, numFiles, label) {
var input = $(this).parent().prev().find(':text'),
log = numFiles > 1 ? numFiles + ' files selected' : label;
if (input.length) {
input.val(log);
} else {
if (log) alert(log);
}
});
});
//
// This is the unminified version of the theme changer script in the layout.html @ line: 21
// ===========================================================
// if (typeof(Storage) !== 'undefined') {
// var bsThemeLink = document.getElementById('bsThemeLink');
// if (localStorage.getItem('theme') === 'dark') {
// setThemeDark();
// }
// function toggleDarkMode() {
// if (localStorage.getItem('theme') === 'dark') {
// setThemeLight();
// } else {
// setThemeDark();
// }
// }
// function setThemeDark() {
// bsThemeLink.href = '/static/css/bootstrap-dark.min.css';
// localStorage.setItem('theme', 'dark');
// }
// function setThemeLight() {
// bsThemeLink.href = '/static/css/bootstrap.min.css';
// localStorage.setItem('theme', 'light');
// }
// }

13
nyaa/static/js/npm.js Normal file
View file

@ -0,0 +1,13 @@
// This file is autogenerated via the `commonjs` Grunt task. You can require() this file in a CommonJS environment.
require('../../js/transition.js')
require('../../js/alert.js')
require('../../js/button.js')
require('../../js/carousel.js')
require('../../js/collapse.js')
require('../../js/dropdown.js')
require('../../js/modal.js')
require('../../js/tooltip.js')
require('../../js/popover.js')
require('../../js/scrollspy.js')
require('../../js/tab.js')
require('../../js/affix.js')

46
nyaa/static/style.css Normal file
View file

@ -0,0 +1,46 @@
/* http://meyerweb.com/eric/tools/css/reset/
v2.0 | 20110126
License: none (public domain)
*/
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline; }
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block; }
body {
line-height: 1; }
ol, ul {
list-style: none; }
blockquote, q {
quotes: none; }
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none; }
table {
border-collapse: collapse;
border-spacing: 0; }

6
nyaa/templates/404.html Normal file
View file

@ -0,0 +1,6 @@
{% extends "layout.html" %}
{% block title %}404 Not Found :: {{ config.SITE_NAME }}{% endblock %}
{% block body %}
<h1>404 Not Found</h1>
<p>The path you requested does not exist on this server.</p>
{% endblock %}

View file

@ -0,0 +1,59 @@
{% macro render_field(field) %}
{% if field.errors %}
<div class="form-group has-error">
{% else %}
<div class="form-group">
{% endif %}
{{ field.label(class='control-label') }}
{{ field(title=field.description,**kwargs) | safe }}
{% if field.errors %}
<div class="help-block">
{% if field.errors|length < 2 %}
{% for error in field.errors %}
{{ error }}
{% endfor %}
{% else %}
<ul>
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endif %}
</div>
{% endmacro %}
{% macro render_upload(field) %}
{% if field.errors %}
<div class="form-group has-error">
{% else %}
<div class="form-group">
{% endif %}
<label class="control-label" for="torrent_file">Torrent file</label>
<div class="input-group">
<label for="{{ field.id }}" class="input-group-btn">
<span class="btn btn-default">Browse&hellip;</span>
</label>
<input type="text" class="form-control" readonly>
</div>
<div class="sr-only">
{{ field(title=field.description,**kwargs) | safe }}
</div>
{% if field.errors %}
<div class="help-block">
{% if field.errors|length < 2 %}
{% for error in field.errors %}
{{ error }}
{% endfor %}
{% else %}
<ul>
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endif %}
</div>
{% endmacro %}

View file

@ -0,0 +1,52 @@
## https://github.com/mbr/flask-bootstrap/blob/master/flask_bootstrap/templates/bootstrap/pagination.html
{% macro _arg_url_for(endpoint, base) %}
{# calls url_for() with a given endpoint and **base as the parameters,
additionally passing on all keyword_arguments (may overwrite existing ones)
#}
{%- with kargs = base.copy() -%}
{%- do kargs.update(kwargs) -%}
{{url_for(endpoint, **kargs)}}
{%- endwith %}
{%- endmacro %}
{% macro render_pagination(pagination,
endpoint=None,
prev=('&laquo;')|safe,
next=('&raquo;')|safe,
size=None,
ellipses='…',
args={}
)
-%}
{% with url_args = {} %}
{%- do url_args.update(request.view_args if not endpoint else {}),
url_args.update(request.args if not endpoint else {}),
url_args.update(args) -%}
{% with endpoint = endpoint or request.endpoint %}
<nav>
<ul class="pagination{% if size %} pagination-{{size}}{% endif %}"{{kwargs|xmlattr}}>
{# prev and next are only show if a symbol has been passed. #}
{% if prev != None -%}
<li{% if not pagination.has_prev %} class="disabled"{% endif %}><a href="{{_arg_url_for(endpoint, url_args, p=pagination.prev_num) if pagination.has_prev else '#'}}">{{prev}}</a></li>
{%- endif -%}
{%- for page in pagination.iter_pages(left_edge=2, left_current=6, right_current=6, right_edge=2) %}
{% if page %}
{% if page != pagination.page %}
<li><a href="{{_arg_url_for(endpoint, url_args, p=page)}}">{{page}}</a></li>
{% else %}
<li class="active"><a href="#">{{page}} <span class="sr-only">(current)</span></a></li>
{% endif %}
{% elif ellipses != None %}
<li class="disabled"><a href="#">{{ellipses}}</a></li>
{% endif %}
{%- endfor %}
{% if next != None -%}
<li{% if not pagination.has_next %} class="disabled"{% endif %}><a href="{{_arg_url_for(endpoint, url_args, p=pagination.next_num) if pagination.has_next else '#'}}">{{next}}</a></li>
{%- endif -%}
</ul>
</nav>
{% endwith %}
{% endwith %}
{% endmacro %}

88
nyaa/templates/edit.html Normal file
View file

@ -0,0 +1,88 @@
{% extends "layout.html" %}
{% block title %}Edit {{ torrent.display_name }} :: {{ config.SITE_NAME }}{% endblock %}
{% block body %}
{% from "_formhelpers.html" import render_field %}
<h1>Edit Torrent</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>
</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">
{{ 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_field(form.description, class_='form-control') }}
</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>
{{ form.is_hidden }}
Hidden
</label>
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
<label>
{{ form.is_remake }}
Remake
</label>
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
<label>
{{ form.is_complete }}
Complete
</label>
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
<label>
{{ form.is_anonymous }}
Anonymous
</label>
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
<input type="submit" value="Edit" class="btn btn-primary">
</div>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,10 @@
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-dismissable alert-{{ category }}" role="alert">
<button type="button" class="close" data-dismiss="alert">&times;</button>
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}

109
nyaa/templates/help.html Normal file
View file

@ -0,0 +1,109 @@
{% extends "layout.html" %}
{% block title %}Help :: {{ config.SITE_NAME }}{% endblock %}
{% block body %}
<h1>Coming soon (tm)</h1>
{# <div class="content">
<h1>Help</h1>
<p><b>The search engine</b> is located at the top right, and it allows users to search through the torrent titles available on the site. Results matching either word A or B can be included by typing a vertical bar between them (|). Results matching a certain word can be excluded by prefixing that word with a hyphen-minus (-). Phrases can be matched by surrounding them with double-quotes (). Search results can be filtered by category, remake, trusted, and/or A+ status, and then narrowed down further by age and size ranges as well as excluding specific users. Sorting can be done in ascending or descending order by date, amount of seeders/leechers/downloads, size, or name. The search engine adapts to the current view and makes it possible to search for specific torrents in a specific subcategory from a specific user.</p>
<p><b>Blue entries:</b></p>
<ul>
<li>
<p>Torrents marked as A+ quality are blue in the torrent lists.</p>
</li>
<li>
<p>These are exclusive torrents picked by us.</p>
</li>
<li>
<p>They represent the best available version of this content.</p>
</li>
<li>
<p>There will be no duplicates.</p>
</li>
<li>
<p>Only versions that we actually consider worthy of a seal of approval will be listed.</p>
</li>
</ul>
<p>Uploaders are encouraged to leave a complete description of the release on the torrent information page. This is especially true for batches.<br></p>
<p><b>Green entries:</b> Torrents uploaded by trusted users are green in the torrent lists.<br></p>
<p><b>Orange entries:</b> Torrents must be marked as remakes if any of the following applies to the release:</p>
<ul>
<li>
<p>Reencode of original release.</p>
</li>
<li>
<p>Remux of another uploader's original release for hardsubbing and/or fixing purposes.</p>
</li><!--<li>Non-v2 (or non-v3, etc.) remux of original release using a similar source.</li>-->
<li>
<p>Reupload of original release using non-original file names.</p>
</li>
<li>
<p>Reupload of original release with missing and/or unrelated additional files.</p>
</li>
</ul>
<p><b>Red entries:</b> Torrents containing completed series or other complete sets are red.<br></p>
<p><b>Grey entries:</b> Hidden torrents are grey.<br></p>
<p><b>The tools to manage your torrents</b> are located right above the torrent's details on the information page. Editable fields are the torrent's title, category, description, information link, metadata, and the alias and key fields which are explained on the page. It is also possible to hide the torrent which prevents it from being displayed in lists or even delete it altogether.<br></p>
<p><b>Pseudo-anonymous uploads</b> are torrents that will appear to be anonymous, but you can still manage them through your account.<br></p>
<p><b>Flagging torrents</b> points them out for moderator review. Torrents can be flagged by clicking on the link located in the upper right corner of their information pages.<br></p>
<p><b>RSS</b> is a useful Web feed that automatically updates when a torrent is added by a user. Many programs such as popular BitTorrent clients, which can be set up for automatic downloading, can make use of RSS feeds. The RSS feed link is dynamic which means that it will - like the search function - adapt to the current view, search results included.<br></p>
<p><b>BBCode user input</b> is parsed by the torrent descriptions, information links, and torrent comments, and they all support basic BBCode like [b], [i], [s], [u], [left], [center], [right], [code], [email], [img], [url], [color], [font], [size], [quote], and [spoiler].<br></p>
<p><b>The upload page</b> returns various HTTP status codes in order to simplify automated uploads. The following details the custom codes used:</p>
<ul>
<li>
<p>200: The ID of the uploaded torrent can be found in the Record-ID header.</p>
</li>
<li>
<p>418: You're doing it wrong.</p>
</li>
<li>
<p>460: You forgot to include a valid announce URL. Torrents using only DHT are not allowed, because this is most often just a mistake on behalf of the uploader.</p>
</li>
<li>
<p>461: This torrent already exists in the database.</p>
</li>
<li>
<p>462: The file you uploaded or linked to does not seem to be a torrent.</p>
</li>
<li>
<p>463: The form is missing required data like the category and/or the checkbox which confirms that you have read the rules.</p>
</li>
<li>
<p>520: Server-side error. Wait for a few minutes, and then notify Nyaa if the problem did not go away.</p>
</li>
</ul>
<h1>IRC help channel</h1><a href="irc://irc-server:port/channel?key">
<h1>NyaaV2 IRC</h1></a>
<p>The IRC channel is only for site support.<br></p>
<p><b>Read this to avoid getting banned:</b></p>
<ul>
<li>
<p>Do not sit around if you do not need site support unless you have voice/+ access.</p>
</li>
<li>
<p>Requests are not allowed. We only manage the site; we do not necessarily have the material you want on hand.</p>
</li>
<li>
<p>We do not know when A or B will be released, if it's authentic, or anything about a particular release. Do not ask.</p>
</li>
<li>
<p>XDCC, similar services, and their triggers are not allowed.</p>
</li>
<li>
<p>Use English only. Even though we aren't all from English-speaking countries, we need level ground to communicate on.</p>
</li>
<li>
<p>Do not send private messages to the staff. Ask your question in the channel on joining and wait; a staff member will respond in due time.</p>
</li>
</ul>
<p><b>Keep these things in mind when asking for help:</b></p>
<ul>
<li>
<p>We are not interested in your user name. Paste a link to your account if you want us to do something with it.</p>
</li>
<li>
<p>Provide as many details as possible. If you are having trouble submitting any kind of entry, we want to know everything about you and what (except any passwords) you supply to the form in question.</p>
</li>
</ul>
</div> #}
{% endblock %}

13
nyaa/templates/home.html Normal file
View file

@ -0,0 +1,13 @@
{% extends "layout.html" %}
{% block title %}Browse :: {{ config.SITE_NAME }}{% endblock %}
{% block body %}
<div class="alert alert-info">
<p><strong>Hello!</strong> This site is still a work in progress and new features (faster search, open source™, etc.) will be added soon.</p>
<p>We welcome you to provide feedback at <a href="irc://irc.rizon.net/nyaa-dev">#nyaa-dev@irc.rizon.net</a></p>
</div>
{% include "search_results.html" %}
{% endblock %}

289
nyaa/templates/layout.html Normal file
View file

@ -0,0 +1,289 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{% block title %}{{ config.SITE_NAME }}{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<link rel="shortcut icon" type="image/png" href="/static/favicon.png">
<link rel="icon" type="image/png" href="/static/favicon.png">
<!-- Bootstrap core CSS -->
<!--
Note: This has been customized at http://getbootstrap.com/customize/ to
set the column breakpoint to tablet mode, instead of mobile. This is to
make the navbar not look awful on tablets.
-->
<link href="/static/css/bootstrap.min.css" rel="stylesheet" id="bsThemeLink">
<!--
This theme changer script needs to be inline and right under the above stylesheet link to prevent FOUC (Flash Of Unstyled Content)
Development version is commented out in static/js/main.js at the bottom of the file
-->
<script>function toggleDarkMode(){"dark"===localStorage.getItem("theme")?setThemeLight():setThemeDark()}function setThemeDark(){bsThemeLink.href="/static/css/bootstrap-dark.min.css",localStorage.setItem("theme","dark")}function setThemeLight(){bsThemeLink.href="/static/css/bootstrap.min.css",localStorage.setItem("theme","light")}if("undefined"!=typeof Storage){var bsThemeLink=document.getElementById("bsThemeLink");"dark"===localStorage.getItem("theme")&&setThemeDark()}</script>
<link href="/static/css/bootstrap-select.min.css" rel="stylesheet">
<link href="/static/css/font-awesome.min.css" rel="stylesheet">
<!-- Custom styles for this template -->
<link href="/static/css/main.css" rel="stylesheet">
<!-- Bootstrap core JavaScript -->
<script src="/static/js/jquery.min.js"></script>
<script src="/static/js/bootstrap.min.js"></script>
<!-- Modified to not apply border-radius to selectpickers and stuff so our navbar looks cool -->
<script src="/static/js/bootstrap-select.js"></script>
<script src="/static/js/main.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/marked/0.3.6/marked.min.js"></script>
<!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/html5shiv/3.7.3/html5shiv.min.js"></script>
<script src="https://oss.maxcdn.com/respond/1.4.2/respond.min.js"></script>
<![endif]-->
</head>
<body>
<!-- Fixed navbar -->
<nav class="navbar navbar-default navbar-static-top navbar-inverse">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="/">{{ config.SITE_NAME }}</a>
</div>
<div id="navbar" class="navbar-collapse collapse">
<ul class="nav navbar-nav">
<li {% if request.path == "/upload" %} class="active"{% endif %}><a href="/upload">Upload</a></li>
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
About
<span class="caret"></span>
</a>
<ul class="dropdown-menu">
<li {% if request.path == "/rules" %} class="active"{% endif %}><a href="/rules">Rules</a></li>
<li {% if request.path == "/help" %} class="active"{% endif %}><a href="/help">Help</a></li>
</ul>
</li>
<li><a href="{% if rss_filter %}{{ url_for('home', page='rss', **rss_filter) }}{% else %}{{ url_for('home', page='rss') }}{% endif %}">RSS</a></li>
{% if config.TABLE_PREFIX == 'nyaa_' %}
<li><a href="https://sukebei.nyaa.si/">R-18</a></li>
{% elif config.TABLE_PREFIX == 'sukebei_' %}
<li><a href="https://nyaa.si/">SFW</a></li>
{% endif %}
</ul>
<ul class="nav navbar-nav navbar-right">
{% if g.user %}
<li class="dropdown">
<a href="#" class="dropdown-toggle visible-lg visible-sm visible-xs" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<i class="fa fa-user fa-fw"></i>
{{g.user.username}}
<span class="caret"></span>
</a>
<a href="#" class="dropdown-toggle hidden-lg hidden-sm hidden-xs" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<i class="fa fa-user fa-fw"></i>
<span class="caret"></span>
</a>
<ul class="dropdown-menu">
<li class="hidden-lg hidden-sm hidden-xs">
<a><i class="fa fa-user fa-fw"></i>Logged in as {{ g.user.username }}</a>
</li>
<li class="hidden-lg hidden-sm hidden-xs divider" role="separator">
</li>
<li>
<a href="{{ url_for('view_user', user_name=g.user.username) }}">
<i class="fa fa-user fa-fw"></i>
Torrents
</a>
</li>
<li>
<a href="/profile">
<i class="fa fa-gear fa-fw"></i>
Profile
</a>
</li>
<li>
<a href="/logout">
<i class="fa fa-times fa-fw"></i>
Logout
</a>
</li>
</ul>
</li>
{% else %}
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<i class="fa fa-user-times fa-fw"></i>
Guest
<span class="caret"></span>
</a>
<ul class="dropdown-menu">
<li>
<a href="/login">
<i class="fa fa-sign-in fa-fw"></i>
Login
</a>
</li>
<li>
<a href="/register">
<i class="fa fa-pencil fa-fw"></i>
Register
</a>
</li>
</ul>
</li>
{% endif %}
</ul>
{% if user_page %}
<form class="navbar-form navbar-right form" action="{{ url_for('view_user', user_name=user.username) }}" method="get">
{% else %}
<form class="navbar-form navbar-right form" action="/" method="get">
{% endif %}
<div class="input-group">
<input type="text" class="form-control" name="q" placeholder="Search..." value="{{ search["term"] if search is defined else '' }}">
<div class="input-group-btn" id="navFilter">
<select class="selectpicker show-tick" title="Filter" data-width="120px" name="f">
<option value="0" title="Show all" {% if search is defined and search["filter"] == "0" %}selected{% else %}selected{% endif %}>Show all</option>
<option value="1" title="No remakes" {% if search is defined and search["filter"] == "1" %}selected{% endif %}>No remakes</option>
<option value="2" title="Trusted only" {% if search is defined and search["filter"] == "2" %}selected{% endif %}>Trusted only</option>
</select>
</div>
<div class="input-group-btn" id="navFilter">
{% if config.TABLE_PREFIX == 'nyaa_' %}
<select class="selectpicker show-tick" title="Category" data-width="170px" name="c">
<option value="0_0" title="Show all" {% if search is defined and search["category"] == "0_0" %}selected{% else %}selected{% endif %}>
Show all
</option>
<option value="1_0" title="Anime" {% if search is defined and search["category"] == "1_0" %}selected{% endif %}>
Anime
</option>
<option value="1_1" title="Anime - AMV" {% if search is defined and search["category"] == "1_1" %}selected{% endif %}>
- Anime Music Video
</option>
<option value="1_2" title="Anime - English" {% if search is defined and search["category"] == "1_2" %}selected{% endif %}>
- English-translated
</option>
<option value="1_3" title="Anime - Non-English" {% if search is defined and search["category"] == "1_3" %}selected{% endif %}>
- Non-English-translated
</option>
<option value="1_4" title="Anime - Raw" {% if search is defined and search["category"] == "1_4" %}selected{% endif %}>
- Raw
</option>
<option value="2_0" title="Audio" {% if search is defined and search["category"] == "2_0" %}selected{% endif %}>
Audio
</option>
<option value="2_1" title="Audio - Lossless" {% if search is defined and search["category"] == "2_1" %}selected{% endif %}>
- Lossless
</option>
<option value="2_2" title="Audio - Lossy" {% if search is defined and search["category"] == "2_2" %}selected{% endif %}>
- Lossy
</option>
<option value="3_0" title="Literature" {% if search is defined and search["category"] == "3_0" %}selected{% endif %}>
Literature
</option>
<option value="3_1" title="Literature - English" {% if search is defined and search["category"] == "3_1" %}selected{% endif %}>
- English-translated
</option>
<option value="3_2" title="Literature - Non-English" {% if search is defined and search["category"] == "3_2" %}selected{% endif %}>
- Non-English-translated
</option>
<option value="3_3" title="Literature - Raw" {% if search is defined and search["category"] == "3_3" %}selected{% endif %}>
- Raw
</option>
<option value="4_0" title="Live Action" {% if search is defined and search["category"] == "4_0" %}selected{% endif %}>
Live Action
</option>
<option value="4_1" title="Live Action - English" {% if search is defined and search["category"] == "4_1" %}selected{% endif %}>
- English-translated
</option>
<option value="4_2" title="Live Action - Idol/PV" {% if search is defined and search["category"] == "4_2" %}selected{% endif %}>
- Idol/Promotional Video
</option>
<option value="4_3" title="Live Action - Non-English" {% if search is defined and search["category"] == "4_3" %}selected{% endif %}>
- Non-English-translated
</option>
<option value="4_4" title="Live Action - Raw" {% if search is defined and search["category"] == "4_4" %}selected{% endif %}>
- Raw
</option>
<option value="5_0" title="Pictures" {% if search is defined and search["category"] == "5_0" %}selected{% endif %}>
Pictures
</option>
<option value="5_1" title="Pictures - Graphics" {% if search is defined and search["category"] == "5_1" %}selected{% endif %}>
- Graphics
</option>
<option value="5_2" title="Pictures - Photos" {% if search is defined and search["category"] == "5_2" %}selected{% endif %}>
- Photos
</option>
<option value="6_0" title="Software" {% if search is defined and search["category"] == "6_0" %}selected{% endif %}>
Software
</option>
<option value="6_1" title="Software - Apps" {% if search is defined and search["category"] == "6_1" %}selected{% endif %}>
- Applications
</option>
<option value="6_2" title="Software - Games" {% if search is defined and search["category"] == "6_2" %}selected{% endif %}>
- Games
</option>
</select>
{% elif config.TABLE_PREFIX == 'sukebei_' %}
<select class="selectpicker show-tick" title="Category" data-width="170px" name="c">
<option value="0_0" title="Show all" {% if search is defined and search["category"] == "0_0" %}selected{% else %}selected{% endif %}>
Show all
</option>
<option value="1_0" title="Art" {% if search is defined and search["category"] == "1_0" %}selected{% endif %}>
Art
</option>
<option value="1_1" title="Art - Anime" {% if search is defined and search["category"] == "1_1" %}selected{% endif %}>
- Anime
</option>
<option value="1_2" title="Art - Doujinshi" {% if search is defined and search["category"] == "1_2" %}selected{% endif %}>
- Doujinshi
</option>
<option value="1_3" title="Art - Games" {% if search is defined and search["category"] == "1_3" %}selected{% endif %}>
- Games
</option>
<option value="1_4" title="Art - Manga" {% if search is defined and search["category"] == "1_4" %}selected{% endif %}>
- Manga
</option>
<option value="1_5" title="Art - Pictures" {% if search is defined and search["category"] == "1_5" %}selected{% endif %}>
- Pictures
</option>
<option value="2_0" title="Real Life" {% if search is defined and search["category"] == "2_0" %}selected{% endif %}>
Real Life
</option>
<option value="2_1" title="Real Life - Pictures" {% if search is defined and search["category"] == "2_1" %}selected{% endif %}>
- Photobooks and Pictures
</option>
<option value="2_2" title="Real Life - Videos" {% if search is defined and search["category"] == "2_2" %}selected{% endif %}>
- Videos
</option>
</select>
{% endif %}
</div>
<div class="input-group-btn">
<button class="btn btn-primary" type="submit">
<i class="fa fa-search fa-fw"></i>
</button>
</div>
</div>
</form>
</div><!--/.nav-collapse -->
</div>
</nav>
<div class="container">
{% include "flashes.html" %}
{% block body %}{% endblock %}
</div> <!-- /container -->
<footer style="text-align: center;">
<p>Dark Mode: <a href="#" id="themeToggle">Toggle</a></p>
</footer>
</body>
</html>

28
nyaa/templates/login.html Normal file
View file

@ -0,0 +1,28 @@
{% extends "layout.html" %}
{% block title %}Login :: {{ config.SITE_NAME }}{% endblock %}
{% block body %}
{% from "_formhelpers.html" import render_field %}
<h1>Login</h1>
<form method="POST">
{{ form.csrf_token }}
<div class="row">
<div class="form-group col-md-4">
{{ render_field(form.username, class_='form-control', placeholder='Username') }}
</div>
</div>
<div class="row">
<div class="form-group col-md-4">
{{ render_field(form.password, class_='form-control', placeholder='Password') }}
</div>
</div>
<div class="row">
<div class="col-md-4">
<input type="submit" value="Login" class="btn btn-primary">
</div>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,43 @@
{% extends "layout.html" %}
{% block title %}Edit Profile :: {{ config.SITE_NAME }}{% endblock %}
{% block body %}
{% from "_formhelpers.html" import render_field %}
<h1>Edit Profile</h1>
<form method="POST">
{{ form.csrf_token }}
<div class="row">
<div class="form-group col-md-4">
{{ render_field(form.email, class_='form-control', placeholder='New email address') }}
</div>
</div>
<div class="row">
<div class="form-group col-md-4">
{{ render_field(form.current_password, class_='form-control', placeholder='Current password') }}
</div>
</div>
<div class="row">
<div class="form-group col-md-4">
{{ render_field(form.new_password, class_='form-control', placeholder='New password') }}
</div>
</div>
<div class="row">
<div class="form-group col-md-4">
{{ render_field(form.password_confirm, class_='form-control', placeholder='New password (confirm)') }}
</div>
</div>
<br>
<div class="row">
<div class="col-md-4">
<input type="submit" value="Update" class="btn btn-primary">
</div>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,54 @@
{% extends "layout.html" %}
{% block title %}Register :: {{ config.SITE_NAME }}{% endblock %}
{% block body %}
{% from "_formhelpers.html" import render_field %}
<h1>Register</h1>
<form method="POST">
{{ form.csrf_token }}
<div class="row">
<div class="form-group col-md-4">
{{ render_field(form.username, class_='form-control', placeholder='Username') }}
</div>
</div>
<div class="row">
<div class="form-group col-md-4">
{{ render_field(form.email, class_='form-control', placeholder='Email address') }}
</div>
</div>
<div class="row">
<div class="form-group col-md-4">
{{ render_field(form.password, class_='form-control', placeholder='Password') }}
</div>
</div>
<div class="row">
<div class="form-group col-md-4">
{{ render_field(form.password_confirm, class_='form-control', placeholder='Password (confirm)') }}
</div>
</div>
{% if config.USE_RECAPTCHA %}
<div class="row">
<div class="col-md-4">
{% for error in form.recaptcha.errors %}
{{ error }}
{% endfor %}
{{ form.recaptcha }}
</div>
</div>
{% endif %}
<br>
<div class="row">
<div class="col-md-4">
<input type="submit" value="Register" class="btn btn-primary">
</div>
</div>
</form>
{% endblock %}

22
nyaa/templates/rss.xml Normal file
View file

@ -0,0 +1,22 @@
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
<channel>
<title>{{ config.SITE_NAME }} Torrent File RSS (No magnets)</title>
<description>
RSS Feed for {{ term }}
</description>
<link>{{ site_url }}</link>
<atom:link href="{{ site_url }}rss" rel="self" type="application/rss+xml" />
{% for torrent in query %}
{% if torrent.has_torrent %}
<item>
<title>{{ torrent.display_name }}</title>
<link>
{{ site_url }}view/{{ torrent.id }}/torrent
</link>
<guid isPermaLink="true">{{ site_url }}view/{{ torrent.id }}</guid>
<pubDate>{{ torrent.created_time|rfc822 }}</pubDate>
</item>
{% endif %}
{% endfor %}
</channel>
</rss>

90
nyaa/templates/rules.html Normal file
View file

@ -0,0 +1,90 @@
{% extends "layout.html" %}
{% block title %}Rules :: {{ config.SITE_NAME }}{% endblock %}
{% block body %}
<h1>Coming soon (tm)</h1>
{# <div class="content">
<h1>Site Rules</h1><!-- <br> -->
<!-- <b>Spoilers:</b> Your account will be banned if you repeatedly post these without using the [spoiler] tag properly. -->
<h1>Breaking any of the rules on this page may result in being banned</h1>
<p><b>Shitposting and Trolling:</b> Your account will be banned if you keep this up.</p>
<p><b>Bumping:</b> Your account will be banned if you keep deleting and reposting your torrents.</p>
<p><b>Flooding:</b> If you have five or more releases of the same type to release in one go, make a batch torrent containing all of them.</p>
<p><b>URL redirection services:</b> These are removed on sight along with their torrents.</p>
<p><b>Advertising:</b> No.</p>
<p><b>Content restrictions:</b>This site is for content that originates from and/or is specific to China, Japan, and/or Korea.</p>
<p>Other content is not allowed without exceptions and will be removed.</p><br>
<p><a href="https://%3CURL%3E/"><b>NAME</b></a> is for <b>work-safe</b> content only. The following rules apply:</p>
<ul>
<li>
<p>No pornography of any kind.</p>
</li>
<li>
<p>No extreme visual content. This means no scat, gore, or any other of such things.</p>
</li>
<li>
<p>Troll torrents are not allowed. These will be removed on sight.</p>
</li>
</ul><br>
<p><a href="https://%3CURL%3E"><b>NAME</b></a> is the place for <b>non-work-safe</b> content only. Still, the following rules apply:</p>
<ul>
<li>
<p>No extreme real life visual content. This means no scat, gore, bestiality, or any other of such things.</p>
</li>
<li>
<p>Absolutely no real life child pornography of any kind.</p>
</li>
<li>
<p>Troll torrents are not allowed. These will be removed on sight.</p>
</li>
</ul><br>
<p><b>Torrent information:</b> Text files (.txt) or info files (.nfo) for torrent or release group information are preferred.</p>
<p>Torrents containing (.chm) or (.url) files may be removed.</p><br>
<p><b>Upper limits on video resolution based on source:</b></p>
<ul>
<li>
<p>DVD source video is limited to 1024x576p.</p>
</li>
<li>
<p>Web source video is limited to 1920x1080p or source resolution, whichever is lower.</p>
</li>
<li>
<p>TV source video is by default limited to 1920x1080p.<!-- The BS11, BS-NTV, NHK-BS Premium, and WOWOW channels are limited to 1920x1080p.--> SD channels, however, are limited to 480p.</p>
</li>
<li>
<p>Blu-ray source video is limited to 1920x1080p.</p>
</li>
<li>
<p>UHD source video is limited to 3840x2160p.</p>
</li>
</ul><br>
<p>Naturally, untouched sources are not bound by these limits.</p><br>
<p><b>Finally, a few notes concerning tagging and using other people's work:</b></p>
<ul>
<li>
<p>Do not add your own tag(s) when reuploading an original release.</p>
</li>
<li>
<p>Unless you are reuploading an original release, you should either avoid using tags that are not your own or make it extremely clear to everyone that you are the one responsible for the upload.</p>
</li>
<li>
<p>If these policies are not obeyed, then those torrents will be removed if reported by a group or person commonly seen as the owner of the tag(s). This especially applies to remake torrents.</p>
</li>
<li>
<p>Although only hinted at above, we will of course remove any troll torrents tagged with A-sucks, B-is-slow, or such if reported by A or B.</p>
</li>
<li>
<p>Remakes which are utterly bit rate-starved are not allowed.</p>
</li>
<li>
<p>Remakes which add watermarks or such are not allowed.</p>
</li>
<li>
<p>Remakes which reencode video to XviD or worse are not allowed.</p>
</li>
<li>
<p>Remakes of JPG/PNG-based releases are not allowed without exceptions since there is most often no point in making such.</p>
</li>
</ul>
</div> #}
{% endblock %}

View file

@ -0,0 +1,70 @@
{% if torrent_query.items %}
<div class="table-responsive">
<table class="table table-bordered table-hover table-striped torrent-list">
<thead>
<tr>
<th style="width:80px;text-align:center;">Category</th>
<th class="sorting{% if search["sort"] == "name" %}{% if search["order"] == "desc" %}_desc{% else %}_asc{% endif %}{% endif %}" style="width:auto;">
<a href="{% if search["sort"] == "name" and search["order"] == "desc" %}{{ modify_query(s="name", o="asc") }}{% else %}{{ modify_query(s="name", o="desc") }}{% endif %}"></a>
Name
</th>
<th style="width:0;text-align:center;">Link</th>
<th class="sorting{% if search["sort"] == "size" %}{% if search["order"] == "desc" %}_desc{% else %}_asc{% endif %}{% endif %}" style="width:100px;text-align:center;">
<a href="{% if search["sort"] == "size" and search["order"] == "desc" %}{{ modify_query(s="size", o="asc") }}{% else %}{{ modify_query(s="size", o="desc") }}{% endif %}"></a>
Size
</th>
{# <th style="width:170px;text-align:center;">Date Uploaded</th> #}
{% if config.ENABLE_SHOW_STATS %}
<th class="sorting{% if search["sort"] == "seeders" %}{% if search["order"] == "desc" %}_desc{% else %}_asc{% endif %}{% endif %} text-center" style="width:65px;">
<a href="{% if search["sort"] == "seeders" and search["order"] == "desc" %}{{ modify_query(s="seeders", o="asc") }}{% else %}{{ modify_query(s="seeders", o="desc") }}{% endif %}"></a>
<i class="fa fa-arrow-up" aria-hidden="true"></i></a>
</th>
<th class="sorting{% if search["sort"] == "leechers" %}{% if search["order"] == "desc" %}_desc{% else %}_asc{% endif %}{% endif %} text-center" style="width:65px;">
<a href="{% if search["sort"] == "leechers" and search["order"] == "desc" %}{{ modify_query(s="leechers", o="asc") }}{% else %}{{ modify_query(s="leechers", o="desc") }}{% endif %}"></a>
<i class="fa fa-arrow-down" aria-hidden="true"></i>
</th>
<th class="sorting{% if search["sort"] == "downloads" %}{% if search["order"] == "desc" %}_desc{% else %}_asc{% endif %}{% endif %} text-center" style="width:65px;">
<a href="{% if search["sort"] == "downloads" and search["order"] == "desc" %}{{ modify_query(s="downloads", o="asc") }}{% else %}{{ modify_query(s="downloads", o="desc") }}{% endif %}"></a>
<i class="fa fa-check" aria-hidden="true"></i>
</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for torrent in torrent_query.items %}
<tr class="{% if torrent.deleted %}deleted{% elif torrent.hidden %}warning{% elif torrent.remake %}danger{% elif torrent.trusted %}success{% else %}default{% endif %}">
{% if config.TABLE_PREFIX == 'nyaa_' %}
<td style="padding:0 4px;">
<a href="/?c={{ torrent.main_category.id }}_{{ torrent.sub_category.id }}">
<img src="/static/img/icons/nyaa/{{ torrent.main_category.id }}_{{ torrent.sub_category.id }}.png">
</a>
</td>
{% elif config.TABLE_PREFIX == 'sukebei_' %}
<td style="padding:0 4px;">
<a href="/?c={{ torrent.main_category.id }}_{{ torrent.sub_category.id }}">
<img src="/static/img/icons/sukebei/{{ torrent.main_category.id }}_{{ torrent.sub_category.id }}.png">
</a>
</td>
{% endif %}
<td><a href="/view/{{ torrent.id }}">{{ torrent.display_name | escape }}</a></td>
<td style="white-space: nowrap;text-align: center;">{% if torrent.has_torrent %}<a href="/view/{{ torrent.id }}/torrent"><i class="fa fa-fw fa-download"></i></a> {% endif %}<a href="{{ torrent.magnet_uri }}"><i class="fa fa-fw fa-magnet"></i></a></td>
<td>{{ torrent.filesize | filesizeformat(True) }}</td>
{# <td>{{ torrent.created_time.strftime('%Y-%m-%d %H:%M') }}</td> #}
{% if config.ENABLE_SHOW_STATS %}
<td class="text-center" style="color: green;">{{ torrent.stats.seed_count }}</td>
<td class="text-center" style="color: red;">{{ torrent.stats.leech_count }}</td>
<td class="text-center">{{ torrent.stats.download_count }}</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<h3>No results found</h3>
{% endif %}
<center>
{% from "bootstrap/pagination.html" import render_pagination %}
{{ render_pagination(torrent_query) }}
</center>

View file

@ -0,0 +1,88 @@
{% extends "layout.html" %}
{% block title %}Upload Torrent :: {{ config.SITE_NAME }}{% endblock %}
{% block body %}
{% from "_formhelpers.html" import render_field %}
{% from "_formhelpers.html" import render_upload %}
<h1>Upload Torrent</h1>
{% if not user %}
<p>You are not logged in, and are uploading anonymously.</p>
{% endif %}
<form method="POST" enctype="multipart/form-data">
{% if config.ENFORCE_MAIN_ANNOUNCE_URL %}<p><strong>Important:</strong> Please put <i>{{config.MAIN_ANNOUNCE_URL}}</i> as your first tracker</p>{% endif %}
<div class="row">
<div class="form-group 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>
</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">
{{ 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_field(form.description, class_='form-control') }}
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
<label>
{{ form.is_hidden }}
Hidden
</label>
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
<label>
{{ form.is_remake }}
Remake
</label>
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
<label>
{{ form.is_complete }}
Complete
</label>
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
<label>
{{ form.is_anonymous }}
Anonymous
</label>
</div>
</div>
<div class="row">
<div class="form-group col-md-6">
<input type="submit" value="Upload" class="btn btn-primary">
</div>
</div>
</form>
{% endblock %}

10
nyaa/templates/user.html Normal file
View file

@ -0,0 +1,10 @@
{% extends "layout.html" %}
{% block title %}{{ user.username }} :: {{ config.SITE_NAME }}{% endblock %}
{% block body %}
<h3>
Browsing {{user.username}}'s torrents
</h3>
{% include "search_results.html" %}
{% endblock %}

120
nyaa/templates/view.html Normal file
View file

@ -0,0 +1,120 @@
{% extends "layout.html" %}
{% block title %}{{ torrent.display_name }} :: {{ config.SITE_NAME }}{% endblock %}
{% block body %}
<div class="panel panel-{% if torrent.deleted %}deleted{% elif torrent.remake %}danger{% elif torrent.trusted %}success{% else %}default{% endif %}">
<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>
{% endif %}
{{ torrent.display_name }}
</h3>
</div>
<div class="panel-body">
<div class="row">
<div class="col-md-1">Category:</div>
<div class="col-md-5">{{ torrent.main_category.name }} - {{ torrent.sub_category.name }}</div>
<div class="col-md-1">Date:</div>
<div class="col-md-5">{{ torrent.created_time.strftime('%Y-%m-%d, %H:%M UTC') }}</div>
</div>
<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-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>
</div>
<div class="row">
<div class="col-md-1">Information:</div>
<div class="col-md-5">
{% if torrent.information %}
{{ torrent.information | escape }}
{% else %}
No information.
{% endif%}
</div>
<div class="col-md-1">Leechers:</div>
<div class="col-md-5"><span style="color: red;">{% if config.ENABLE_SHOW_STATS %}{{ torrent.stats.leech_count }}{% else %}Coming soon{% endif %}</span></div>
</div>
<div class="row">
<div class="col-md-1">File size:</div>
<div class="col-md-5">{{ torrent.filesize | filesizeformat(True) }}</div>
<div class="col-md-1">Downloads:</div>
<div class="col-md-5">{% if config.ENABLE_SHOW_STATS %}{{ torrent.stats.download_count }}{% else %}Coming soon{% endif %}</div>
</div>
</div>
<div class="panel-footer">
{% if torrent.has_torrent %}<a href="/view/{{ torrent.id }}/torrent"><i class="fa fa-download fa-fw"></i>Download Torrent</a> or {% endif %}<a href="{{ torrent.magnet_uri }}" class="card-footer-item"><i class="fa fa-magnet fa-fw"></i>Magnet</a>
</div>
</div>
<div class="panel panel-default">
<div class="panel-body" id="torrent-description">
{% if torrent.description %}
{{ torrent.description | escape }}
{% else %}
#### No description.
{% endif%}
</div>
</div>
{% if files and files.__len__() <= config.MAX_FILES_VIEW %}
<div class="panel panel-default">
<div class="panel-heading panel-heading-collapse">
<h3 class="panel-title">
<div class="row">
<a class="collapsed col-md-12" data-target="#collapseFileList" data-toggle="collapse" style="color:inherit;text-decoration:none;">File list</a>
</div>
</h3>
</div>
<div class="panel-collapse collapse" id="collapseFileList">
<table class="table table-bordered table-hover table-striped">
<thead>
<th style="width:auto;">Path</th>
<th style="width:auto;">Size</th>
</thead>
<tbody>
{%- for key, value in files.items() %}
<tr>
<td>{{ key }}</td>
<td class="col-md-2">{{ value | filesizeformat(True) }}</td>
</tr>
{%- endfor %}
<tbody>
</table>
</div>
</div>
{% elif files %}
<div class="panel panel-default">
<div class="panel-heading panel-heading-collapse">
<h3 class="panel-title">
<div class="row"><div class="col-md-12">Too many files to display.</div></div>
</h3>
</div>
</div>
{% else %}
<div class="panel panel-default">
<div class="panel-heading panel-heading-collapse">
<h3 class="panel-title">
<div class="row"><div class="col-md-12">File list is not available for this torrent.</div></div>
</h3>
</div>
</div>
{% endif %}
<script>
var target = document.getElementById('torrent-description');
var text = target.innerHTML;
var html = marked(text.trim(), { sanitize: true });
target.innerHTML = html;
</script>
{% endblock %}

View file

@ -0,0 +1,6 @@
{% extends "layout.html" %}
{% block title %}Awaiting Verification :: {{ config.SITE_NAME }}{% endblock %}
{% block body %}
<h1>Awaiting Verification</h1>
<p>Your account been registered. Please check your email for the verification link to activate your account.</p>
{% endblock %}

View file

102
nyaa/torrents.py Normal file
View file

@ -0,0 +1,102 @@
import os
import base64
import time
from urllib.parse import urlencode
from orderedset import OrderedSet
from nyaa import bencode
from nyaa import app
from nyaa import models
USED_TRACKERS = OrderedSet()
def read_trackers_from_file(file_object):
USED_TRACKERS.clear()
for line in file_object:
line = line.strip()
if line:
USED_TRACKERS.add(line)
return USED_TRACKERS
def read_trackers():
tracker_list_file = os.path.join(app.config['BASE_DIR'], 'trackers.txt')
if os.path.exists(tracker_list_file):
with open(tracker_list_file, 'r') as in_file:
return read_trackers_from_file(in_file)
def default_trackers():
if not USED_TRACKERS:
read_trackers()
return USED_TRACKERS[:]
def get_trackers(torrent):
trackers = default_trackers()
torrent_trackers = torrent.trackers
for torrent_tracker in torrent_trackers:
trackers.add(torrent_tracker.tracker.uri)
return list(trackers)
def create_magnet(torrent, max_trackers=5, trackers=None):
if trackers is None:
trackers = get_trackers(torrent)
magnet_parts = [
('dn', torrent.display_name)
]
for tracker in trackers[:max_trackers]:
magnet_parts.append(('tr', tracker))
b32_info_hash = base64.b32encode(torrent.info_hash).decode('utf-8')
return 'magnet:?xt=urn:btih:' + b32_info_hash + '&' + urlencode(magnet_parts)
def create_default_metadata_base(torrent, trackers=None):
if trackers is None:
trackers = get_trackers(torrent)
metadata_base = {
'created by': 'NyaaV2',
'creation date': int(time.time()),
'comment': 'NyaaV2 Torrent #' + str(torrent.id), # Throw the url here or something neat
# 'encoding' : 'UTF-8' # It's almost always UTF-8 and expected, but if it isn't...
}
if len(trackers) > 0:
metadata_base['announce'] = trackers[0]
if len(trackers) > 1:
# Yes, it's a list of lists with a single element inside.
metadata_base['announce-list'] = [[tracker] for tracker in trackers]
return metadata_base
def create_bencoded_torrent(torrent, metadata_base=None):
''' Creates a bencoded torrent metadata for a given torrent,
optionally using a given metadata_base dict (note: 'info' key will be
popped off the dict) '''
if metadata_base is None:
metadata_base = create_default_metadata_base(torrent)
metadata_base['encoding'] = torrent.encoding
# Make sure info doesn't exist on the base
metadata_base.pop('info', None)
prefixed_dict = {key: metadata_base[key] for key in metadata_base if key < 'info'}
suffixed_dict = {key: metadata_base[key] for key in metadata_base if key > 'info'}
prefix = bencode.encode(prefixed_dict)
suffix = bencode.encode(suffixed_dict)
bencoded_info = torrent.info.info_dict
bencoded_torrent = prefix[:-1] + b'4:info' + bencoded_info + b'e' + suffix[1:]
return bencoded_torrent

61
nyaa/utils.py Normal file
View file

@ -0,0 +1,61 @@
import hashlib
import functools
from collections import OrderedDict
def sha1_hash(input_bytes):
""" Hash given bytes with hashlib.sha1 and return the digest (as bytes) """
return hashlib.sha1(input_bytes).digest()
def sorted_pathdict(input_dict):
""" Sorts a parsed torrent filelist dict by alphabat, directories first """
directories = OrderedDict()
files = OrderedDict()
for key, value in input_dict.items():
if isinstance(value, dict):
directories[key] = sorted_pathdict(value)
else:
files[key] = value
return OrderedDict(sorted(directories.items()) + sorted(files.items()))
def cached_function(f):
sentinel = object()
f._cached_value = sentinel
@functools.wraps(f)
def decorator(*args, **kwargs):
if f._cached_value is sentinel:
print('Evaluating', f, args, kwargs)
f._cached_value = f(*args, **kwargs)
return f._cached_value
return decorator
def flattenDict(d, result=None):
if result is None:
result = {}
for key in d:
value = d[key]
if isinstance(value, dict):
value1 = {}
for keyIn in value:
value1["/".join([key, keyIn])] = value[keyIn]
flattenDict(value1, result)
elif isinstance(value, (list, tuple)):
for indexB, element in enumerate(value):
if isinstance(element, dict):
value1 = {}
index = 0
for keyIn in element:
newkey = "/".join([key, keyIn])
value1["/".join([key, keyIn])] = value[indexB][keyIn]
index += 1
for keyA in value1:
flattenDict(value1, result)
else:
result[key] = value
return result

34
requirements.txt Normal file
View file

@ -0,0 +1,34 @@
appdirs==1.4.3
argon2-cffi==16.3.0
autopep8==1.3.1
blinker==1.4
cffi==1.10.0
click==6.7
dominate==2.3.1
Flask==0.12.1
Flask-Assets==0.12
Flask-DebugToolbar==0.10.1
Flask-SQLAlchemy==2.2
Flask-WTF==0.14.2
gevent==1.2.1
greenlet==0.4.12
itsdangerous==0.24
Jinja2==2.9.6
libsass==0.12.3
MarkupSafe==1.0
mysqlclient==1.3.10
orderedset==2.0
packaging==16.8
passlib==1.7.1
pycodestyle==2.3.1
pycparser==2.17
pyparsing==2.2.0
six==1.10.0
SQLAlchemy>=1.1.9
SQLAlchemy-FullText-Search==0.2.3
SQLAlchemy-Utils>=0.32.14
uWSGI==2.0.15
visitor==0.1.3
webassets==0.12.1
Werkzeug==0.12.1
WTForms==2.1

2
run.py Normal file
View file

@ -0,0 +1,2 @@
from nyaa import app
app.run(host='0.0.0.0', port=5500, debug=True)

1
torrent_cache/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
*.torrent

10
trackers.txt Normal file
View file

@ -0,0 +1,10 @@
udp://tracker.internetwarriors.net:1337/announce
udp://tracker.leechers-paradise.org:6969/announce
udp://tracker.coppersurfer.tk:6969/announce
udp://exodus.desync.com:6969/announce
udp://tracker.openbittorrent.com:80/announce
udp://tracker.sktorrent.net:6969/announce
udp://tracker.zer0day.to:1337/announce
udp://tracker.pirateparty.gr:6969/announce
udp://oscar.reyesleon.xyz:6969/announce

30
uwsgi.ini Normal file
View file

@ -0,0 +1,30 @@
[uwsgi]
# socket = [addr:port]
socket = uwsgi.sock
chmod-socket = 664
# logging
disable-logging = True
#logger = file:uwsgi.log
# Base application directory
#chdir = .
# WSGI module and callable
# module = [wsgi_module_name]:[application_callable_name]
module = WSGI:app
# master = [master process (true of false)]
master = true
# debugging
catch-exceptions = True
# performance
processes = 4
buffer-size = 8192
loop = gevent
socket-timeout = 10
gevent = 1000
gevent-monkey-patch = true