1
0
Fork 0
mirror of https://gitlab.com/SIGBUS/nyaa.git synced 2024-12-22 19:49:59 +00:00

Merge branch 'master' into reports

This commit is contained in:
nyaazi 2017-05-26 15:25:02 +03:00
commit 5332ba1a49
41 changed files with 1868 additions and 783 deletions

18
.travis.yml Normal file
View file

@ -0,0 +1,18 @@
language: python
python:
- 3.6
dist: xenial
sudo: false
cache: pip
install:
- pip install --upgrade pycodestyle
script:
- pycodestyle nyaa/ --show-source --max-line-length=100
notifications:
email: false

View file

@ -1,14 +1,6 @@
# NyaaV2 # NyaaV2
## Setup: ## 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 dependencies https://github.com/pyenv/pyenv/wiki/Common-build-problems
- Install `pyenv` https://github.com/pyenv/pyenv/blob/master/README.md#installation - Install `pyenv` https://github.com/pyenv/pyenv/blob/master/README.md#installation
@ -20,7 +12,7 @@
- Copy `config.example.py` into `config.py` - Copy `config.example.py` into `config.py`
- Change TABLE_PREFIX to `nyaa_` or `sukebei_` depending on the site - Change TABLE_PREFIX to `nyaa_` or `sukebei_` depending on the site
## Setting up MySQL/MariaDB database for advanced functionality ### Setting up MySQL/MariaDB database for advanced functionality
- Enable `USE_MYSQL` flag in config.py - Enable `USE_MYSQL` flag in config.py
- Install latest mariadb by following instructions here https://downloads.mariadb.org/mariadb/repositories/ - 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` - Tested versions: `mysql Ver 15.1 Distrib 10.0.30-MariaDB, for debian-linux-gnu (x86_64) using readline 5.2`
@ -35,18 +27,18 @@
- `CREATE DATABASE nyaav2 DEFAULT CHARACTER SET utf8 COLLATE utf8_bin;` - `CREATE DATABASE nyaav2 DEFAULT CHARACTER SET utf8 COLLATE utf8_bin;`
- `SOURCE ~/path/to/database/nyaa_maria_vx.sql` - `SOURCE ~/path/to/database/nyaa_maria_vx.sql`
## Finishing up ### Finishing up
- Run `python db_create.py` to create the database - Run `python db_create.py` to create the database
- Load the .sql file - Load the .sql file
- `mysql -u user -p nyaav2` - `mysql -u user -p nyaav2`
- `SOURCE cocks.sql` - `SOURCE cocks.sql`
- Remember to change the default user password to an empty string to disable logging in - Remember to change the default user password to an empty string to disable logging in
- Start the dev server with `python run.py` - Start the dev server with `python run.py`
- Deactivate `source deactivate` - When you are finished developing, deactivate your virtualenv with `source deactivate`
# Enabling ElasticSearch ## Enabling ElasticSearch
## Basics ### Basics
- Install jdk `sudo apt-get install openjdk-8-jdk` - Install jdk `sudo apt-get install openjdk-8-jdk`
- Install elasticsearch https://www.elastic.co/guide/en/elasticsearch/reference/current/deb.html - Install elasticsearch https://www.elastic.co/guide/en/elasticsearch/reference/current/deb.html
- `sudo systemctl enable elasticsearch.service` - `sudo systemctl enable elasticsearch.service`
@ -54,7 +46,7 @@
- Run `curl -XGET 'localhost:9200'` and make sure ES is running - Run `curl -XGET 'localhost:9200'` and make sure ES is running
- Optional: install Kabana as a search frontend for ES - Optional: install Kabana as a search frontend for ES
## Enable MySQL Binlogging ### Enable MySQL Binlogging
- Add the `[mariadb]` bin-log section to my.cnf and reload mysql server - Add the `[mariadb]` bin-log section to my.cnf and reload mysql server
- Connect to mysql - Connect to mysql
- `SHOW VARIABLES LIKE 'binlog_format';` - `SHOW VARIABLES LIKE 'binlog_format';`
@ -62,7 +54,7 @@
- Connect to root user - Connect to root user
- `GRANT REPLICATION SLAVE ON *.* TO 'test'@'localhost';` where test is the user you will be running `sync_es.py` with - `GRANT REPLICATION SLAVE ON *.* TO 'test'@'localhost';` where test is the user you will be running `sync_es.py` with
## Setting up ES ### Setting up ES
- Run `./create_es.sh` and this creates two indicies: `nyaa` and `sukebei` - Run `./create_es.sh` and this creates two indicies: `nyaa` and `sukebei`
- The output should show `acknowledged: true` twice - The output should show `acknowledged: true` twice
- The safest bet is to disable the webapp here to ensure there's no database writes - The safest bet is to disable the webapp here to ensure there's no database writes
@ -70,7 +62,7 @@
- Run `python import_to_es.py` with `SITE_FLAVOR` set to `sukebei` - Run `python import_to_es.py` with `SITE_FLAVOR` set to `sukebei`
- These will take some time to run as it's indexing - These will take some time to run as it's indexing
## Setting up sync_es.py ### Setting up sync_es.py
- Sync_es.py keeps the ElasticSearch index updated by reading the BinLog - Sync_es.py keeps the ElasticSearch index updated by reading the BinLog
- Configure the MySQL options with the user where you granted the REPLICATION permissions - Configure the MySQL options with the user where you granted the REPLICATION permissions
- Connect to MySQL, run `SHOW MASTER STATUS;`. - Connect to MySQL, run `SHOW MASTER STATUS;`.
@ -78,9 +70,13 @@
- Set up `sync_es.py` as a service and run it, preferably as the system/root - Set up `sync_es.py` as a service and run it, preferably as the system/root
- Make sure `sync_es.py` runs within venv with the right dependencies - Make sure `sync_es.py` runs within venv with the right dependencies
## Good to go! Enable the `USE_ELASTIC_SEARCH` flag in `config.py`, restart the application, and you're good to go.
- After that, enable the `USE_ELASTIC_SEARCH` flag and restart the webapp and you're good to go
## Database migrations
- Uses [flask-Migrate](https://flask-migrate.readthedocs.io/)
- Run `./db_migrate.py db migrate` to generate the migration script after database model changes.
- Take a look at the result in `migrations/versions/...` to make sure nothing went wrong.
- Run `./db_migrate.py db upgrade` to upgrade your database.
## Code Quality: ## Code Quality:
- Remember to follow PEP8 style guidelines and run `./lint.sh` before committing. - Remember to follow PEP8 style guidelines and run `./lint.sh` before committing.

View file

@ -11,7 +11,7 @@ ENABLE_SHOW_STATS = False
BASE_DIR = os.path.abspath(os.path.dirname(__file__)) BASE_DIR = os.path.abspath(os.path.dirname(__file__))
if USE_MYSQL: if USE_MYSQL:
SQLALCHEMY_DATABASE_URI = ('mysql://test:test123@localhost/nyaav2') SQLALCHEMY_DATABASE_URI = ('mysql://test:test123@localhost/nyaav2?charset=utf8mb4')
else: else:
SQLALCHEMY_DATABASE_URI = ( SQLALCHEMY_DATABASE_URI = (
'sqlite:///' + os.path.join(BASE_DIR, 'test.db') + '?check_same_thread=False') 'sqlite:///' + os.path.join(BASE_DIR, 'test.db') + '?check_same_thread=False')

View file

@ -33,9 +33,3 @@ if not existing_cats:
db.session.add(main_cat) db.session.add(main_cat)
db.session.commit() 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)')

13
db_migrate.py Normal file
View file

@ -0,0 +1,13 @@
#!/usr/bin/python3
# -*- coding: utf-8 -*-
from nyaa import app, db
from flask_script import Manager
from flask_migrate import Migrate, MigrateCommand
migrate = Migrate(app, db)
manager = Manager(app)
manager.add_command("db", MigrateCommand)
if __name__ == "__main__":
manager.run()

View file

@ -21,6 +21,7 @@ settings:
- resolution - resolution
- lowercase - lowercase
- my_ngram - my_ngram
- word_delimit
filter: filter:
my_ngram: my_ngram:
type: edgeNGram type: edgeNGram
@ -28,7 +29,11 @@ settings:
max_gram: 15 max_gram: 15
resolution: resolution:
type: pattern_capture type: pattern_capture
patterns: ["(\\d+)x(\\d+)"] patterns: ["(\\d+)[xX](\\d+)"]
word_delimit:
type: word_delimiter
preserve_original: true
split_on_numerics: false
char_filter: char_filter:
my_char_filter: my_char_filter:
type: mapping type: mapping

1
migrations/README Normal file
View file

@ -0,0 +1 @@
Generic single-database configuration.

45
migrations/alembic.ini Normal file
View file

@ -0,0 +1,45 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

87
migrations/env.py Normal file
View file

@ -0,0 +1,87 @@
from __future__ import with_statement
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig
import logging
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from flask import current_app
config.set_main_option('sqlalchemy.url',
current_app.config.get('SQLALCHEMY_DATABASE_URI'))
target_metadata = current_app.extensions['migrate'].db.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.readthedocs.org/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
engine = engine_from_config(config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool)
connection = engine.connect()
context.configure(connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
**current_app.extensions['migrate'].configure_args)
try:
with context.begin_transaction():
context.run_migrations()
finally:
connection.close()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View file

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,30 @@
"""Add uploader_ip column to torrents table.
Revision ID: 3001f79b7722
Revises:
Create Date: 2017-05-21 18:01:35.472717
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3001f79b7722'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('nyaa_torrents', sa.Column('uploader_ip', sa.Binary(), nullable=True))
op.add_column('sukebei_torrents', sa.Column('uploader_ip', sa.Binary(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('nyaa_torrents', 'uploader_ip')
op.drop_column('sukebei_torrents', 'uploader_ip')
# ### end Alembic commands ###

View file

@ -0,0 +1,48 @@
"""Add comments table.
Revision ID: d0eeb8049623
Revises: 3001f79b7722
Create Date: 2017-05-22 22:58:12.039149
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd0eeb8049623'
down_revision = '3001f79b7722'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('nyaa_comments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('torrent_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('created_time', sa.DateTime(), nullable=True),
sa.Column('text', sa.String(length=255, collation='utf8mb4_bin'), nullable=False),
sa.ForeignKeyConstraint(['torrent_id'], ['nyaa_torrents.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('sukebei_comments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('torrent_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('created_time', sa.DateTime(), nullable=True),
sa.Column('text', sa.String(length=255, collation='utf8mb4_bin'), nullable=False),
sa.ForeignKeyConstraint(['torrent_id'], ['sukebei_torrents.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('nyaa_comments')
op.drop_table('sukebei_comments')
# ### end Alembic commands ###

View file

@ -12,6 +12,7 @@ app.config.from_object('config')
# Database # Database
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['MYSQL_DATABASE_CHARSET'] = 'utf8mb4'
# Don't refresh cookie each request # Don't refresh cookie each request
app.config['SESSION_REFRESH_EACH_REQUEST'] = False app.config['SESSION_REFRESH_EACH_REQUEST'] = False
@ -37,7 +38,7 @@ if not app.config['DEBUG']:
def internal_error(exception): def internal_error(exception):
app.logger.error(exception) app.logger.error(exception)
flask.flash(flask.Markup( flask.flash(flask.Markup(
'<strong>An error occured!</strong> Debugging information has been logged.'), 'danger') '<strong>An error occurred!</strong> Debug information has been logged.'), 'danger')
return flask.redirect('/') return flask.redirect('/')
# Get git commit hash # Get git commit hash

View file

@ -6,6 +6,9 @@ from nyaa import models, forms
from nyaa import bencode, backend, utils from nyaa import bencode, backend, utils
from nyaa import torrents from nyaa import torrents
# For _create_upload_category_choices
from nyaa import routes
import functools import functools
import json import json
import os.path import os.path
@ -42,84 +45,7 @@ def api_require_user(f):
return decorator return decorator
def validate_user(upload_request):
auth_info = None
try:
if 'auth_info' in upload_request.files:
auth_info = json.loads(upload_request.files['auth_info'].read().decode('utf-8'))
if 'username' not in auth_info.keys() or 'password' not in auth_info.keys():
return False, None, None
username = auth_info['username']
password = auth_info['password']
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 False, None, None
return True, user, None
else:
return False, None, None
except Exception as e:
return False, None, e
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
# #################################### API ROUTES #################################### # #################################### API ROUTES ####################################
def api_upload(upload_request, user):
form_info = None
try:
form_info = json.loads(upload_request.files['torrent_info'].read().decode('utf-8'))
form_info_as_dict = []
for k, v in form_info.items():
if k in ['is_anonymous', 'is_hidden', 'is_remake', 'is_complete']:
if v:
form_info_as_dict.append((k, v))
else:
form_info_as_dict.append((k, v))
form_info = ImmutableMultiDict(form_info_as_dict)
except Exception as e:
return flask.make_response(flask.jsonify(
{'Failure': ['Invalid data. See HELP in api_uploader.py']}), 400)
try:
torrent_file = upload_request.files['torrent_file']
torrent_file = ImmutableMultiDict([('torrent_file', torrent_file)])
except Exception as e:
return flask.make_response(flask.jsonify(
{'Failure': ['No torrent file was attached.']}), 400)
form = forms.UploadForm(CombinedMultiDict((torrent_file, form_info)))
form.category.choices = _create_upload_category_choices()
if upload_request.method == 'POST' and form.validate():
torrent = backend.handle_torrent_upload(form, user, True)
return flask.make_response(flask.jsonify({'Success': int('{0}'.format(torrent.id))}), 200)
else:
return_error_messages = []
for error_name, error_messages in form.errors.items():
return_error_messages.extend(error_messages)
return flask.make_response(flask.jsonify({'Failure': return_error_messages}), 400)
# V2 below
# Map UploadForm fields to API keys # Map UploadForm fields to API keys
UPLOAD_API_FORM_KEYMAP = { UPLOAD_API_FORM_KEYMAP = {
@ -134,19 +60,20 @@ UPLOAD_API_FORM_KEYMAP = {
'is_trusted': 'trusted' 'is_trusted': 'trusted'
} }
UPLOAD_API_FORM_KEYMAP_REVERSE = {v: k for k, v in UPLOAD_API_FORM_KEYMAP.items()} UPLOAD_API_FORM_KEYMAP_REVERSE = {v: k for k, v in UPLOAD_API_FORM_KEYMAP.items()}
UPLOAD_API_KEYS = [ UPLOAD_API_DEFAULTS = {
'name', 'name': '',
'category', 'category': '',
'anonymous', 'anonymous': False,
'hidden', 'hidden': False,
'complete', 'complete': False,
'remake', 'remake': False,
'trusted', 'trusted': True,
'information', 'information': '',
'description' 'description': ''
] }
@api_blueprint.route('/upload', methods=['POST'])
@api_blueprint.route('/v2/upload', methods=['POST']) @api_blueprint.route('/v2/upload', methods=['POST'])
@basic_auth_user @basic_auth_user
@api_require_user @api_require_user
@ -158,16 +85,21 @@ def v2_api_upload():
request_data_field = flask.request.form.get('torrent_data') request_data_field = flask.request.form.get('torrent_data')
if request_data_field is None: if request_data_field is None:
return flask.jsonify({'errors': ['missing torrent_data field']}), 400 return flask.jsonify({'errors': ['missing torrent_data field']}), 400
try:
request_data = json.loads(request_data_field) request_data = json.loads(request_data_field)
except json.decoder.JSONDecodeError:
return flask.jsonify({'errors': ['unable to parse valid JSON in torrent_data']}), 400
# Map api keys to upload form fields # Map api keys to upload form fields
for key in UPLOAD_API_KEYS: for key, default in UPLOAD_API_DEFAULTS.items():
mapped_key = UPLOAD_API_FORM_KEYMAP_REVERSE.get(key, key) mapped_key = UPLOAD_API_FORM_KEYMAP_REVERSE.get(key, key)
mapped_dict[mapped_key] = request_data.get(key) or '' value = request_data.get(key, default)
mapped_dict[mapped_key] = value if value is not None else default
# Flask-WTF (very helpfully!!) automatically grabs the request form, so force a None formdata # Flask-WTF (very helpfully!!) automatically grabs the request form, so force a None formdata
upload_form = forms.UploadForm(None, data=mapped_dict) upload_form = forms.UploadForm(None, data=mapped_dict, meta={'csrf': False})
upload_form.category.choices = _create_upload_category_choices() upload_form.category.choices = routes._create_upload_category_choices()
if upload_form.validate(): if upload_form.validate():
torrent = backend.handle_torrent_upload(upload_form, flask.g.user) torrent = backend.handle_torrent_upload(upload_form, flask.g.user)
@ -186,3 +118,139 @@ def v2_api_upload():
# Map errors back from form fields into the api keys # Map errors back from form fields into the api keys
mapped_errors = {UPLOAD_API_FORM_KEYMAP.get(k, k): v for k, v in upload_form.errors.items()} mapped_errors = {UPLOAD_API_FORM_KEYMAP.get(k, k): v for k, v in upload_form.errors.items()}
return flask.jsonify({'errors': mapped_errors}), 400 return flask.jsonify({'errors': mapped_errors}), 400
# #################################### TEMPORARY ####################################
from orderedset import OrderedSet # noqa: E402
@api_blueprint.route('/ghetto_import', methods=['POST'])
def ghetto_import():
if flask.request.remote_addr != '127.0.0.1':
return flask.error(403)
torrent_file = flask.request.files.get('torrent')
try:
torrent_dict = bencode.decode(torrent_file)
# field.data.close()
except (bencode.MalformedBencodeException, UnicodeError):
return 'Malformed torrent file', 500
try:
forms._validate_torrent_metadata(torrent_dict)
except AssertionError as e:
return 'Malformed torrent metadata ({})'.format(e.args[0]), 500
try:
tracker_found = forms._validate_trackers(torrent_dict)
except AssertionError as e:
return 'Malformed torrent trackers ({})'.format(e.args[0]), 500
bencoded_info_dict = bencode.encode(torrent_dict['info'])
info_hash = utils.sha1_hash(bencoded_info_dict)
# Check if the info_hash exists already in the database
torrent = models.Torrent.by_info_hash(info_hash)
if not torrent:
return 'This torrent does not exists', 500
if torrent.has_torrent:
return 'This torrent already has_torrent', 500
# Torrent is legit, pass original filename and dict along
torrent_data = forms.TorrentFileData(filename=os.path.basename(torrent_file.filename),
torrent_dict=torrent_dict,
info_hash=info_hash,
bencoded_info_dict=bencoded_info_dict)
# The torrent has been validated and is safe to access with ['foo'] etc - all relevant
# keys and values have been checked for (see UploadForm in forms.py for details)
info_dict = torrent_data.torrent_dict['info']
changed_to_utf8 = backend._replace_utf8_values(torrent_data.torrent_dict)
torrent_filesize = info_dict.get('length') or sum(
f['length'] for f in info_dict.get('files'))
# In case no encoding, assume UTF-8.
torrent_encoding = torrent_data.torrent_dict.get('encoding', b'utf-8').decode('utf-8')
# Store bencoded info_dict
torrent.info = models.TorrentInfo(info_dict=torrent_data.bencoded_info_dict)
torrent.has_torrent = True
# To simplify parsing the filelist, turn single-file torrent into a list
torrent_filelist = info_dict.get('files')
used_path_encoding = changed_to_utf8 and 'utf-8' or torrent_encoding
parsed_file_tree = dict()
if not torrent_filelist:
# If single-file, the root will be the file-tree (no directory)
file_tree_root = parsed_file_tree
torrent_filelist = [{'length': torrent_filesize, 'path': [info_dict['name']]}]
else:
# If multi-file, use the directory name as root for files
file_tree_root = parsed_file_tree.setdefault(
info_dict['name'].decode(used_path_encoding), {})
# Parse file dicts into a tree
for file_dict in torrent_filelist:
# Decode path parts from utf8-bytes
path_parts = [path_part.decode(used_path_encoding) for path_part in file_dict['path']]
filename = path_parts.pop()
current_directory = file_tree_root
for directory in path_parts:
current_directory = current_directory.setdefault(directory, {})
# Don't add empty filenames (BitComet directory)
if filename:
current_directory[filename] = file_dict['length']
parsed_file_tree = utils.sorted_pathdict(parsed_file_tree)
json_bytes = json.dumps(parsed_file_tree, separators=(',', ':')).encode('utf8')
torrent.filelist = models.TorrentFilelist(filelist_blob=json_bytes)
db.session.add(torrent)
db.session.flush()
# Store the users trackers
trackers = OrderedSet()
announce = torrent_data.torrent_dict.get('announce', b'').decode('ascii')
if announce:
trackers.add(announce)
# List of lists with single item
announce_list = torrent_data.torrent_dict.get('announce-list', [])
for announce in announce_list:
trackers.add(announce[0].decode('ascii'))
# Remove our trackers, maybe? TODO ?
# Search for/Add trackers in DB
db_trackers = OrderedSet()
for announce in trackers:
tracker = models.Trackers.by_uri(announce)
# Insert new tracker if not found
if not tracker:
tracker = models.Trackers(uri=announce)
db.session.add(tracker)
db.session.flush()
db_trackers.add(tracker)
# Store tracker refs in DB
for order, tracker in enumerate(db_trackers):
torrent_tracker = models.TorrentTrackers(torrent_id=torrent.id,
tracker_id=tracker.id, order=order)
db.session.add(torrent_tracker)
db.session.commit()
return 'success'

View file

@ -1,3 +1,4 @@
import flask
from nyaa import app, db from nyaa import app, db
from nyaa import models, forms from nyaa import models, forms
from nyaa import bencode, utils from nyaa import bencode, utils
@ -8,6 +9,7 @@ import json
from werkzeug import secure_filename from werkzeug import secure_filename
from collections import OrderedDict from collections import OrderedDict
from orderedset import OrderedSet from orderedset import OrderedSet
from ipaddress import ip_address
def _replace_utf8_values(dict_or_list): def _replace_utf8_values(dict_or_list):
@ -53,7 +55,8 @@ def handle_torrent_upload(upload_form, uploading_user=None, fromAPI=False):
description=description, description=description,
encoding=torrent_encoding, encoding=torrent_encoding,
filesize=torrent_filesize, filesize=torrent_filesize,
user=uploading_user) user=uploading_user,
uploader_ip=ip_address(flask.request.remote_addr).packed)
# Store bencoded info_dict # Store bencoded info_dict
torrent.info = models.TorrentInfo(info_dict=torrent_data.bencoded_info_dict) torrent.info = models.TorrentInfo(info_dict=torrent_data.bencoded_info_dict)
@ -69,7 +72,9 @@ def handle_torrent_upload(upload_form, uploading_user=None, fromAPI=False):
torrent.complete = upload_form.is_complete.data torrent.complete = upload_form.is_complete.data
# Copy trusted status from user if possible # Copy trusted status from user if possible
can_mark_trusted = uploading_user and uploading_user.is_trusted can_mark_trusted = uploading_user and uploading_user.is_trusted
# To do, automatically mark trusted if user is trusted unless user specifies otherwise
torrent.trusted = upload_form.is_trusted.data if can_mark_trusted else False torrent.trusted = upload_form.is_trusted.data if can_mark_trusted else False
# Set category ids # Set category ids
torrent.main_category_id, torrent.sub_category_id = \ torrent.main_category_id, torrent.sub_category_id = \
upload_form.category.parsed_data.get_category_ids() upload_form.category.parsed_data.get_category_ids()
@ -134,11 +139,10 @@ def handle_torrent_upload(upload_form, uploading_user=None, fromAPI=False):
if not tracker: if not tracker:
tracker = models.Trackers(uri=announce) tracker = models.Trackers(uri=announce)
db.session.add(tracker) db.session.add(tracker)
db.session.flush()
db_trackers.add(tracker) db_trackers.add(tracker)
db.session.flush()
# Store tracker refs in DB # Store tracker refs in DB
for order, tracker in enumerate(db_trackers): for order, tracker in enumerate(db_trackers):
torrent_tracker = models.TorrentTrackers(torrent_id=torrent.id, torrent_tracker = models.TorrentTrackers(torrent_id=torrent.id,

View file

@ -1,3 +1,4 @@
import flask
from nyaa import db, app from nyaa import db, app
from nyaa.models import User from nyaa.models import User
from nyaa import bencode, utils, models from nyaa import bencode, utils, models
@ -15,6 +16,7 @@ from wtforms.widgets import Select as SelectWidget
from wtforms.widgets import html_params, HTMLString from wtforms.widgets import html_params, HTMLString
from flask_wtf.recaptcha import RecaptchaField from flask_wtf.recaptcha import RecaptchaField
from flask_wtf.recaptcha.validators import Recaptcha as RecaptchaValidator
class Unique(object): class Unique(object):
@ -35,7 +37,7 @@ class Unique(object):
_username_validator = Regexp( _username_validator = Regexp(
r'[a-zA-Z0-9_\-]+', r'^[a-zA-Z0-9_\-]+$',
message='Your username must only consist of alphanumerics and _- (a-zA-Z0-9_-)') message='Your username must only consist of alphanumerics and _- (a-zA-Z0-9_-)')
@ -124,11 +126,18 @@ class DisabledSelectField(SelectField):
raise ValueError(self.gettext('Not a valid choice')) raise ValueError(self.gettext('Not a valid choice'))
class CommentForm(FlaskForm):
comment = TextAreaField('Make a comment', [
Length(min=3, max=255, message='Comment must be at least %(min)d characters '
'long and %(max)d at most.'),
DataRequired()
])
class EditForm(FlaskForm): class EditForm(FlaskForm):
display_name = StringField('Torrent display name', [ display_name = StringField('Torrent display name', [
Length(min=3, max=255, Length(min=3, max=255, message='Torrent display name must be at least %(min)d characters '
message='Torrent display name must be at least %(min)d characters long ' 'long and %(max)d at most.')
'and %(max)d at most.')
]) ])
category = DisabledSelectField('Category') category = DisabledSelectField('Category')
@ -164,10 +173,6 @@ class EditForm(FlaskForm):
class UploadForm(FlaskForm): class UploadForm(FlaskForm):
class Meta:
csrf = False
torrent_file = FileField('Torrent file', [ torrent_file = FileField('Torrent file', [
FileRequired() FileRequired()
]) ])
@ -179,6 +184,16 @@ class UploadForm(FlaskForm):
'%(max)d at most.') '%(max)d at most.')
]) ])
if app.config['USE_RECAPTCHA']:
# Captcha only for not logged in users
_recaptcha_validator = RecaptchaValidator()
def _validate_recaptcha(form, field):
if not flask.g.user:
return UploadForm._recaptcha_validator(form, field)
recaptcha = RecaptchaField(validators=[_validate_recaptcha])
# category = SelectField('Category') # category = SelectField('Category')
category = DisabledSelectField('Category') category = DisabledSelectField('Category')
@ -263,7 +278,7 @@ class UploadForm(FlaskForm):
class UserForm(FlaskForm): class UserForm(FlaskForm):
user_class = DisabledSelectField('Change User Class') user_class = SelectField('Change User Class')
def validate_user_class(form, field): def validate_user_class(form, field):
if not field.data: if not field.data:
@ -309,7 +324,8 @@ def _validate_trackers(torrent_dict, tracker_to_check_for=None):
for announce in announce_list: for announce in announce_list:
_validate_list(announce, 'announce-list item') _validate_list(announce, 'announce-list item')
announce_string = _validate_bytes(announce[0], 'announce-list item url', test_decode='utf-8') announce_string = _validate_bytes(
announce[0], 'announce-list item url', test_decode='utf-8')
if tracker_to_check_for and announce_string.lower() == tracker_to_check_for.lower(): if tracker_to_check_for and announce_string.lower() == tracker_to_check_for.lower():
tracker_found = True tracker_found = True

View file

@ -1,3 +1,4 @@
import flask
from enum import Enum, IntEnum from enum import Enum, IntEnum
from datetime import datetime, timezone from datetime import datetime, timezone
from nyaa import app, db from nyaa import app, db
@ -6,11 +7,13 @@ from sqlalchemy import func, ForeignKeyConstraint, Index
from sqlalchemy_utils import ChoiceType, EmailType, PasswordType from sqlalchemy_utils import ChoiceType, EmailType, PasswordType
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from sqlalchemy_fulltext import FullText from sqlalchemy_fulltext import FullText
from ipaddress import ip_address
import re import re
import base64 import base64
from markupsafe import escape as escape_markup from markupsafe import escape as escape_markup
from urllib.parse import unquote as unquote_url from urllib.parse import urlencode, unquote as unquote_url
from hashlib import md5
if app.config['USE_MYSQL']: if app.config['USE_MYSQL']:
from sqlalchemy.dialects import mysql from sqlalchemy.dialects import mysql
@ -61,6 +64,7 @@ class Torrent(db.Model):
encoding = db.Column(db.String(length=32), nullable=False) encoding = db.Column(db.String(length=32), nullable=False)
flags = db.Column(db.Integer, default=0, nullable=False, index=True) flags = db.Column(db.Integer, default=0, nullable=False, index=True)
uploader_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) uploader_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
uploader_ip = db.Column(db.Binary(length=16), default=None, nullable=True)
has_torrent = db.Column(db.Boolean, nullable=False, default=False) has_torrent = db.Column(db.Boolean, nullable=False, default=False)
created_time = db.Column(db.DateTime(timezone=False), default=datetime.utcnow, nullable=False) created_time = db.Column(db.DateTime(timezone=False), default=datetime.utcnow, nullable=False)
@ -95,8 +99,10 @@ class Torrent(db.Model):
cascade="all, delete-orphan", back_populates='torrent') cascade="all, delete-orphan", back_populates='torrent')
stats = db.relationship('Statistic', uselist=False, stats = db.relationship('Statistic', uselist=False,
cascade="all, delete-orphan", back_populates='torrent', lazy='joined') cascade="all, delete-orphan", back_populates='torrent', lazy='joined')
trackers = db.relationship('TorrentTrackers', uselist=True, trackers = db.relationship('TorrentTrackers', uselist=True, cascade="all, delete-orphan",
cascade="all, delete-orphan", lazy='joined') lazy='joined', order_by='TorrentTrackers.order')
comments = db.relationship('Comment', uselist=True,
cascade="all, delete-orphan")
def __repr__(self): def __repr__(self):
return '<{0} #{1.id} \'{1.display_name}\' {1.filesize}b>'.format(type(self).__name__, self) return '<{0} #{1.id} \'{1.display_name}\' {1.filesize}b>'.format(type(self).__name__, self)
@ -138,6 +144,11 @@ class Torrent(db.Model):
def magnet_uri(self): def magnet_uri(self):
return create_magnet(self) return create_magnet(self)
@property
def uploader_ip_string(self):
if self.uploader_ip:
return str(ip_address(self.uploader_ip))
@property @property
def anonymous(self): def anonymous(self):
return self.flags & TorrentFlags.ANONYMOUS return self.flags & TorrentFlags.ANONYMOUS
@ -194,6 +205,11 @@ class Torrent(db.Model):
def by_info_hash(cls, info_hash): def by_info_hash(cls, info_hash):
return cls.query.filter_by(info_hash=info_hash).first() return cls.query.filter_by(info_hash=info_hash).first()
@classmethod
def by_info_hash_hex(cls, info_hash_hex):
info_hash_bytes = bytearray.fromhex(info_hash_hex)
return cls.by_info_hash(info_hash_bytes)
class TorrentNameSearch(FullText, Torrent): class TorrentNameSearch(FullText, Torrent):
__fulltext_columns__ = ('display_name',) __fulltext_columns__ = ('display_name',)
@ -310,10 +326,31 @@ class SubCategory(db.Model):
return cls.query.get((sub_cat_id, main_cat_id)) return cls.query.get((sub_cat_id, main_cat_id))
class Comment(db.Model):
__tablename__ = DB_TABLE_PREFIX + 'comments'
id = db.Column(db.Integer, primary_key=True)
torrent_id = db.Column(db.Integer, db.ForeignKey(
DB_TABLE_PREFIX + 'torrents.id', ondelete='CASCADE'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'))
created_time = db.Column(db.DateTime(timezone=False), default=datetime.utcnow)
text = db.Column(db.String(length=255, collation=COL_UTF8MB4_BIN), nullable=False)
user = db.relationship('User', uselist=False, back_populates='comments', lazy="joined")
def __repr__(self):
return '<Comment %r>' % self.id
@property
def created_utc_timestamp(self):
''' Returns a UTC POSIX timestamp, as seconds '''
return (self.created_time - UTC_EPOCH).total_seconds()
class UserLevelType(IntEnum): class UserLevelType(IntEnum):
REGULAR = 0 REGULAR = 0
TRUSTED = 1 TRUSTED = 1
ADMIN = 2 MODERATOR = 2
SUPERADMIN = 3 SUPERADMIN = 3
@ -339,8 +376,8 @@ class User(db.Model):
last_login_date = db.Column(db.DateTime(timezone=False), default=None, nullable=True) 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) last_login_ip = db.Column(db.Binary(length=16), default=None, nullable=True)
torrents = db.relationship('Torrent', back_populates='user', lazy="dynamic") torrents = db.relationship('Torrent', back_populates='user', lazy='dynamic')
comments = db.relationship('Comment', back_populates='user', lazy='dynamic')
# session = db.relationship('Session', uselist=False, back_populates='user') # session = db.relationship('Session', uselist=False, back_populates='user')
def __init__(self, username, email, password): def __init__(self, username, email, password):
@ -363,6 +400,39 @@ class User(db.Model):
] ]
return all(checks) return all(checks)
def gravatar_url(self):
# from http://en.gravatar.com/site/implement/images/python/
size = 120
# construct the url
default_avatar = flask.url_for('static', filename='img/avatar/default.png', _external=True)
gravatar_url = 'https://www.gravatar.com/avatar/{}?{}'.format(
md5(self.email.encode('utf-8').lower()).hexdigest(),
urlencode({'d': default_avatar, 's': str(size)}))
return gravatar_url
@property
def userlevel_str(self):
if self.level == UserLevelType.REGULAR:
return 'User'
elif self.level == UserLevelType.TRUSTED:
return 'Trusted'
elif self.level >= UserLevelType.MODERATOR:
return 'Moderator'
@property
def userlevel_color(self):
if self.level == UserLevelType.REGULAR:
return 'default'
elif self.level == UserLevelType.TRUSTED:
return 'success'
elif self.level >= UserLevelType.MODERATOR:
return 'purple'
@property
def ip_string(self):
if self.last_login_ip:
return str(ip_address(self.last_login_ip))
@classmethod @classmethod
def by_id(cls, id): def by_id(cls, id):
return cls.query.get(id) return cls.query.get(id)
@ -382,8 +452,8 @@ class User(db.Model):
return cls.by_username(username_or_email) or cls.by_email(username_or_email) return cls.by_username(username_or_email) or cls.by_email(username_or_email)
@property @property
def is_admin(self): def is_moderator(self):
return self.level >= UserLevelType.ADMIN return self.level >= UserLevelType.MODERATOR
@property @property
def is_superadmin(self): def is_superadmin(self):

View file

@ -7,11 +7,13 @@ from nyaa import torrents
from nyaa import backend from nyaa import backend
from nyaa import api_handler from nyaa import api_handler
from nyaa.search import search_elastic, search_db from nyaa.search import search_elastic, search_db
from sqlalchemy.orm import joinedload
import config import config
import re
import json import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
import ipaddress from ipaddress import ip_address
import os.path import os.path
import base64 import base64
from urllib.parse import quote from urllib.parse import quote
@ -35,6 +37,10 @@ SERACH_PAGINATE_DISPLAY_MSG = ('Displaying results {start}-{end} out of {total}
'what you were looking for.') 'what you were looking for.')
# For static_cachebuster
_static_cache = {}
def redirect_url(): def redirect_url():
url = flask.request.args.get('next') or \ url = flask.request.args.get('next') or \
flask.request.referrer or \ flask.request.referrer or \
@ -44,6 +50,31 @@ def redirect_url():
return url return url
@app.template_global()
def static_cachebuster(static_filename):
''' Adds a ?t=<mtime> cachebuster to the given path, if the file exists.
Results are cached in memory and persist until app restart! '''
# Instead of timestamps, we could use commit hashes (we already load it in __init__)
# But that'd mean every static resource would get cache busted. This lets unchanged items
# stay in the cache.
if app.debug:
# Do not bust cache on debug (helps debugging)
return static_filename
# Get file mtime if not already cached.
if static_filename not in _static_cache:
file_path = os.path.join(app.config['BASE_DIR'], 'nyaa', static_filename[1:])
if os.path.exists(file_path):
file_mtime = int(os.path.getmtime(file_path))
_static_cache[static_filename] = static_filename + '?t=' + str(file_mtime)
else:
# Throw a warning?
_static_cache[static_filename] = static_filename
return _static_cache[static_filename]
@app.template_global() @app.template_global()
def modify_query(**new_values): def modify_query(**new_values):
args = flask.request.args.copy() args = flask.request.args.copy()
@ -132,8 +163,6 @@ def get_category_id_map():
# Routes start here # # Routes start here #
app.register_blueprint(api_handler.api_blueprint, url_prefix='/api')
def chain_get(source, *args): def chain_get(source, *args):
''' Tries to return values from source by the given keys. ''' Tries to return values from source by the given keys.
Returns None if none match. Returns None if none match.
@ -145,6 +174,7 @@ def chain_get(source, *args):
return value return value
return None return None
@app.route('/rss', defaults={'rss': True}) @app.route('/rss', defaults={'rss': True})
@app.route('/', defaults={'rss': False}) @app.route('/', defaults={'rss': False})
def home(rss): def home(rss):
@ -180,6 +210,26 @@ def home(rss):
flask.abort(404) flask.abort(404)
user_id = user.id user_id = user.id
special_results = {
'first_word_user': None,
'query_sans_user': None,
'infohash_torrent': None
}
# Add advanced features to searches (but not RSS or user searches)
if search_term and not render_as_rss and not user_id:
# Check if the first word of the search is an existing user
user_word_match = re.match(r'^([a-zA-Z0-9_-]+) *(.*|$)', search_term)
if user_word_match:
special_results['first_word_user'] = models.User.by_username(user_word_match.group(1))
special_results['query_sans_user'] = user_word_match.group(2)
# Check if search is a 40-char torrent hash
infohash_match = re.match(r'(?i)^([a-f0-9]{40})$', search_term)
if infohash_match:
# Check for info hash in database
matched_torrent = models.Torrent.by_info_hash_hex(infohash_match.group(1))
special_results['infohash_torrent'] = matched_torrent
query_args = { query_args = {
'user': user_id, 'user': user_id,
'sort': sort_key or 'id', 'sort': sort_key or 'id',
@ -193,9 +243,17 @@ def home(rss):
if flask.g.user: if flask.g.user:
query_args['logged_in_user'] = flask.g.user query_args['logged_in_user'] = flask.g.user
if flask.g.user.is_admin: # God mode if flask.g.user.is_moderator: # God mode
query_args['admin'] = True query_args['admin'] = True
infohash_torrent = special_results.get('infohash_torrent')
if infohash_torrent:
# infohash_torrent is only set if this is not RSS or userpage search
flask.flash(flask.Markup('You were redirected here because '
'the given hash matched this torrent.'), 'info')
# Redirect user from search to the torrent if we found one with the specific info_hash
return flask.redirect(flask.url_for('view_torrent', torrent_id=infohash_torrent.id))
# If searching, we get results from elastic search # If searching, we get results from elastic search
use_elastic = app.config.get('USE_ELASTIC_SEARCH') use_elastic = app.config.get('USE_ELASTIC_SEARCH')
if use_elastic and search_term: if use_elastic and search_term:
@ -212,9 +270,12 @@ def home(rss):
query_results = search_elastic(**query_args) query_results = search_elastic(**query_args)
if render_as_rss: if render_as_rss:
return render_rss('"{}"'.format(search_term), query_results, use_elastic=True, magnet_links=use_magnet_links) return render_rss(
'"{}"'.format(search_term), query_results,
use_elastic=True, magnet_links=use_magnet_links)
else: else:
rss_query_string = _generate_query_string(search_term, category, quality_filter, user_name) rss_query_string = _generate_query_string(
search_term, category, quality_filter, user_name)
max_results = min(max_search_results, query_results['hits']['total']) max_results = min(max_search_results, query_results['hits']['total'])
# change p= argument to whatever you change page_parameter to or pagination breaks # change p= argument to whatever you change page_parameter to or pagination breaks
pagination = Pagination(p=query_args['page'], per_page=results_per_page, pagination = Pagination(p=query_args['page'], per_page=results_per_page,
@ -225,7 +286,8 @@ def home(rss):
pagination=pagination, pagination=pagination,
torrent_query=query_results, torrent_query=query_results,
search=query_args, search=query_args,
rss_filter=rss_query_string) rss_filter=rss_query_string,
special_results=special_results)
else: else:
# If ES is enabled, default to db search for browsing # If ES is enabled, default to db search for browsing
if use_elastic: if use_elastic:
@ -237,7 +299,8 @@ def home(rss):
if render_as_rss: if render_as_rss:
return render_rss('Home', query, use_elastic=False, magnet_links=use_magnet_links) return render_rss('Home', query, use_elastic=False, magnet_links=use_magnet_links)
else: else:
rss_query_string = _generate_query_string(search_term, category, quality_filter, user_name) rss_query_string = _generate_query_string(
search_term, category, quality_filter, user_name)
# Use elastic is always false here because we only hit this section # Use elastic is always false here because we only hit this section
# if we're browsing without a search term (which means we default to DB) # if we're browsing without a search term (which means we default to DB)
# or if ES is disabled # or if ES is disabled
@ -245,7 +308,8 @@ def home(rss):
use_elastic=False, use_elastic=False,
torrent_query=query, torrent_query=query,
search=query_args, search=query_args,
rss_filter=rss_query_string) rss_filter=rss_query_string,
special_results=special_results)
@app.route('/user/<user_name>', methods=['GET', 'POST']) @app.route('/user/<user_name>', methods=['GET', 'POST'])
@ -255,22 +319,23 @@ def view_user(user_name):
if not user: if not user:
flask.abort(404) flask.abort(404)
if flask.g.user and flask.g.user.id != user.id: admin_form = None
admin = flask.g.user.is_admin if flask.g.user and flask.g.user.is_moderator and flask.g.user.level > user.level:
superadmin = flask.g.user.is_superadmin admin_form = forms.UserForm()
else: default, admin_form.user_class.choices = _create_user_class_choices(user)
admin = False if flask.request.method == 'GET':
superadmin = False admin_form.user_class.data = default
form = forms.UserForm() if flask.request.method == 'POST' and admin_form and admin_form.validate():
form.user_class.choices = _create_user_class_choices() selection = admin_form.user_class.data
if flask.request.method == 'POST' and form.validate():
selection = form.user_class.data
if selection == 'regular': if selection == 'regular':
user.level = models.UserLevelType.REGULAR user.level = models.UserLevelType.REGULAR
elif selection == 'trusted': elif selection == 'trusted':
user.level = models.UserLevelType.TRUSTED user.level = models.UserLevelType.TRUSTED
elif selection == 'moderator':
user.level = models.UserLevelType.MODERATOR
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
@ -310,7 +375,7 @@ def view_user(user_name):
if flask.g.user: if flask.g.user:
query_args['logged_in_user'] = flask.g.user query_args['logged_in_user'] = flask.g.user
if flask.g.user.is_admin: # God mode if flask.g.user.is_moderator: # God mode
query_args['admin'] = True query_args['admin'] = True
# Use elastic search for term searching # Use elastic search for term searching
@ -343,9 +408,7 @@ def view_user(user_name):
user_page=True, user_page=True,
rss_filter=rss_query_string, rss_filter=rss_query_string,
level=user_level, level=user_level,
admin=admin, admin_form=admin_form)
superadmin=superadmin,
form=form)
# Similar logic as home page # Similar logic as home page
else: else:
if use_elastic: if use_elastic:
@ -361,9 +424,7 @@ def view_user(user_name):
user_page=True, user_page=True,
rss_filter=rss_query_string, rss_filter=rss_query_string,
level=user_level, level=user_level,
admin=admin, admin_form=admin_form)
superadmin=superadmin,
form=form)
@app.template_filter('rfc822') @app.template_filter('rfc822')
@ -416,7 +477,7 @@ def login():
return flask.redirect(flask.url_for('login')) return flask.redirect(flask.url_for('login'))
user.last_login_date = datetime.utcnow() user.last_login_date = datetime.utcnow()
user.last_login_ip = ipaddress.ip_address(flask.request.remote_addr).packed user.last_login_ip = ip_address(flask.request.remote_addr).packed
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
@ -450,7 +511,7 @@ def register():
if flask.request.method == 'POST' and form.validate(): if flask.request.method == 'POST' and form.validate():
user = models.User(username=form.username.data.strip(), user = models.User(username=form.username.data.strip(),
email=form.email.data.strip(), password=form.password.data) email=form.email.data.strip(), password=form.password.data)
user.last_login_ip = ipaddress.ip_address(flask.request.remote_addr).packed user.last_login_ip = ip_address(flask.request.remote_addr).packed
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
@ -478,14 +539,6 @@ def profile():
form = forms.ProfileForm(flask.request.form) form = forms.ProfileForm(flask.request.form)
level = 'Regular'
if flask.g.user.is_admin:
level = 'Moderator'
if flask.g.user.is_superadmin: # check this second because we can be admin AND superadmin
level = 'Administrator'
elif flask.g.user.is_trusted:
level = 'Trusted'
if flask.request.method == 'POST' and form.validate(): if flask.request.method == 'POST' and form.validate():
user = flask.g.user user = flask.g.user
new_email = form.email.data.strip() new_email = form.email.data.strip()
@ -515,12 +568,7 @@ def profile():
flask.g.user = user flask.g.user = user
return flask.redirect('/profile') return flask.redirect('/profile')
_user = models.User.by_id(flask.g.user.id) return flask.render_template('profile.html', form=form)
username = _user.username
current_email = _user.email
return flask.render_template('profile.html', form=form, name=username, email=current_email,
level=level)
@app.route('/user/activate/<payload>') @app.route('/user/activate/<payload>')
@ -562,34 +610,65 @@ def _create_upload_category_choices():
@app.route('/upload', methods=['GET', 'POST']) @app.route('/upload', methods=['GET', 'POST'])
def upload(): def upload():
form = forms.UploadForm(CombinedMultiDict((flask.request.files, flask.request.form))) upload_form = forms.UploadForm(CombinedMultiDict((flask.request.files, flask.request.form)))
form.category.choices = _create_upload_category_choices() upload_form.category.choices = _create_upload_category_choices()
if flask.request.method == 'POST' and form.validate(): if flask.request.method == 'POST' and upload_form.validate():
torrent = backend.handle_torrent_upload(form, flask.g.user) torrent = backend.handle_torrent_upload(upload_form, flask.g.user)
return flask.redirect('/view/' + str(torrent.id)) return flask.redirect('/view/' + str(torrent.id))
else: else:
# If we get here with a POST, it means the form data was invalid: return a non-okay status # If we get here with a POST, it means the form data was invalid: return a non-okay status
status_code = 400 if flask.request.method == 'POST' else 200 status_code = 400 if flask.request.method == 'POST' else 200
return flask.render_template('upload.html', form=form, user=flask.g.user), status_code return flask.render_template('upload.html', upload_form=upload_form), status_code
@app.route('/view/<int:torrent_id>') @app.route('/view/<int:torrent_id>', methods=['GET', 'POST'])
def view_torrent(torrent_id): def view_torrent(torrent_id):
if flask.request.method == 'POST':
torrent = models.Torrent.by_id(torrent_id) torrent = models.Torrent.by_id(torrent_id)
else:
viewer = flask.g.user torrent = models.Torrent.query \
.options(joinedload('filelist'),
joinedload('comments')) \
.filter_by(id=torrent_id) \
.first()
if not torrent: if not torrent:
flask.abort(404) flask.abort(404)
# Only allow admins see deleted torrents # Only allow admins see deleted torrents
if torrent.deleted and not (viewer and viewer.is_admin): if torrent.deleted and not (flask.g.user and flask.g.user.is_moderator):
flask.abort(404) flask.abort(404)
comment_form = None
if flask.g.user:
comment_form = forms.CommentForm()
if flask.request.method == 'POST':
if not flask.g.user:
flask.abort(403)
if comment_form.validate():
comment_text = (comment_form.comment.data or '').strip()
comment = models.Comment(
torrent_id=torrent_id,
user_id=flask.g.user.id,
text=comment_text)
db.session.add(comment)
db.session.commit()
torrent_count = models.Comment.query.filter_by(torrent_id=torrent.id).count()
flask.flash('Comment successfully posted.', 'success')
return flask.redirect(flask.url_for('view_torrent',
torrent_id=torrent_id,
_anchor='com-' + str(torrent_count)))
# Only allow owners and admins to edit torrents # Only allow owners and admins to edit torrents
can_edit = viewer and (viewer is torrent.user or viewer.is_admin) can_edit = flask.g.user and (flask.g.user is torrent.user or flask.g.user.is_moderator)
files = None files = None
if torrent.filelist: if torrent.filelist:
@ -598,11 +677,32 @@ def view_torrent(torrent_id):
report_form = forms.ReportForm() report_form = forms.ReportForm()
return flask.render_template('view.html', torrent=torrent, return flask.render_template('view.html', torrent=torrent,
files=files, files=files,
viewer=viewer, comment_form=comment_form,
comments=torrent.comments,
can_edit=can_edit, can_edit=can_edit,
report_form=report_form) report_form=report_form)
@app.route('/view/<int:torrent_id>/comment/<int:comment_id>/delete', methods=['POST'])
def delete_comment(torrent_id, comment_id):
if not flask.g.user:
flask.abort(403)
comment = models.Comment.query.filter_by(id=comment_id).first()
if not comment:
flask.abort(404)
if not (comment.user.id == flask.g.user.id or flask.g.user.is_moderator):
flask.abort(403)
db.session.delete(comment)
db.session.commit()
flask.flash('Comment successfully deleted.', 'success')
return flask.redirect(flask.url_for('view_torrent', torrent_id=torrent_id))
@app.route('/view/<int:torrent_id>/edit', methods=['GET', 'POST']) @app.route('/view/<int:torrent_id>/edit', methods=['GET', 'POST'])
def edit_torrent(torrent_id): def edit_torrent(torrent_id):
torrent = models.Torrent.by_id(torrent_id) torrent = models.Torrent.by_id(torrent_id)
@ -615,11 +715,11 @@ def edit_torrent(torrent_id):
flask.abort(404) flask.abort(404)
# Only allow admins edit deleted torrents # Only allow admins edit deleted torrents
if torrent.deleted and not (editor and editor.is_admin): if torrent.deleted and not (flask.g.user and flask.g.user.is_moderator):
flask.abort(404) flask.abort(404)
# Only allow torrent owners or admins edit torrents # Only allow torrent owners or admins edit torrents
if not editor or not (editor is torrent.user or editor.is_admin): if not flask.g.user or not (flask.g.user is torrent.user or flask.g.user.is_moderator):
flask.abort(403) flask.abort(403)
if flask.request.method == 'POST' and form.validate(): if flask.request.method == 'POST' and form.validate():
@ -635,15 +735,16 @@ def edit_torrent(torrent_id):
torrent.complete = form.is_complete.data torrent.complete = form.is_complete.data
torrent.anonymous = form.is_anonymous.data torrent.anonymous = form.is_anonymous.data
if editor.is_trusted: if flask.g.user.is_trusted:
torrent.trusted = form.is_trusted.data torrent.trusted = form.is_trusted.data
if editor.is_admin: if flask.g.user.is_moderator:
torrent.deleted = form.is_deleted.data torrent.deleted = form.is_deleted.data
db.session.commit() db.session.commit()
flask.flash(flask.Markup( flask.flash(flask.Markup(
'Torrent has been successfully edited! Changes might take a few minutes to show up.'), 'info') 'Torrent has been successfully edited! Changes might take a few minutes to show up.'),
'info')
return flask.redirect(flask.url_for('view_torrent', torrent_id=torrent.id)) return flask.redirect(flask.url_for('view_torrent', torrent_id=torrent.id))
else: else:
@ -664,8 +765,7 @@ def edit_torrent(torrent_id):
return flask.render_template('edit.html', return flask.render_template('edit.html',
form=form, form=form,
torrent=torrent, torrent=torrent)
editor=editor)
@app.route('/view/<int:torrent_id>/magnet') @app.route('/view/<int:torrent_id>/magnet')
@ -679,15 +779,16 @@ def redirect_magnet(torrent_id):
@app.route('/view/<int:torrent_id>/torrent') @app.route('/view/<int:torrent_id>/torrent')
@app.route('/download/<int:torrent_id>.torrent')
def download_torrent(torrent_id): def download_torrent(torrent_id):
torrent = models.Torrent.by_id(torrent_id) torrent = models.Torrent.by_id(torrent_id)
if not torrent: if not torrent or not torrent.has_torrent:
flask.abort(404) flask.abort(404)
resp = flask.Response(_get_cached_torrent_file(torrent)) resp = flask.Response(_get_cached_torrent_file(torrent))
resp.headers['Content-Type'] = 'application/x-bittorrent' resp.headers['Content-Type'] = 'application/x-bittorrent'
resp.headers['Content-Disposition'] = 'inline; filename*=UTF-8\'\'{}'.format( resp.headers['Content-Disposition'] = 'inline; filename="{0}"; filename*=UTF-8\'\'{0}'.format(
quote(torrent.torrent_name.encode('utf-8'))) quote(torrent.torrent_name.encode('utf-8')))
return resp return resp
@ -717,7 +818,7 @@ def submit_report(torrent_id):
@app.route('/reports', methods=['GET', 'POST']) @app.route('/reports', methods=['GET', 'POST'])
def view_reports(): def view_reports():
if not flask.g.user or not flask.g.user.is_admin: if not flask.g.user or not flask.g.user.is_moderator:
flask.abort(403) flask.abort(403)
page = flask.request.args.get('p', flask.request.args.get('offset', 1, int), int) page = flask.request.args.get('p', flask.request.args.get('offset', 1, int), int)
@ -800,14 +901,55 @@ def send_verification_email(to_address, activ_link):
server.quit() server.quit()
def _create_user_class_choices(): def _create_user_class_choices(user):
choices = [('regular', 'Regular')] choices = [('regular', 'Regular')]
if flask.g.user and flask.g.user.is_superadmin: default = 'regular'
if flask.g.user:
if flask.g.user.is_moderator:
choices.append(('trusted', 'Trusted')) choices.append(('trusted', 'Trusted'))
return choices if flask.g.user.is_superadmin:
choices.append(('moderator', 'Moderator'))
if user:
if user.is_moderator:
default = 'moderator'
elif user.is_trusted:
default = 'trusted'
return default, choices
@app.template_filter()
def timesince(dt, default='just now'):
"""
Returns string representing "time since" e.g.
3 minutes ago, 5 hours ago etc.
Date and time (UTC) are returned if older than 1 day.
"""
now = datetime.utcnow()
diff = now - dt
periods = (
(diff.days, 'day', 'days'),
(diff.seconds / 3600, 'hour', 'hours'),
(diff.seconds / 60, 'minute', 'minutes'),
(diff.seconds, 'second', 'seconds'),
)
if diff.days >= 1:
return dt.strftime('%Y-%m-%d %H:%M UTC')
else:
for period, singular, plural in periods:
if period >= 1:
return '%d %s ago' % (period, singular if int(period) == 1 else plural)
return default
# #################################### STATIC PAGES #################################### # #################################### STATIC PAGES ####################################
@app.route('/rules', methods=['GET']) @app.route('/rules', methods=['GET'])
def site_rules(): def site_rules():
return flask.render_template('rules.html') return flask.render_template('rules.html')
@ -818,11 +960,11 @@ def site_help():
return flask.render_template('help.html') return flask.render_template('help.html')
@app.route('/xmlns/nyaa', methods=['GET'])
def xmlns_nyaa():
return flask.render_template('xmlns.html')
# #################################### API ROUTES #################################### # #################################### API ROUTES ####################################
@app.route('/api/upload', methods=['POST'])
def api_upload(): app.register_blueprint(api_handler.api_blueprint, url_prefix='/api')
is_valid_user, user, debug = api_handler.validate_user(flask.request)
if not is_valid_user:
return flask.make_response(flask.jsonify({"Failure": "Invalid username or password."}), 400)
api_response = api_handler.api_upload(flask.request, user)
return api_response

277
nyaa/static/css/bootstrap-xl-mod.css vendored Normal file
View file

@ -0,0 +1,277 @@
/*
* CSS file with Bootstrap grid classes for screens bigger than 1600px. Just add this file after the Bootstrap CSS file and you will be able to juse col-xl, col-xl-push, hidden-xl, etc.
*
* Author: Marc van Nieuwenhuijzen
* Company: WebVakman
* Site: WebVakman.nl
*
* Edited to be for >=1480px with container width of 1400px for Nyaa.si
* Also edited to not fuck up column gutters.
*/
@media (min-width: 1200px) and (max-width: 1479px) {
.hidden-lg {
display: none !important;
}
}
.visible-xl-block,
.visible-xl-inline,
.visible-xl-inline-block,
.visible-xl{
display: none !important;
}
@media (min-width: 1480px) {
.container {
width: 1400px;
}
.col-xl-1, .col-xl-2, .col-xl-3, .col-xl-4, .col-xl-5, .col-xl-6, .col-xl-7, .col-xl-8, .col-xl-9, .col-xl-10, .col-xl-11, .col-xl-12 {
position: relative;
min-height: 1px;
padding-right: 15px;
padding-left: 15px;
float: left;
}
.col-xl-12 {
width: 100%;
}
.col-xl-11 {
width: 91.66666667%;
}
.col-xl-10 {
width: 83.33333333%;
}
.col-xl-9 {
width: 75%;
}
.col-xl-8 {
width: 66.66666667%;
}
.col-xl-7 {
width: 58.33333333%;
}
.col-xl-6 {
width: 50%;
}
.col-xl-5 {
width: 41.66666667%;
}
.col-xl-4 {
width: 33.33333333%;
}
.col-xl-3 {
width: 25%;
}
.col-xl-2 {
width: 16.66666667%;
}
.col-xl-1 {
width: 8.33333333%;
}
.col-xl-pull-12 {
right: 100%;
}
.col-xl-pull-11 {
right: 91.66666667%;
}
.col-xl-pull-10 {
right: 83.33333333%;
}
.col-xl-pull-9 {
right: 75%;
}
.col-xl-pull-8 {
right: 66.66666667%;
}
.col-xl-pull-7 {
right: 58.33333333%;
}
.col-xl-pull-6 {
right: 50%;
}
.col-xl-pull-5 {
right: 41.66666667%;
}
.col-xl-pull-4 {
right: 33.33333333%;
}
.col-xl-pull-3 {
right: 25%;
}
.col-xl-pull-2 {
right: 16.66666667%;
}
.col-xl-pull-1 {
right: 8.33333333%;
}
.col-xl-pull-0 {
right: auto;
}
.col-xl-push-12 {
left: 100%;
}
.col-xl-push-11 {
left: 91.66666667%;
}
.col-xl-push-10 {
left: 83.33333333%;
}
.col-xl-push-9 {
left: 75%;
}
.col-xl-push-8 {
left: 66.66666667%;
}
.col-xl-push-7 {
left: 58.33333333%;
}
.col-xl-push-6 {
left: 50%;
}
.col-xl-push-5 {
left: 41.66666667%;
}
.col-xl-push-4 {
left: 33.33333333%;
}
.col-xl-push-3 {
left: 25%;
}
.col-xl-push-2 {
left: 16.66666667%;
}
.col-xl-push-1 {
left: 8.33333333%;
}
.col-xl-push-0 {
left: auto;
}
.col-xl-offset-12 {
margin-left: 100%;
}
.col-xl-offset-11 {
margin-left: 91.66666667%;
}
.col-xl-offset-10 {
margin-left: 83.33333333%;
}
.col-xl-offset-9 {
margin-left: 75%;
}
.col-xl-offset-8 {
margin-left: 66.66666667%;
}
.col-xl-offset-7 {
margin-left: 58.33333333%;
}
.col-xl-offset-6 {
margin-left: 50%;
}
.col-xl-offset-5 {
margin-left: 41.66666667%;
}
.col-xl-offset-4 {
margin-left: 33.33333333%;
}
.col-xl-offset-3 {
margin-left: 25%;
}
.col-xl-offset-2 {
margin-left: 16.66666667%;
}
.col-xl-offset-1 {
margin-left: 8.33333333%;
}
.col-xl-offset-0 {
margin-left: 0;
}
.visible-xl {
display: block !important;
}
table.visible-xl {
display: table;
}
tr.visible-xl {
display: table-row !important;
}
th.visible-xl, td.visible-xl {
display: table-cell !important;
}
.visible-xl-block {
display: block !important;
}
.visible-xl-inline {
display: inline !important;
}
.visible-xl-inline-block {
display: inline-block !important;
}
.hidden-xl {
display: none !important;
}
}

View file

@ -96,6 +96,10 @@ table.torrent-list tbody tr td a:visited {
padding: 1em 0; padding: 1em 0;
} }
.markdown-source {
min-height: 360px;
}
@media (max-width: 991px){ @media (max-width: 991px){
.panel-body .col-md-5 { .panel-body .col-md-5 {
margin-left: 20px; margin-left: 20px;
@ -218,3 +222,106 @@ table.torrent-list tbody tr td a:visited {
ul.nav-tabs#profileTabs { ul.nav-tabs#profileTabs {
margin-bottom: 15px; margin-bottom: 15px;
} }
.comment-panel {
width: 99%;
margin: 0 auto;
margin-top: 10px;
margin-bottom: 10px;
}
.comment-panel:target {
border-color: black;
border-width: 2px;
}
.text-purple, a.text-purple:visited { color: #a760bc; }
a.text-purple:hover, a.text-purple:active, a.text-purple:focus { color: #a760e0; }
.comment-content {
word-break: break-all;
}
.comment-content img {
max-width: 100%;
max-height: 600px;
}
.comment-box {
width: 95%;
margin: 0 auto;
margin-top: 30px;
margin-bottom: 10px;
}
.delete-comment-form {
position: relative;
float: right;
}
.avatar {
max-width: 120px;
}
.btn-grey {
color: #000000;
background-color: #cccfd2;
border-color: #ccc;
}
.btn-grey:hover, .btn-grey:focus, .btn-grey:active, .btn-grey.active, .open > .dropdown-toggle.btn-grey {
background-color: #aaaaaa;
}
.btn span.glyphicon-check {
display: none;
}
.btn.active span.glyphicon-check {
display: inline;
}
.btn span.glyphicon-unchecked {
display: inline;
}
.btn.active span.glyphicon-unchecked {
display: none;
}
.center {
float: none;
margin: 0 auto;
text-align: center;
}
/* Torrent file list */
.torrent-file-list ul {
padding: 5px 20px 0px 20px;
list-style: none;
display: none;
}
.torrent-file-list > ul {
display: block; /* First level always visible */
padding: 0;
margin: 0;
}
.torrent-file-list ul[data-show] {
/* Used to show first level's items based on amount */
display: block;
}
.torrent-file-list li:not(:last-of-type) {
margin-bottom: 5px;
}
.torrent-file-list i.fa {
padding-right: 5px;
}
.torrent-file-list i.fa-folder-open {
padding-right: 3px;
}
.torrent-file-list a.folder {
font-weight: bold;
text-decoration: none;
}
.torrent-file-list .file-size {
font-weight: bold;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View file

@ -43,6 +43,7 @@ $(document).ready(function() {
} }
}); });
// Drag & Drop zone for upload page
$('body').on('dragenter', function(event) { $('body').on('dragenter', function(event) {
event.preventDefault(); event.preventDefault();
dropZone.css({ 'visibility': 'visible', 'opacity': 1 }); dropZone.css({ 'visibility': 'visible', 'opacity': 1 });
@ -63,6 +64,13 @@ $(document).ready(function() {
$('#torrent_file')[0].files = files; $('#torrent_file')[0].files = files;
$(this).css({ 'visibility': 'hidden', 'opacity': 0 }); $(this).css({ 'visibility': 'hidden', 'opacity': 0 });
}); });
// Collapsible file lists
$('.torrent-file-list a.folder').click(function(e) {
e.preventDefault();
$(this).blur().children('i').toggleClass('fa-folder-open fa-folder');
$(this).next().stop().slideToggle(250);
});
}); });
function _format_time_difference(seconds) { function _format_time_difference(seconds) {
@ -80,6 +88,8 @@ function _format_time_difference(seconds) {
if (seconds < 0) { if (seconds < 0) {
suffix = ""; suffix = "";
prefix = "After "; prefix = "After ";
} else if (seconds == 0) {
return "Just now"
} }
var parts = []; var parts = [];
@ -96,11 +106,12 @@ function _format_time_difference(seconds) {
} }
return prefix + parts.join(" ") + suffix; return prefix + parts.join(" ") + suffix;
} }
function _format_date(date) { function _format_date(date, show_seconds) {
var pad = function (n) { return ("00" + n).slice(-2); } var pad = function (n) { return ("00" + n).slice(-2); }
var ymd = date.getFullYear() + "-" + pad(date.getMonth()+1) + "-" + pad(date.getDate()); var ymd = date.getFullYear() + "-" + pad(date.getMonth()+1) + "-" + pad(date.getDate());
var hm = pad(date.getHours()) + ":" + pad(date.getMinutes()); var hm = pad(date.getHours()) + ":" + pad(date.getMinutes());
return ymd + " " + hm; var s = show_seconds ? ":" + pad(date.getSeconds()) : ""
return ymd + " " + hm + s;
} }
// Add title text to elements with data-timestamp attribute // Add title text to elements with data-timestamp attribute
@ -111,11 +122,20 @@ document.addEventListener("DOMContentLoaded", function(event) {
for (var i = 0; i < timestamp_targets.length; i++) { for (var i = 0; i < timestamp_targets.length; i++) {
var target = timestamp_targets[i]; var target = timestamp_targets[i];
var torrent_timestamp = parseInt(target.getAttribute('data-timestamp')); var torrent_timestamp = parseInt(target.getAttribute('data-timestamp'));
var swap_flag = target.getAttribute('data-timestamp-swap') != null;
if (torrent_timestamp) { if (torrent_timestamp) {
var timedelta = now_timestamp - torrent_timestamp; var timedelta = now_timestamp - torrent_timestamp;
target.setAttribute('title', _format_time_difference(timedelta));
target.innerText = _format_date(new Date(torrent_timestamp*1000)); var formatted_date = _format_date(new Date(torrent_timestamp*1000), swap_flag);
var formatted_timedelta = _format_time_difference(timedelta);
if (swap_flag) {
target.setAttribute('title', formatted_date);
target.innerText = formatted_timedelta;
} else {
target.setAttribute('title', formatted_timedelta);
target.innerText = formatted_date;
}
} }
}; };

View file

@ -1,5 +1,8 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block title %}404 Not Found :: {{ config.SITE_NAME }}{% endblock %} {% block title %}404 Not Found :: {{ config.SITE_NAME }}{% endblock %}
{% block metatags %}
<meta property="og:description" content="Nothing here.">
{% endblock %}
{% block body %} {% block body %}
<h1>404 Not Found</h1> <h1>404 Not Found</h1>
<p>The path you requested does not exist on this server.</p> <p>The path you requested does not exist on this server.</p>

View file

@ -37,12 +37,12 @@
{{ field.label(class='control-label') }} {{ field.label(class='control-label') }}
<ul class="nav nav-tabs" role="tablist"> <ul class="nav nav-tabs" role="tablist">
<li role="presentation" class="active"> <li role="presentation" class="active">
<a href="#{{ field_name }}-tab" aria-controls="" role="tab" data-toggle="tab"> <a href="#{{ field_name }}-tab" role="tab" data-toggle="tab">
Write Write
</a> </a>
</li> </li>
<li role="presentation"> <li role="presentation">
<a href="#{{ field_name }}-preview" id="{{ field_name }}-preview-tab" aria-controls="preview" role="tab" data-toggle="tab"> <a href="#{{ field_name }}-preview" id="{{ field_name }}-preview-tab" role="tab" data-toggle="tab">
Preview Preview
</a> </a>
</li> </li>

View file

@ -7,7 +7,7 @@
{% set torrent_url = url_for('view_torrent', torrent_id=torrent.id) %} {% set torrent_url = url_for('view_torrent', torrent_id=torrent.id) %}
<h1> <h1>
Edit Torrent <a href="{{ torrent_url }}">#{{torrent.id}}</a> Edit Torrent <a href="{{ torrent_url }}">#{{torrent.id}}</a>
{% if (torrent.user != None) and (torrent.user != editor) %} {% if (torrent.user != None) and (torrent.user != g.user) %}
(by <a href="{{ url_for('view_user', user_name=torrent.user.username) }}">{{ torrent.user.username }}</a>) (by <a href="{{ url_for('view_user', user_name=torrent.user.username) }}">{{ torrent.user.username }}</a>)
{% endif %} {% endif %}
</h1> </h1>
@ -29,38 +29,51 @@
{{ render_field(form.information, class_='form-control', placeholder='Your website or IRC channel') }} {{ render_field(form.information, class_='form-control', placeholder='Your website or IRC channel') }}
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="control-label">Torrent flags</label> <label class="control-label">Torrent flags</label><br>
<div> <div class="btn-group" data-toggle="buttons">
{% if editor.is_admin %}
<label class="btn btn-primary">
{{ form.is_deleted }}
Deleted
</label>
{% endif %}
<label class="btn btn-default" style="background-color: darkgray; border-color: #ccc;" title="Hide torrent from listing">
{{ form.is_hidden }}
Hidden
</label>
<label class="btn btn-danger" title="This torrent is derived from another release">
{{ form.is_remake }}
Remake
</label>
<label class="btn btn-primary" title="This torrent is a complete batch (eg. season)">
{{ form.is_complete }}
Complete
</label>
{# Only allow changing anonymous status when an uploader exists #} {# Only allow changing anonymous status when an uploader exists #}
{% if torrent.uploader_id %} {% if torrent.uploader_id %}
<label class="btn btn-primary" title="Upload torrent anonymously (don't display your username)"> <label class="btn btn-default {% if torrent.anonymous %}active{% endif %}" title="Upload torrent anonymously (don't display your username)">
{{ form.is_anonymous }} {{ form.is_anonymous }}
<span class="glyphicon glyphicon-check"></span>
<span class="glyphicon glyphicon-unchecked"></span>
Anonymous Anonymous
</label> </label>
{% endif %} {% endif %}
{% if editor.is_trusted %} <label class="btn btn-grey {% if torrent.hidden %}active{% endif %}" title="Hide torrent from listing">
<label class="btn btn-success" title="Mark torrent trusted"> {{ form.is_hidden }}
<span class="glyphicon glyphicon-check"></span>
<span class="glyphicon glyphicon-unchecked"></span>
Hidden
</label>
{% if g.user.is_moderator %}
<label class="btn btn-primary {% if torrent.deleted %}active{% endif %}">
{{ form.is_deleted }}
<span class="glyphicon glyphicon-check"></span>
<span class="glyphicon glyphicon-unchecked"></span>
Deleted
</label>
{% endif %}
</div>
<div class="hidden-xl"><br></div>
<div class="btn-group" data-toggle="buttons">
<label class="btn btn-danger {% if torrent.remake %}active{% endif %}" title="This torrent is derived from another release">
{{ form.is_remake }}
<span class="glyphicon glyphicon-check"></span>
<span class="glyphicon glyphicon-unchecked"></span>
Remake
</label>
<label class="btn btn-warning {% if torrent.complete %}active{% endif %}" title="This torrent is a complete batch (eg. season)">
{{ form.is_complete }}
<span class="glyphicon glyphicon-check"></span>
<span class="glyphicon glyphicon-unchecked"></span>
Complete
</label>
{% if g.user.is_trusted %}
<label class="btn btn-success {% if torrent.trusted %}active{% endif %}" title="Mark torrent trusted">
{{ form.is_trusted }} {{ form.is_trusted }}
<span class="glyphicon glyphicon-check"></span>
<span class="glyphicon glyphicon-unchecked"></span>
Trusted Trusted
</label> </label>
{% endif %} {% endif %}

View file

@ -1,15 +1,22 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block title %}{% if search.term %}{{ search.term | e}}{% else %}Browse{% endif %} :: {{ config.SITE_NAME }}{% endblock %} {% block title %}{% if search.term %}{{ search.term | e}}{% else %}Browse{% endif %} :: {{ config.SITE_NAME }}{% endblock %}
{% block metatags %}
{% if search.term %}
<meta property="og:description" content="Search for '{{ search.term }}'">
{% else %}
<meta property="og:description" content="{{ config.SITE_NAME }} homepage">
{% endif %}
{% endblock %}
{% block body %} {% block body %}
{% if search["term"] == '' %}
<div class="alert alert-info"> <div class="alert alert-info">
<p><strong>5/18 Update:</strong> We've added an upload api for ease of uploading. See documentation <a href="https://github.com/nyaadevs/nyaa/blob/master/utils/api_uploader.py">here</a>.</p> <p><strong>2017-05-22 Update:</strong> We've added comments. You can change your avatar using <a href="http://en.gravatar.com">Gravatar</a> or if you don't like gravatar you can just stick with our spify default avatar.</p>
<p><strong>5/17 Update:</strong> We've added faster and more accurate search! In addition to your typical keyword search in both English and other languages, you can also now use powerful operators <p><strong>2017-05-22 Update:</strong> We've updated our upload API to v2 (v1 <b>is now disabled!</b>). See documentation <b><a href="https://github.com/nyaadevs/nyaa/blob/master/utils/api_uploader_v2.py">here</a></b>.</p>
like <kbd>clockwork planet -horrible</kbd> or <kbd>commie|horrible|cartel yowamushi</kbd> to search. For all supported operators, please click <a href="https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html#_simple_query_string_syntax">here</a>. More features are coming soon!</p><br>
<p>We welcome you to provide feedback at <a href="irc://irc.rizon.net/nyaa-dev">#nyaa-dev@irc.rizon.net</a></p> <p>We welcome you to provide feedback at <a href="irc://irc.rizon.net/nyaa-dev">#nyaa-dev@irc.rizon.net</a></p>
<p>Our GitHub: <a href="https://github.com/nyaadevs" target="_blank">https://github.com/nyaadevs</a> - creating <a href="https://github.com/nyaadevs/nyaa/issues">issues</a> for features and faults is recommendable!</p> <p>Our GitHub: <a href="https://github.com/nyaadevs" target="_blank">https://github.com/nyaadevs</a> - creating <a href="https://github.com/nyaadevs/nyaa/issues">issues</a> for features and faults is recommendable!</p>
</div> </div>
{% endif %}
{% include "search_results.html" %} {% include "search_results.html" %}

View file

@ -4,38 +4,49 @@
<meta charset="utf-8"> <meta charset="utf-8">
<title>{% block title %}{{ config.SITE_NAME }}{% endblock %}</title> <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 name="viewport" content="width=device-width">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<link rel="shortcut icon" type="image/png" href="/static/favicon.png"> <link rel="shortcut icon" type="image/png" href="/static/favicon.png">
<link rel="icon" type="image/png" href="/static/favicon.png"> <link rel="icon" type="image/png" href="/static/favicon.png">
<link rel="mask-icon" href="/static/pinned-tab.svg" color="#3582F7"> <link rel="mask-icon" href="/static/pinned-tab.svg" color="#3582F7">
<link rel="alternate" type="application/rss+xml" href="{% if rss_filter %}{{ url_for('home', page='rss', _external=True, **rss_filter) }}{% else %}{{ url_for('home', page='rss', _external=True) }}{% endif %}" /> <link rel="alternate" type="application/rss+xml" href="{% if rss_filter %}{{ url_for('home', page='rss', _external=True, **rss_filter) }}{% else %}{{ url_for('home', page='rss', _external=True) }}{% endif %}" />
<meta property="og:site_name" content="{{ config.SITE_NAME }}">
<meta property="og:title" content="{{ self.title() }}">
<meta property="og:image" content="{% block meta_image %}/static/img/avatar/default.png{% endblock %}">
{% block metatags %}
{# Filled by children #}
{% endblock %}
<!-- Bootstrap core CSS --> <!-- Bootstrap core CSS -->
<!-- <!--
Note: This has been customized at http://getbootstrap.com/customize/ to Note: This has been customized at http://getbootstrap.com/customize/ to
set the column breakpoint to tablet mode, instead of mobile. This is to set the column breakpoint to tablet mode, instead of mobile. This is to
make the navbar not look awful on tablets. make the navbar not look awful on tablets.
--> -->
<link href="/static/css/bootstrap.min.css" rel="stylesheet" id="bsThemeLink"> {# These are extracted here for the dark mode toggle #}
{% set bootstrap_light = static_cachebuster('/static/css/bootstrap.min.css') %}
{% set bootstrap_dark = static_cachebuster('/static/css/bootstrap-dark.min.css') %}
<link href="{{ bootstrap_light }}" rel="stylesheet" id="bsThemeLink">
<link href="{{ static_cachebuster('/static/css/bootstrap-xl-mod.css') }}" rel="stylesheet">
<!-- <!--
This theme changer script needs to be inline and right under the above stylesheet link to prevent FOUC (Flash Of Unstyled Content) 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 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> <script>function toggleDarkMode(){"dark"===localStorage.getItem("theme")?setThemeLight():setThemeDark()}function setThemeDark(){bsThemeLink.href="{{ bootstrap_dark }}",localStorage.setItem("theme","dark")}function setThemeLight(){bsThemeLink.href="{{ bootstrap_light }}",localStorage.setItem("theme","light")}if("undefined"!=typeof Storage){var bsThemeLink=document.getElementById("bsThemeLink");"dark"===localStorage.getItem("theme")&&setThemeDark()}</script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-select/1.12.2/css/bootstrap-select.min.css" integrity="sha256-an4uqLnVJ2flr7w0U74xiF4PJjO2N5Df91R2CUmCLCA=" crossorigin="anonymous" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-select/1.12.2/css/bootstrap-select.min.css" integrity="sha256-an4uqLnVJ2flr7w0U74xiF4PJjO2N5Df91R2CUmCLCA=" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css" integrity="sha256-eZrrJcwDc/3uDhsdt61sL2oOBY362qM3lon1gyExkL0=" crossorigin="anonymous" /> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css" integrity="sha256-eZrrJcwDc/3uDhsdt61sL2oOBY362qM3lon1gyExkL0=" crossorigin="anonymous" />
<!-- Custom styles for this template --> <!-- Custom styles for this template -->
<link href="/static/css/main.css" rel="stylesheet"> <link href="{{ static_cachebuster('/static/css/main.css') }}" rel="stylesheet">
<!-- Core JavaScript --> <!-- Core JavaScript -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js" integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4=" crossorigin="anonymous"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js" integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha256-U5ZEeKfGNOja007MMD3YBI0A3OSZOQbeG6z2f2Y0hu8=" crossorigin="anonymous"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha256-U5ZEeKfGNOja007MMD3YBI0A3OSZOQbeG6z2f2Y0hu8=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/commonmark/0.27.0/commonmark.min.js" integrity="sha256-10JreQhQG80GtKuzsioj0K46DlaB/CK/EG+NuG0q97E=" crossorigin="anonymous"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/commonmark/0.27.0/commonmark.min.js" integrity="sha256-10JreQhQG80GtKuzsioj0K46DlaB/CK/EG+NuG0q97E=" crossorigin="anonymous"></script>
<!-- Modified to not apply border-radius to selectpickers and stuff so our navbar looks cool --> <!-- 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_cachebuster('/static/js/bootstrap-select.js') }}"></script>
<script src="/static/js/main.js"></script> <script src="{{ static_cachebuster('/static/js/main.js') }}"></script>
<!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries --> <!-- HTML5 shim and Respond.js for IE8 support of HTML5 elements and media queries -->
<!--[if lt IE 9]> <!--[if lt IE 9]>
@ -56,6 +67,8 @@
</button> </button>
<a class="navbar-brand" href="/">{{ config.SITE_NAME }}</a> <a class="navbar-brand" href="/">{{ config.SITE_NAME }}</a>
</div> </div>
{% set search_username = (user.username + ("'" if user.username[-1] == 's' else "'s")) if user_page else None %}
{% set search_placeholder = 'Search {} torrents...'.format(search_username) if user_page else 'Search...' %}
<div id="navbar" class="navbar-collapse collapse"> <div id="navbar" class="navbar-collapse collapse">
<ul class="nav navbar-nav"> <ul class="nav navbar-nav">
<li {% if request.path == "/upload" %} class="active"{% endif %}><a href="/upload">Upload</a></li> <li {% if request.path == "/upload" %} class="active"{% endif %}><a href="/upload">Upload</a></li>
@ -75,8 +88,8 @@
{% elif config.SITE_FLAVOR == 'sukebei' %} {% elif config.SITE_FLAVOR == 'sukebei' %}
<li><a href="https://nyaa.si/">Fun</a></li> <li><a href="https://nyaa.si/">Fun</a></li>
{% endif %} {% endif %}
{% if g.user.is_admin %} {% if g.user.is_moderator %}
<li><a href="{{ url_for('view_reports') }}">Reports</a></li> <li><a href="{{ url_for('view_reports') }}">Reports</a> </li>
{% endif %} {% endif %}
</ul> </ul>
@ -85,7 +98,7 @@
<li class="dropdown"> <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"> <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> <i class="fa fa-user fa-fw"></i>
{{g.user.username}} {{ g.user.username }}
<span class="caret"></span> <span class="caret"></span>
</a> </a>
<a href="#" class="dropdown-toggle hidden-lg hidden-sm hidden-xs" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false"> <a href="#" class="dropdown-toggle hidden-lg hidden-sm hidden-xs" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
@ -121,11 +134,15 @@
</li> </li>
{% else %} {% else %}
<li class="dropdown"> <li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false"> <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-times fa-fw"></i> <i class="fa fa-user fa-fw"></i>
Guest Guest
<span class="caret"></span> <span class="caret"></span>
</a> </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"> <ul class="dropdown-menu">
<li> <li>
<a href="/login"> <a href="/login">
@ -143,21 +160,6 @@
</li> </li>
{% endif %} {% endif %}
</ul> </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 search-container">
<input type="text" class="form-control search-bar" name="q" placeholder="Search..." value="{{ search["term"] if search is defined else '' }}">
<div class="input-group-btn nav-filter" id="navFilter-criteria">
<select class="selectpicker show-tick" title="Filter" data-width="120px" name="f">
<option value="0" title="No filter" {% if search is defined and search["quality_filter"] == "0" %}selected{% else %}selected{% endif %}>No filter</option>
<option value="1" title="No remakes" {% if search is defined and search["quality_filter"] == "1" %}selected{% endif %}>No remakes</option>
<option value="2" title="Trusted only" {% if search is defined and search["quality_filter"] == "2" %}selected{% endif %}>Trusted only</option>
</select>
</div>
<div class="input-group-btn nav-filter" id="navFilter-category">
{% set nyaa_cats = [('1_0', 'Anime', 'Anime'), {% set nyaa_cats = [('1_0', 'Anime', 'Anime'),
('1_1', '- Anime Music Video', 'Anime - AMV'), ('1_1', '- Anime Music Video', 'Anime - AMV'),
('1_2', '- English-translated', 'Anime - English'), ('1_2', '- English-translated', 'Anime - English'),
@ -180,7 +182,9 @@
('5_2', '- Photos', 'Pictures - Photos'), ('5_2', '- Photos', 'Pictures - Photos'),
('6_0', 'Software', 'Software'), ('6_0', 'Software', 'Software'),
('6_1', '- Applications', 'Software - Apps'), ('6_1', '- Applications', 'Software - Apps'),
('6_2', '- Games', 'Software - Games')] %} ('6_2', '- Games', 'Software - Games')]
%}
{% set suke_cats = [('1_0', 'Art', 'Art'), {% set suke_cats = [('1_0', 'Art', 'Art'),
('1_1', '- Anime', 'Art - Anime'), ('1_1', '- Anime', 'Art - Anime'),
('1_2', '- Doujinshi', 'Art - Doujinshi'), ('1_2', '- Doujinshi', 'Art - Doujinshi'),
@ -189,13 +193,85 @@
('1_5', '- Pictures', 'Art - Pictures'), ('1_5', '- Pictures', 'Art - Pictures'),
('2_0', 'Real Life', 'Real Life'), ('2_0', 'Real Life', 'Real Life'),
('2_1', '- Photobooks and Pictures', 'Real Life - Pictures'), ('2_1', '- Photobooks and Pictures', 'Real Life - Pictures'),
('2_2', '- Videos', 'Real Life - Videos')] %} ('2_2', '- Videos', 'Real Life - Videos')]
%}
{% if config.SITE_FLAVOR == 'nyaa' %} {% if config.SITE_FLAVOR == 'nyaa' %}
{% set used_cats = nyaa_cats %} {% set used_cats = nyaa_cats %}
{% elif config.SITE_FLAVOR == 'sukebei' %} {% elif config.SITE_FLAVOR == 'sukebei' %}
{% set used_cats = suke_cats %} {% set used_cats = suke_cats %}
{% endif %} {% endif %}
<select class="selectpicker show-tick" title="Category" data-width="170px" name="c">
<div class="search-container visible-xs visible-sm">
{# The mobile menu #}
{% 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 %}
<input type="text" class="form-control" name="q" placeholder="{{ search_placeholder }}" value="{{ search["term"] if search is defined else '' }}">
<br>
<select class="form-control" title="Filter" data-width="120px" name="f">
<option value="0" title="No filter" {% if search is defined and search["quality_filter"] == "0" %}selected{% else %}selected{% endif %}>No filter</option>
<option value="1" title="No remakes" {% if search is defined and search["quality_filter"] == "1" %}selected{% endif %}>No remakes</option>
<option value="2" title="Trusted only" {% if search is defined and search["quality_filter"] == "2" %}selected{% endif %}>Trusted only</option>
</select>
<br>
<select class="form-control" title="Category" data-width="200px" name="c">
<option value="0_0" title="All categories" {% if search is defined and search["category"] == "0_0" %}selected{% else %}selected{% endif %}>
All categories
</option>
{% for cat_id, cat_name, cat_title in used_cats %}
<option value="{{ cat_id }}" title="{{ cat_title }}" {% if search is defined and search.category == cat_id %}selected{% endif %}>
{{ cat_name }}
</option>
{% endfor %}
</select>
<br>
<button class="btn btn-primary form-control" type="submit">
<i class="fa fa-search fa-fw"></i> Search
</button>
</form>
</div>
{% 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 search-container hidden-xs hidden-sm">
<input type="text" class="form-control search-bar" name="q" placeholder="{{ search_placeholder }}" value="{{ search["term"] if search is defined else '' }}">
<div class="input-group-btn nav-filter" id="navFilter-criteria">
<select class="selectpicker show-tick" title="Filter" data-width="120px" name="f">
<option value="0" title="No filter" {% if search is defined and search["quality_filter"] == "0" %}selected{% else %}selected{% endif %}>No filter</option>
<option value="1" title="No remakes" {% if search is defined and search["quality_filter"] == "1" %}selected{% endif %}>No remakes</option>
<option value="2" title="Trusted only" {% if search is defined and search["quality_filter"] == "2" %}selected{% endif %}>Trusted only</option>
</select>
</div>
<div class="input-group-btn nav-filter" id="navFilter-category">
<!--
On narrow viewports, there isn't enough room to fit the full stuff in the selectpicker, so we show a full-width one on wide viewports, but squish it on narrow ones.
-->
{# XXX Search breaks with multiple fields with the same name: default to the shorter one so we don't break visuals. This is a hack! #}
{# <select class="selectpicker show-tick visible-lg" title="Category" data-width="200px" name="c">
<option value="0_0" title="All categories" {% if search is defined and search["category"] == "0_0" %}selected{% else %}selected{% endif %}>
All categories
</option>
{% for cat_id, cat_name, cat_title in used_cats %}
<option value="{{ cat_id }}" title="{{ cat_title }}" {% if search is defined and search.category == cat_id %}selected{% endif %}>
{{ cat_name }}
</option>
{% endfor %}
</select> #}
<select class="selectpicker show-tick" title="Category" data-width="130px" name="c">
<option value="0_0" title="All categories" {% if search is defined and search["category"] == "0_0" %}selected{% else %}selected{% endif %}> <option value="0_0" title="All categories" {% if search is defined and search["category"] == "0_0" %}selected{% else %}selected{% endif %}>
All categories All categories
</option> </option>
@ -213,8 +289,8 @@
</div> </div>
</div> </div>
</form> </form>
</div><!--/.nav-collapse -->
</div> </div>
</div><!--/.nav-collapse -->
</nav> </nav>
<div class="container"> <div class="container">
@ -231,5 +307,3 @@
</footer> </footer>
</body> </body>
</html> </html>

View file

@ -1,5 +1,8 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block title %}Login :: {{ config.SITE_NAME }}{% endblock %} {% block title %}Login :: {{ config.SITE_NAME }}{% endblock %}
{% block metatags %}
<meta property="og:description" content="Log in to {{ config.SITE_NAME }}!">
{% endblock %}
{% block body %} {% block body %}
{% from "_formhelpers.html" import render_field %} {% from "_formhelpers.html" import render_field %}

View file

@ -3,17 +3,17 @@
{% block body %} {% block body %}
{% from "_formhelpers.html" import render_field %} {% from "_formhelpers.html" import render_field %}
<h2 style="margin-bottom: 20px;">Profile of <strong>{{ name }}</strong></h2> <h2 style="margin-bottom: 20px;">Profile of <strong class="text-{{ g.user.userlevel_color }}">{{ g.user.username }}</strong></h2>
<div class="row"> <div class="row" style="margin-bottom: 20px;">
<div class="col-sm-4 avatar" style="display: none;"> <div class="col-sm-2" style="max-width: 150px;">
<!-- TO BE IMPLEMENTED --> <img class="avatar" src="{{ g.user.gravatar_url() }}">
</div> </div>
<div class="col-sm-8"> <div class="col-sm-10">
<dl class="row" style="margin: 20px 0 15px 0;"> <dl class="row" style="margin: 20px 0 15px 0;">
<dt class="col-sm-3">User ID:</dt><dd class="col-sm-9">{{ g.user.id }}</dd> <dt class="col-sm-2">User ID:</dt><dd class="col-sm-10">{{ g.user.id }}</dd>
<dt class="col-sm-3">User Class:</dt><dd class="col-sm-9">{{ level }}</dd> <dt class="col-sm-2">User Class:</dt><dd class="col-sm-10">{{ g.user.userlevel_str }}</dd>
<dt class="col-sm-3">User Created on:</dt><dd class="col-sm-9">{{ g.user.created_time }}</dd> <dt class="col-sm-2">User Created on:</dt><dd class="col-sm-10">{{ g.user.created_time }}</dd>
</dl> </dl>
</div> </div>
</div> </div>
@ -59,7 +59,7 @@
<div class="row"> <div class="row">
<div class="form-group col-md-4"> <div class="form-group col-md-4">
<label class="control-label" for="current_email">Current Email</label> <label class="control-label" for="current_email">Current Email</label>
<div>{{email}}</div> <div>{{ g.user.email }}</div>
</div> </div>
</div> </div>
<div class="row"> <div class="row">

View file

@ -1,5 +1,8 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block title %}Register :: {{ config.SITE_NAME }}{% endblock %} {% block title %}Register :: {{ config.SITE_NAME }}{% endblock %}
{% block metatags %}
<meta property="og:description" content="Register to {{ config.SITE_NAME }}!">
{% endblock %}
{% block body %} {% block body %}
{% from "_formhelpers.html" import render_field %} {% from "_formhelpers.html" import render_field %}

View file

@ -1,4 +1,4 @@
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"> <rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:nyaa="{{ url_for('xmlns_nyaa', _external=True) }}" version="2.0">
<channel> <channel>
<title>{{ config.SITE_NAME }} Torrent File RSS</title> <title>{{ config.SITE_NAME }} Torrent File RSS</title>
<description>RSS Feed for {{ term }}</description> <description>RSS Feed for {{ term }}</description>
@ -12,15 +12,15 @@
{% if torrent.has_torrent and not magnet_links %} {% if torrent.has_torrent and not magnet_links %}
<link>{{ url_for('download_torrent', torrent_id=torrent.meta.id, _external=True) }}</link> <link>{{ url_for('download_torrent', torrent_id=torrent.meta.id, _external=True) }}</link>
{% else %} {% else %}
<link>{{ create_magnet_from_info(torrent.display_name, torrent.info_hash) }}</link> <link>{{ create_magnet_from_es_info(torrent.display_name, torrent.info_hash) }}</link>
{% endif %} {% endif %}
<guid isPermaLink="true">{{ url_for('view_torrent', torrent_id=torrent.meta.id, _external=True) }}</guid> <guid isPermaLink="true">{{ url_for('view_torrent', torrent_id=torrent.meta.id, _external=True) }}</guid>
<pubDate>{{ torrent.created_time|rfc822_es }}</pubDate> <pubDate>{{ torrent.created_time|rfc822_es }}</pubDate>
<seeders> {{- torrent.seed_count }}</seeders> <nyaa:seeders> {{- torrent.seed_count }}</nyaa:seeders>
<leechers> {{- torrent.leech_count }}</leechers> <nyaa:leechers> {{- torrent.leech_count }}</nyaa:leechers>
<downloads>{{- torrent.download_count }}</downloads> <nyaa:downloads>{{- torrent.download_count }}</nyaa:downloads>
<infoHash> {{- torrent.info_hash }}</infoHash> <nyaa:infoHash> {{- torrent.info_hash }}</nyaa:infoHash>
{% else %} {% else %}
{% if torrent.has_torrent and not magnet_links %} {% if torrent.has_torrent and not magnet_links %}
<link>{{ url_for('download_torrent', torrent_id=torrent.id, _external=True) }}</link> <link>{{ url_for('download_torrent', torrent_id=torrent.id, _external=True) }}</link>
@ -30,15 +30,15 @@
<guid isPermaLink="true">{{ url_for('view_torrent', torrent_id=torrent.id, _external=True) }}</guid> <guid isPermaLink="true">{{ url_for('view_torrent', torrent_id=torrent.id, _external=True) }}</guid>
<pubDate>{{ torrent.created_time|rfc822 }}</pubDate> <pubDate>{{ torrent.created_time|rfc822 }}</pubDate>
<seeders> {{- torrent.stats.seed_count }}</seeders> <nyaa:seeders> {{- torrent.stats.seed_count }}</nyaa:seeders>
<leechers> {{- torrent.stats.leech_count }}</leechers> <nyaa:leechers> {{- torrent.stats.leech_count }}</nyaa:leechers>
<downloads>{{- torrent.stats.download_count }}</downloads> <nyaa:downloads>{{- torrent.stats.download_count }}</nyaa:downloads>
<infoHash> {{- torrent.info_hash_as_hex }}</infoHash> <nyaa:infoHash> {{- torrent.info_hash_as_hex }}</nyaa:infoHash>
{% endif %} {% endif %}
{% set cat_id = use_elastic and ((torrent.main_category_id|string) + '_' + (torrent.sub_category_id|string)) or torrent.sub_category.id_as_string %} {% set cat_id = use_elastic and ((torrent.main_category_id|string) + '_' + (torrent.sub_category_id|string)) or torrent.sub_category.id_as_string %}
<categoryId>{{- cat_id }}</categoryId> <nyaa:categoryId>{{- cat_id }}</nyaa:categoryId>
<category> {{- category_name(cat_id) }}</category> <nyaa:category> {{- category_name(cat_id) }}</nyaa:category>
<size> {{- torrent.filesize | filesizeformat(True) }}</size> <nyaa:size> {{- torrent.filesize | filesizeformat(True) }}</nyaa:size>
</item> </item>
{% endfor %} {% endfor %}
</channel> </channel>

View file

@ -8,6 +8,15 @@
{{ caller() }} {{ caller() }}
</th> </th>
{% endmacro %} {% endmacro %}
{% if special_results is defined and not search.user %}
{% if special_results.first_word_user %}
<div class="alert alert-info">
<a href="/user/{{ special_results.first_word_user.username }}{{ modify_query(q=special_results.query_sans_user)[1:] }}">Click here to see only results uploaded by {{ special_results.first_word_user.username }}</a>
</div>
{% endif %}
{% endif %}
{% if (use_elastic and torrent_query.hits.total > 0) or (torrent_query.items) %} {% if (use_elastic and torrent_query.hits.total > 0) or (torrent_query.items) %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-bordered table-hover table-striped torrent-list"> <table class="table table-bordered table-hover table-striped torrent-list">
@ -56,7 +65,7 @@
{% else %} {% else %}
<a href="/?c={{ cat_id }}" title="{{ torrent.main_category.name }} - {{ torrent.sub_category.name }}"> <a href="/?c={{ cat_id }}" title="{{ torrent.main_category.name }} - {{ torrent.sub_category.name }}">
{% endif %} {% endif %}
<img src="/static/img/icons/{{ icon_dir }}/{{ cat_id }}.png"> <img src="/static/img/icons/{{ icon_dir }}/{{ cat_id }}.png" alt="{{ category_name(cat_id) }}">
</a> </a>
</td> </td>
{% if use_elastic %} {% if use_elastic %}
@ -67,7 +76,7 @@
<td style="white-space: nowrap;text-align: center;"> <td style="white-space: nowrap;text-align: center;">
{% if torrent.has_torrent %}<a href="{{ url_for('download_torrent', torrent_id=torrent.id) }}"><i class="fa fa-fw fa-download"></i></a>{% endif %} {% if torrent.has_torrent %}<a href="{{ url_for('download_torrent', torrent_id=torrent.id) }}"><i class="fa fa-fw fa-download"></i></a>{% endif %}
{% if use_elastic %} {% if use_elastic %}
<a href="{{ create_magnet_from_info(torrent.display_name, torrent.info_hash) }}"><i class="fa fa-fw fa-magnet"></i></a> <a href="{{ create_magnet_from_es_info(torrent.display_name, torrent.info_hash) }}"><i class="fa fa-fw fa-magnet"></i></a>
{% else %} {% else %}
<a href="{{ torrent.magnet_uri }}"><i class="fa fa-fw fa-magnet"></i></a> <a href="{{ torrent.magnet_uri }}"><i class="fa fa-fw fa-magnet"></i></a>
{% endif %} {% endif %}
@ -76,7 +85,7 @@
{% if use_elastic %} {% if use_elastic %}
<td class="text-center" data-timestamp="{{ torrent.created_time | utc_time }}">{{ torrent.created_time | display_time }}</td> <td class="text-center" data-timestamp="{{ torrent.created_time | utc_time }}">{{ torrent.created_time | display_time }}</td>
{% else %} {% else %}
<td class="text-center" data-timestamp="{{ torrent.created_utc_timestamp|int }}">{{ torrent.created_time.strftime('%Y-%m-%d %H:%M') }}</td> <td class="text-center" data-timestamp="{{ torrent.created_utc_timestamp | int }}">{{ torrent.created_time.strftime('%Y-%m-%d %H:%M') }}</td>
{% endif %} {% endif %}
{% if config.ENABLE_SHOW_STATS %} {% if config.ENABLE_SHOW_STATS %}
@ -99,7 +108,7 @@
<h3>No results found</h3> <h3>No results found</h3>
{% endif %} {% endif %}
<center> <div class="center">
{% if use_elastic %} {% if use_elastic %}
{{ pagination.info }} {{ pagination.info }}
{{ pagination.links }} {{ pagination.links }}
@ -107,4 +116,4 @@
{% from "bootstrap/pagination.html" import render_pagination %} {% from "bootstrap/pagination.html" import render_pagination %}
{{ render_pagination(torrent_query) }} {{ render_pagination(torrent_query) }}
{% endif %} {% endif %}
</center> </div>

View file

@ -1,5 +1,8 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block title %}Upload Torrent :: {{ config.SITE_NAME }}{% endblock %} {% block title %}Upload Torrent :: {{ config.SITE_NAME }}{% endblock %}
{% block metatags %}
<meta property="og:description" content="Upload a torrent to {{ config.SITE_NAME }}">
{% endblock %}
{% block body %} {% block body %}
{% from "_formhelpers.html" import render_field %} {% from "_formhelpers.html" import render_field %}
{% from "_formhelpers.html" import render_upload %} {% from "_formhelpers.html" import render_upload %}
@ -7,69 +10,97 @@
<h1>Upload Torrent</h1> <h1>Upload Torrent</h1>
{% if not user %} {% if not g.user %}
<p>You are not logged in, and are uploading anonymously.</p> <p>You are not logged in, and are uploading anonymously.</p>
{% endif %} {% endif %}
<div id="upload-drop-zone"><span>Drop here!</span></div> <div id="upload-drop-zone"><span>Drop here!</span></div>
<form method="POST" enctype="multipart/form-data"> <form method="POST" enctype="multipart/form-data">
{% if config.ENFORCE_MAIN_ANNOUNCE_URL %}<p><strong>Important:</strong> Please include <kbd>{{config.MAIN_ANNOUNCE_URL}}</kbd> in your trackers</p>{% endif %} {{ upload_form.csrf_token }}
{% if config.ENFORCE_MAIN_ANNOUNCE_URL %}<p><strong>Important:</strong> Please include <kbd>{{ config.MAIN_ANNOUNCE_URL }}</kbd> in your trackers</p>{% endif %}
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-10">
{{ render_upload(form.torrent_file, accept=".torrent") }} {{ render_upload(upload_form.torrent_file, accept=".torrent") }}
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
{{ render_field(form.display_name, class_='form-control', placeholder='Display name') }} {{ render_field(upload_form.display_name, class_='form-control', placeholder='Display name') }}
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
{{ render_field(form.category, class_='form-control')}} {{ render_field(upload_form.category, class_='form-control')}}
</div> </div>
</div> </div>
<div class="row"> <div class="row"></div>
</div>
<div class="row form-group"> <div class="row form-group">
<div class="col-md-6"> <div class="col-md-6">
{{ render_field(form.information, class_='form-control', placeholder='Your website or IRC channel') }} {{ render_field(upload_form.information, class_='form-control', placeholder='Your website or IRC channel') }}
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="control-label">Torrent flags</label> <label class="control-label">Torrent flags</label><br>
<div>
<label class="btn btn-primary" title="Upload torrent anonymously (don't display your username)"> <div class="btn-group" data-toggle="buttons">
{{ form.is_anonymous(disabled=(False if user else ""), checked=(False if user else "")) }} <label class="btn btn-default {% if not g.user %}active disabled{% endif %}" title="Upload torrent anonymously (don't display your username)">
{{ upload_form.is_anonymous(disabled=(False if g.user else ""), checked=(False if g.user else "")) }}
{% if not g.user %}<span class="glyphicon glyphicon-ban-circle"></span>{% endif %}
{% if g.user %}<span class="glyphicon glyphicon-check"></span>{% endif %}
{% if g.user %}<span class="glyphicon glyphicon-unchecked"></span>{% endif %}
Anonymous Anonymous
</label> </label>
<label class="btn btn-default" style="background-color: darkgray; border-color: #ccc;" title="Hide torrent from listing"> <label class="btn btn-grey" title="Hide torrent from listing">
{{ form.is_hidden }} {{ upload_form.is_hidden }}
<span class="glyphicon glyphicon-check"></span>
<span class="glyphicon glyphicon-unchecked"></span>
Hidden Hidden
</label> </label>
</div>
<div class="hidden-xl hidden-lg"><br></div>
<div class="btn-group" data-toggle="buttons">
<label class="btn btn-danger" title="This torrent is derived from another release"> <label class="btn btn-danger" title="This torrent is derived from another release">
{{ form.is_remake }} {{ upload_form.is_remake }}
<span class="glyphicon glyphicon-check"></span>
<span class="glyphicon glyphicon-unchecked"></span>
Remake Remake
</label> </label>
<label class="btn btn-primary" title="This torrent is a complete batch (eg. season)"> <label class="btn btn-warning" title="This torrent is a complete batch (eg. season)">
{{ form.is_complete }} {{ upload_form.is_complete }}
<span class="glyphicon glyphicon-check"></span>
<span class="glyphicon glyphicon-unchecked"></span>
Complete Complete
</label> </label>
{% if user.is_trusted %} {% if g.user.is_trusted %}
<label class="btn btn-success" title="Mark torrent trusted"> <label class="btn btn-success active" title="Mark torrent trusted">
{{ form.is_trusted(checked="") }} {{ upload_form.is_trusted(checked="") }}
<span class="glyphicon glyphicon-check"></span>
<span class="glyphicon glyphicon-unchecked"></span>
Trusted Trusted
</label> </label>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
{{ render_markdown_editor(form.description, field_name='description') }} {{ render_markdown_editor(upload_form.description, field_name='description') }}
</div> </div>
</div> </div>
{% if config.USE_RECAPTCHA and not g.user %}
<div class="row">
<div class="col-md-4">
{% for error in upload_form.recaptcha.errors %}
{{ error }}
{% endfor %}
{{ upload_form.recaptcha }}
</div>
</div>
{% endif %}
<br>
<div class="row"> <div class="row">
<div class="form-group col-md-6"> <div class="form-group col-md-6">
<input type="submit" value="Upload" class="btn btn-primary"> <input type="submit" value="Upload" class="btn btn-primary">

View file

@ -1,34 +1,56 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block title %}{{ user.username }} :: {{ config.SITE_NAME }}{% endblock %} {% block title %}{{ user.username }} :: {{ config.SITE_NAME }}{% endblock %}
{% block meta_image %}{{ user.gravatar_url() }}{% endblock %}
{% block metatags %}
{% if search.term %}
<meta property="og:description" content="Search for '{{ search.term }}' in torrents uploaded by {{ user.username }}">
{% else %}
<meta property="og:description" content="Torrents uploaded by {{ user.username }}">
{% endif %}
{% endblock %}
{% block body %} {% block body %}
{% from "_formhelpers.html" import render_menu_with_button %} {% from "_formhelpers.html" import render_menu_with_button %}
{% if superadmin %} {% if g.user and g.user.is_moderator %}
<h2>User Information</h2><br> <h2>User Information</h2><br>
<div class="row" style="margin-bottom: 20px;">
<div class="col-sm-2" style="max-width: 150px;">
<img class="avatar" src="{{ user.gravatar_url() }}">
</div>
<div class="col-sm-10">
<dl class="dl-horizontal"> <dl class="dl-horizontal">
<dt>User ID:</dt> <dt>User ID:</dt>
<dd>{{user.id}}</dd> <dd>{{ user.id }}</dd>
<dt>Account created on:</dt> <dt>Account created on:</dt>
<dd>{{user.created_time}}</dd> <dd>{{ user.created_time }}</dd>
<dt>Email address:</dt> <dt>Email address:</dt>
<dd>{{user.email}}</dd> <dd>{{ user.email }}</dd>
<dt>User class:</dt> <dt>User class:</dt>
<dd>{{level}}</dd><br> <dd>{{ level }}</dd>
{%- if g.user.is_superadmin -%}
<dt>Last login IP:</dt>
<dd>{{ user.ip_string }}</dd><br>
{%- endif -%}
</dl> </dl>
</div>
</div>
{% if admin_form %}
<form method="POST"> <form method="POST">
{{ form.csrf_token }} {{ admin_form.csrf_token }}
<div class="form-group row"> <div class="form-group row">
<div class="col-md-6"> <div class="col-md-6">
{{ render_menu_with_button(form.user_class)}} {{ render_menu_with_button(admin_form.user_class) }}
</div> </div>
</div> </div>
</form> </form>
<br> <br>
{% endif %}
{% endif %} {% endif %}
<h3> <h3>
Browsing {{user.username}}'s torrents Browsing <span class="text-{{ user.userlevel_color }}">{{ user.username }}</span>'{{ '' if user.username[-1] == 's' else 's' }} torrents
</h3> </h3>
{% include "search_results.html" %} {% include "search_results.html" %}

View file

@ -1,5 +1,9 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block title %}{{ torrent.display_name }} :: {{ config.SITE_NAME }}{% endblock %} {% block title %}{{ torrent.display_name }} :: {{ config.SITE_NAME }}{% endblock %}
{% block metatags %}
{% set uploader_name = torrent.user.username if (torrent.user and not torrent.anonymous) else 'Anonymous' %}
<meta property="og:description" content="{{ category_name(torrent.sub_category.id_as_string) }} | {{ torrent.filesize | filesizeformat(True) }} | Uploaded by {{ uploader_name }} on {{ torrent.created_time.strftime('%Y-%m-%d') }}">
{% endblock %}
{% block body %} {% block body %}
{% from "_formhelpers.html" import render_field %} {% from "_formhelpers.html" import render_field %}
<div class="panel panel-{% if torrent.deleted %}deleted{% elif torrent.remake %}danger{% elif torrent.trusted %}success{% else %}default{% endif %}"> <div class="panel panel-{% if torrent.deleted %}deleted{% elif torrent.remake %}danger{% elif torrent.trusted %}success{% else %}default{% endif %}">
@ -27,10 +31,13 @@
<div class="col-md-5"> <div class="col-md-5">
{% set user_url = torrent.user and url_for('view_user', user_name=torrent.user.username) %} {% set user_url = torrent.user and url_for('view_user', user_name=torrent.user.username) %}
{%- if not torrent.anonymous and torrent.user -%} {%- if not torrent.anonymous and torrent.user -%}
<a href="{{ user_url }}">{{ torrent.user.username }}</a> <a class="text-{{ torrent.user.userlevel_color }}" href="{{ user_url }}">{{ torrent.user.username }}</a>
{%- else -%} {%- else -%}
Anonymous {% if torrent.user and (viewer == torrent.user or viewer.is_admin) %}(<a href="{{ user_url }}">{{ torrent.user.username }}</a>){% endif %} Anonymous {% if torrent.user and (g.user == torrent.user or g.user.is_moderator) %}(<a href="{{ user_url }}">{{ torrent.user.username }}</a>){% endif %}
{%- endif -%} {%- endif -%}
{% if g.user and g.user.is_superadmin and torrent.uploader_ip %}
({{ torrent.uploader_ip_string }})
{% endif %}
</div> </div>
<div class="col-md-1">Seeders:</div> <div class="col-md-1">Seeders:</div>
@ -56,16 +63,20 @@
<div class="col-md-1">File size:</div> <div class="col-md-1">File size:</div>
<div class="col-md-5">{{ torrent.filesize | filesizeformat(True) }}</div> <div class="col-md-5">{{ torrent.filesize | filesizeformat(True) }}</div>
<div class="col-md-1">Downloads:</div> <div class="col-md-1">Completed:</div>
<div class="col-md-5">{% if config.ENABLE_SHOW_STATS %}{{ torrent.stats.download_count }}{% else %}Coming soon{% endif %}</div> <div class="col-md-5">{% if config.ENABLE_SHOW_STATS %}{{ torrent.stats.download_count }}{% else %}Coming soon{% endif %}</div>
</div> </div>
<div class="row">
<div class="col-md-offset-6 col-md-1">Info hash:</div>
<div class="col-md-5"><kbd>{{ torrent.info_hash_as_hex }}</kbd></div>
</div> </div>
<div class="panel-footer clearfix" style="font-size: large"> </div>
{% 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 class="panel-footer clearfix">
{% if torrent.has_torrent %}<a href="{{ url_for('download_torrent', torrent_id=torrent.id )}}"><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>
<button type="button" class="btn btn-danger pull-right" data-toggle="modal" data-target="#reportModal"> <button type="button" class="btn btn-danger pull-right" data-toggle="modal" data-target="#reportModal">
Report Report
</button> </button>
</div> </div>
</div> </div>
@ -82,42 +93,32 @@
{% if files and files.__len__() <= config.MAX_FILES_VIEW %} {% if files and files.__len__() <= config.MAX_FILES_VIEW %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading panel-heading-collapse"> <div class="panel-heading">
<h3 class="panel-title"> <h3 class="panel-title">File list</h3>
<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>
<div class="panel-collapse collapse" id="collapseFileList"> <div class="torrent-file-list panel-body">
<table class="table table-bordered table-hover table-striped"> <ul>
<thead> {% for key, value in files.items() recursive -%}
<th style="width:auto;">Path</th> {% if value is iterable %}
<th style="width:auto;">Size</th> {% set pre_expanded = not loop.depth0 and value.items()|length <= 20 %}
</thead> <li>
<tbody> <a href="" class="folder"><i class="fa fa-folder{% if pre_expanded %}-open{% endif %}"></i>{{ key }}</a>
{%- for key, value in files.items() recursive %} <ul{% if pre_expanded %} data-show="yes"{% endif %}>{{ '\n' + loop(value.items()) }}
<tr> </ul>
{%- if value is iterable %} </li>
<td colspan="2" {% if loop.depth0 is greaterthan 0 %}style="padding-left: {{ loop.depth0 * 20 }}px"{% endif %}> {% else %}
<i class="glyphicon glyphicon-folder-open"></i>&nbsp;&nbsp;<b>{{ key }}</b></td> <li><i class="fa fa-file"></i>{{ key }} <span class="file-size">({{ value | filesizeformat(True) }})</span></a></li>
{{ loop(value.items()) }} {% endif %}
{%- else %} {% endfor %}
<td{% if loop.depth0 is greaterthan 0 %} style="padding-left: {{ loop.depth0 * 20 }}px"{% endif %}> </ul>
<i class="glyphicon glyphicon-file"></i>&nbsp;{{ key }}</td>
<td class="col-md-2">{{ value | filesizeformat(True) }}</td>
{%- endif %}
</tr>
{%- endfor %}
</table>
</div> </div>
</div> </div>
{% elif files %} {% elif files %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading panel-heading-collapse"> <div class="panel-heading panel-heading-collapse">
<h3 class="panel-title"> <h3 class="panel-title">
<div class="row"><div class="col-md-12">Too many files to display.</div></div> Too many files to display.
</h3> </h3>
</div> </div>
</div> </div>
@ -125,13 +126,66 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading panel-heading-collapse"> <div class="panel-heading panel-heading-collapse">
<h3 class="panel-title"> <h3 class="panel-title">
<div class="row"><div class="col-md-12">File list is not available for this torrent.</div></div> File list is not available for this torrent.
</h3> </h3>
</div> </div>
</div> </div>
{% endif %} {% endif %}
<div class="modal fade" id="reportModal" tabindex="-1" role="dialog" aria-labelledby="reportModalLabel">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
Comments - {{ comments | length }}
</h3>
</div>
{% for comment in comments %}
<div class="panel panel-default comment-panel" id="com-{{ loop.index }}">
<div class="panel-body">
<div class="col-md-2">
<p>
<a class="text-{{ comment.user.userlevel_color }}" href="{{ url_for('view_user', user_name=comment.user.username) }}">{{ comment.user.username }}</a>
{% if comment.user.id == torrent.uploader_id and not torrent.anonymous %}
(uploader)
{% endif %}
</p>
<p><img class="avatar" src="{{ comment.user.gravatar_url() }}" alt="{{ comment.user.userlevel_str }}"></p>
</div>
<div class="col-md-10">
<div class="row">
<a href="#com-{{ loop.index }}"><small data-timestamp-swap data-timestamp="{{ comment.created_utc_timestamp|int }}">{{ comment.created_time.strftime('%Y-%m-%d %H:%M UTC') }}</small></a>
{% if g.user.is_moderator or g.user.id == comment.user_id %}
<form class="delete-comment-form" action="{{ url_for('delete_comment', torrent_id=torrent.id, comment_id=comment.id) }}" method="POST">
<button name="submit" type="submit" class="btn btn-danger btn-xs" title="Delete">Delete</button>
</form>
{% endif %}
</div>
<div class="row">
{# Escape newlines into html entities because CF strips blank newlines #}
<div class="comment-content" id="torrent-comment{{ comment.id }}">{{ comment.text }}</div>
</div>
</div>
</div>
</div>
<script type="text/javascript">
var target = document.getElementById('torrent-comment{{ comment.id }}');
var text = target.innerHTML;
var reader = new commonmark.Parser({safe: true});
var writer = new commonmark.HtmlRenderer({safe: true, softbreak: '<br />'});
var parsed = reader.parse(text.trim());
target.innerHTML = writer.render(parsed);
</script>
{% endfor %}
{% if comment_form %}
<form class="comment-box" method="POST">
{{ comment_form.csrf_token }}
{{ render_field(comment_form.comment, class_='form-control') }}
<input type="submit" value="Submit" class="btn btn-success btn-sm">
</form>
{% endif %}
</div>
<div class="modal fade" id="reportModal" tabindex="-1" role="dialog" aria-labelledby="reportModalLabel">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@ -153,7 +207,6 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<script> <script>
var target = document.getElementById('torrent-description'); var target = document.getElementById('torrent-description');

32
nyaa/templates/xmlns.html Normal file
View file

@ -0,0 +1,32 @@
{% extends "layout.html" %}
{% block title %}XML Namespace :: {{ config.SITE_NAME }}{% endblock %}
{% block body %}
<div class="content">
<h1>Nyaa XML Namespace</h1>
<p>You found this page because our RSS feeds contain an URL that links here. Said URL is not an actual page but rather a unique identifier used to prevent name collisions with other XML namespaces.</p>
<p>The namespace contains the following additional, informational <b>tags</b>:</p>
<ul>
<li>
<p><code>&lt;nyaa:seeders&gt;</code> holds the current amount of seeders on the respective torrent.</p>
</li>
<li>
<p><code>&lt;nyaa:leechers&gt;</code> holds the current amount of leechers on the respective torrent.</p>
</li>
<li>
<p><code>&lt;nyaa:downloads&gt;</code> counts the downloads the torrent got up to the point the feed was refreshed.</p>
</li>
<li>
<p><code>&lt;nyaa:infoHash&gt;</code> is the torrent's infohash, a unique identifier, in hexadecimal.</p>
</li>
<li>
<p><code>&lt;nyaa:categoryId&gt;</code> contains the ID of the category containing the upload in the form <code>category_subcategory</code>.</p>
</li>
<li>
<p><code>&lt;nyaa:category&gt;</code> contains the written name of the torrent's category in the form <code>Category - Subcategory</code>.</p>
</li>
<li>
<p><code>&lt;nyaa:size&gt;</code> indicates the torrent's download size to one decimal place, using a magnitude prefix according to ISO/IEC 80000-13.</p>
</li>
</ul>
</div>
{% endblock %}

View file

@ -11,6 +11,9 @@ from nyaa import models
USED_TRACKERS = OrderedSet() USED_TRACKERS = OrderedSet()
# Limit the amount of trackers added into .torrent files
MAX_TRACKERS = 5
def read_trackers_from_file(file_object): def read_trackers_from_file(file_object):
USED_TRACKERS.clear() USED_TRACKERS.clear()
@ -55,7 +58,7 @@ def get_trackers(torrent):
return list(trackers) return list(trackers)
def get_trackers_magnet(): def get_default_trackers():
trackers = OrderedSet() trackers = OrderedSet()
# Our main one first # Our main one first
@ -70,8 +73,9 @@ def get_trackers_magnet():
def create_magnet(torrent, max_trackers=5, trackers=None): def create_magnet(torrent, max_trackers=5, trackers=None):
# Unless specified, we just use default trackers
if trackers is None: if trackers is None:
trackers = get_trackers_magnet() trackers = get_default_trackers()
magnet_parts = [ magnet_parts = [
('dn', torrent.display_name) ('dn', torrent.display_name)
@ -85,10 +89,10 @@ def create_magnet(torrent, max_trackers=5, trackers=None):
# For processing ES links # For processing ES links
@app.context_processor @app.context_processor
def create_magnet_from_info(): def create_magnet_from_es_info():
def _create_magnet_from_info(display_name, info_hash, max_trackers=5, trackers=None): def _create_magnet_from_es_info(display_name, info_hash, max_trackers=5, trackers=None):
if trackers is None: if trackers is None:
trackers = get_trackers_magnet() trackers = get_default_trackers()
magnet_parts = [ magnet_parts = [
('dn', display_name) ('dn', display_name)
@ -98,7 +102,7 @@ def create_magnet_from_info():
b32_info_hash = base64.b32encode(bytes.fromhex(info_hash)).decode('utf-8') b32_info_hash = base64.b32encode(bytes.fromhex(info_hash)).decode('utf-8')
return 'magnet:?xt=urn:btih:' + b32_info_hash + '&' + urlencode(magnet_parts) return 'magnet:?xt=urn:btih:' + b32_info_hash + '&' + urlencode(magnet_parts)
return dict(create_magnet_from_info=_create_magnet_from_info) return dict(create_magnet_from_es_info=_create_magnet_from_es_info)
def create_default_metadata_base(torrent, trackers=None): def create_default_metadata_base(torrent, trackers=None):
@ -116,7 +120,7 @@ def create_default_metadata_base(torrent, trackers=None):
metadata_base['announce'] = trackers[0] metadata_base['announce'] = trackers[0]
if len(trackers) > 1: if len(trackers) > 1:
# Yes, it's a list of lists with a single element inside. # Yes, it's a list of lists with a single element inside.
metadata_base['announce-list'] = [[tracker] for tracker in trackers] metadata_base['announce-list'] = [[tracker] for tracker in trackers[:MAX_TRACKERS]]
return metadata_base return metadata_base

View file

@ -1,3 +1,4 @@
alembic==0.9.2
appdirs==1.4.3 appdirs==1.4.3
argon2-cffi==16.3.0 argon2-cffi==16.3.0
autopep8==1.3.1 autopep8==1.3.1
@ -5,9 +6,14 @@ blinker==1.4
cffi==1.10.0 cffi==1.10.0
click==6.7 click==6.7
dominate==2.3.1 dominate==2.3.1
Flask==0.12.1 elasticsearch==5.3.0
elasticsearch-dsl==5.2.0
Flask==0.12.2
Flask-Assets==0.12 Flask-Assets==0.12
Flask-DebugToolbar==0.10.1 Flask-DebugToolbar==0.10.1
Flask-Migrate==2.0.3
flask-paginate==0.4.5
Flask-Script==2.0.5
Flask-SQLAlchemy==2.2 Flask-SQLAlchemy==2.2
Flask-WTF==0.14.2 Flask-WTF==0.14.2
gevent==1.2.1 gevent==1.2.1
@ -15,27 +21,29 @@ greenlet==0.4.12
itsdangerous==0.24 itsdangerous==0.24
Jinja2==2.9.6 Jinja2==2.9.6
libsass==0.12.3 libsass==0.12.3
Mako==1.0.6
MarkupSafe==1.0 MarkupSafe==1.0
mysql-replication==0.13
mysqlclient==1.3.10 mysqlclient==1.3.10
orderedset==2.0 orderedset==2.0
packaging==16.8 packaging==16.8
passlib==1.7.1 passlib==1.7.1
progressbar2==3.20.0
pycodestyle==2.3.1 pycodestyle==2.3.1
pycparser==2.17 pycparser==2.17
PyMySQL==0.7.11
pyparsing==2.2.0 pyparsing==2.2.0
python-dateutil==2.6.0
python-editor==1.0.3
python-utils==2.1.0
six==1.10.0 six==1.10.0
SQLAlchemy==1.1.9 SQLAlchemy==1.1.10
SQLAlchemy-FullText-Search==0.2.3 SQLAlchemy-FullText-Search==0.2.3
SQLAlchemy-Utils==0.32.14 SQLAlchemy-Utils==0.32.14
statsd==3.2.1
urllib3==1.21.1
uWSGI==2.0.15 uWSGI==2.0.15
visitor==0.1.3 visitor==0.1.3
webassets==0.12.1 webassets==0.12.1
Werkzeug==0.12.1 Werkzeug==0.12.2
WTForms==2.1 WTForms==2.1
## elasticsearch dependencies
elasticsearch==5.3.0
elasticsearch-dsl==5.2.0
progressbar2==3.20.0
mysql-replication==0.13
flask-paginate==0.4.5
statsd==3.2.1

View file

@ -1,13 +1,4 @@
udp://oscar.reyesleon.xyz:6969/announce
udp://tracker.cyberia.is:6969/announce
udp://tracker.doko.moe:6969
http://tracker.baka-sub.cf:80/announce
udp://tracker.coppersurfer.tk:6969/announce
udp://tracker.torrent.eu.org:451
udp://tracker.opentrackr.org:1337/announce udp://tracker.opentrackr.org:1337/announce
udp://tracker.coppersurfer.tk:6969/announce
udp://tracker.doko.moe:6969
udp://tracker.zer0day.to:1337/announce udp://tracker.zer0day.to:1337/announce
http://t.nyaatracker.com:80/announce
https://open.kickasstracker.com:443/announce
udp://tracker.safe.moe:6969/announce
udp://p4p.arenabg.ch:1337/announce
udp://tracker.justseed.it:1337/announce

View file

@ -1,134 +0,0 @@
# Uploads a single torrent file
# Works on nyaa.si and sukebei.nyaa.si
# Consider using api_uploader_v2.py instead
# It has a nice command line interface
import json
import requests
'''
The POST payload to the api endpoint (/api/upload) should be multipart/form-data containing three fields
'auth_info': file containing "{
'username': str,
'password': str
}",
'torrent_info': {
'category': str, # see below
'display_name': str, # optional
'information': str,
'description': str,
'is_anonymous': boolean,
'is_hidden': boolean,
'is_remake': boolean,
'is_complete': boolean
},
'torrent_file': multi part file format
A successful request should return {'Success': int(torrent_id)}
A failed request should return {'Failure': ["Failure 1", "Failure 2"...]]}
'''
# ########################################### HELP ############################################
# ################################# CATEGORIES MUST BE EXACT ##################################
'''
# Nyaa categories only for now, but api still works for sukebei
Anime
Anime - AMV : '1_1'
Anime - English : '1_2'
Anime - Non-English : '1_3'
Anime - Raw : '1_4'
Audio
Lossless : '2_1'
Lossy : '2_2'
Literature
Literature - English-translated : '3_1'
Literature - Non-English : '3_2'
Literature - Non-English-Translated : '3_3'
Literature - Raw : '3_4'
Live Action
Live Action - English-translated : '4_1'
Live Action - Idol/Promotional Video : '4_2'
Live Action - Non-English-translated : '4_3'
Live Action - Raw : '4_4'
Pictures
Pictures - Graphics : '5_1'
Pictures - Photos : '5_2'
Software
Software - Applications : '6_1'
Software - Games : '6_2'
'''
# ################################# CATEGORIES MUST BE EXACT ##################################
# ###################################### EXAMPLE REQUEST ######################################
'''
# Required
username = ''
password = ''
torrent_file = '/path/to/my.torrent'
category = '1_2'
#Optional
display_name = ''
information = 'API HOWTO'
description = 'Visit #nyaa-dev@irc.rizon.net'
# Defaults to False, change to True to set
is_anonymous : False,
is_hidden : False,
is_remake : False,
is_complete : False
'''
# ######################################## CHANGE HERE ########################################
url = 'https://nyaa.si/api/upload' # or 'https://sukebei.nyaa.si/api/upload' or 'http://127.0.0.1:5500/api/upload'
# Required
username = ''
password = ''
torrent_file = ''
category = ''
# Optional
display_name = ''
information = ''
description = ''
is_anonymous = False
is_hidden = False
is_remake = False
is_complete = False
auth_info = {
'username' : username,
'password' : password
}
metadata={
'category' : category,
'display_name' : display_name,
'information' : information,
'description' : description,
'is_anonymous' : is_anonymous,
'is_hidden' : is_hidden,
'is_remake' : is_remake,
'is_complete' : is_complete
}
files = {
'auth_info' : (json.dumps(auth_info)),
'torrent_info' : (json.dumps(metadata)),
'torrent_file' : ('{0}'.format(torrent_file), open(torrent_file, 'rb'), 'application/octet-stream')
}
response = requests.post(url, files=files)
json_response = response.json()
print(json_response)
# A successful request should print {'Success': int(torrent_id)}

View file

@ -9,7 +9,7 @@ NYAA_HOST = 'https://nyaa.si'
SUKEBEI_HOST = 'https://sukebei.nyaa.si' SUKEBEI_HOST = 'https://sukebei.nyaa.si'
API_BASE = '/api' API_BASE = '/api'
API_UPLOAD = API_BASE + '/v2/upload' API_UPLOAD = API_BASE + '/upload'
NYAA_CATS = '''1_1 - Anime - AMV NYAA_CATS = '''1_1 - Anime - AMV
1_2 - Anime - English 1_2 - Anime - English