├── tests ├── test_models.py ├── test_api_handler.py ├── __init__.py ├── test_nyaa.py ├── test_backend.py ├── test_template_utils.py ├── test_utils.py └── test_bencode.py ├── info_dicts └── .gitignore ├── torrents └── .gitignore ├── .docker ├── mariadb-init-sql │ ├── .gitignore │ └── 50-grant-binlog-access.sql ├── kibana.config.yml ├── es_sync_config.json ├── nyaa-config-partial.py ├── Dockerfile ├── uwsgi.config.ini ├── entrypoint-sync.sh ├── nginx.conf ├── entrypoint.sh ├── README.md └── full-stack.yml ├── migrations ├── README ├── script.py.mako ├── versions │ ├── ffd23e570f92_add_is_webseed_to_trackers.py │ ├── 3001f79b7722_add_torrents.uploader_ip.py │ ├── f703f911d4ae_add_registration_ip.py │ ├── cf7bf6d0e6bd_add_edited_time_to_comments.py │ ├── b79d2fcafd88_comment_text.py │ ├── d0eeb8049623_add_comments.py │ ├── 8a6a7662eb37_add_user_preferences_table.py │ ├── 6cc823948c5a_add_trackerapi.py │ ├── 500117641608_add_bans.py │ ├── f69d7fec88d6_add_rangebans.py │ ├── 1add911660a6_admin_log_added.py │ ├── 5cbcee17bece_add_trusted_applications.py │ ├── 2bceb2cb4d7c_add_comment_count_to_torrent.py │ ├── 7f064e009cab_add_report_table.py │ └── b61e4f6a88cc_del_torrents_info.py ├── alembic.ini └── env.py ├── nyaa ├── templates │ ├── trusted_rules.html │ ├── infobubble_content.html │ ├── email │ │ ├── reset.txt │ │ ├── reset-request.txt │ │ ├── verify.txt │ │ ├── reset.html │ │ ├── trusted.txt │ │ ├── verify.html │ │ ├── reset-request.html │ │ └── trusted.html │ ├── waiting.html │ ├── 404.html │ ├── flashes.html │ ├── trusted.html │ ├── infobubble.html │ ├── home.html │ ├── password_reset.html │ ├── password_reset_request.html │ ├── adminlog.html │ ├── trusted_form.html │ ├── admin_bans.html │ ├── register.html │ ├── admin_trusted.html │ ├── login.html │ ├── xmlns.html │ ├── reports.html │ ├── bootstrap │ │ └── pagination.html │ ├── rss.xml │ ├── user_comments.html │ ├── admin_trusted_view.html │ ├── profile.html │ ├── rules.html │ ├── _formhelpers.html │ ├── upload.html │ └── user.html ├── static │ ├── favicon.png │ ├── fonts │ │ ├── FontAwesome.otf │ │ ├── fontawesome-webfont.eot │ │ ├── fontawesome-webfont.ttf │ │ ├── fontawesome-webfont.woff │ │ ├── fontawesome-webfont.woff2 │ │ ├── glyphicons-halflings-regular.eot │ │ ├── glyphicons-halflings-regular.ttf │ │ ├── glyphicons-halflings-regular.woff │ │ └── glyphicons-halflings-regular.woff2 │ ├── img │ │ ├── avatar │ │ │ └── default.png │ │ └── icons │ │ │ ├── nyaa │ │ │ ├── 1_1.png │ │ │ ├── 1_2.png │ │ │ ├── 1_3.png │ │ │ ├── 1_4.png │ │ │ ├── 2_1.png │ │ │ ├── 2_2.png │ │ │ ├── 3_1.png │ │ │ ├── 3_2.png │ │ │ ├── 3_3.png │ │ │ ├── 4_1.png │ │ │ ├── 4_2.png │ │ │ ├── 4_3.png │ │ │ ├── 4_4.png │ │ │ ├── 5_1.png │ │ │ ├── 5_2.png │ │ │ ├── 6_1.png │ │ │ └── 6_2.png │ │ │ └── sukebei │ │ │ ├── 1_1.png │ │ │ ├── 1_2.png │ │ │ ├── 1_3.png │ │ │ ├── 1_4.png │ │ │ ├── 1_5.png │ │ │ ├── 2_1.png │ │ │ └── 2_2.png │ ├── style.css │ ├── pinned-tab.svg │ ├── search.xml │ ├── search-sukebei.xml │ └── css │ │ └── bootstrap-xl-mod.css ├── views │ ├── site.py │ └── __init__.py ├── extensions.py ├── email.py ├── utils.py ├── torrents.py ├── __init__.py ├── template_utils.py └── bencode.py ├── run.py ├── utils ├── batch_upload_torrent.sh ├── simple_bench.py ├── infodict_mysql2file.py └── api_info.py ├── configs └── my.cnf ├── create_es.sh ├── es_sync_config.example.json ├── trackers.txt ├── .github └── issue_template.md ├── .gitignore ├── WSGI.py ├── db_migrate.py ├── setup.cfg ├── uwsgi.ini ├── .gitattributes ├── .travis.yml ├── lint.sh ├── requirements.txt ├── db_create.py ├── rangeban.py ├── es_mapping.yml ├── import_to_es.py └── dev.py /tests/test_models.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /info_dicts/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /torrents/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /.docker/mariadb-init-sql/.gitignore: -------------------------------------------------------------------------------- 1 | !*.sql 2 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /nyaa/templates/trusted_rules.html: -------------------------------------------------------------------------------- 1 |

Trusted rules go here

2 | -------------------------------------------------------------------------------- /nyaa/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/favicon.png -------------------------------------------------------------------------------- /nyaa/static/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /nyaa/static/img/avatar/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/avatar/default.png -------------------------------------------------------------------------------- /nyaa/static/img/icons/nyaa/1_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/icons/nyaa/1_1.png -------------------------------------------------------------------------------- /nyaa/static/img/icons/nyaa/1_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/icons/nyaa/1_2.png -------------------------------------------------------------------------------- /nyaa/static/img/icons/nyaa/1_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/icons/nyaa/1_3.png -------------------------------------------------------------------------------- /nyaa/static/img/icons/nyaa/1_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/icons/nyaa/1_4.png -------------------------------------------------------------------------------- /nyaa/static/img/icons/nyaa/2_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/icons/nyaa/2_1.png -------------------------------------------------------------------------------- /nyaa/static/img/icons/nyaa/2_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/icons/nyaa/2_2.png -------------------------------------------------------------------------------- /nyaa/static/img/icons/nyaa/3_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/icons/nyaa/3_1.png -------------------------------------------------------------------------------- /nyaa/static/img/icons/nyaa/3_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/icons/nyaa/3_2.png -------------------------------------------------------------------------------- /nyaa/static/img/icons/nyaa/3_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/icons/nyaa/3_3.png -------------------------------------------------------------------------------- /nyaa/static/img/icons/nyaa/4_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/icons/nyaa/4_1.png -------------------------------------------------------------------------------- /nyaa/static/img/icons/nyaa/4_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/icons/nyaa/4_2.png -------------------------------------------------------------------------------- /nyaa/static/img/icons/nyaa/4_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/icons/nyaa/4_3.png -------------------------------------------------------------------------------- /nyaa/static/img/icons/nyaa/4_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/icons/nyaa/4_4.png -------------------------------------------------------------------------------- /nyaa/static/img/icons/nyaa/5_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/icons/nyaa/5_1.png -------------------------------------------------------------------------------- /nyaa/static/img/icons/nyaa/5_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/icons/nyaa/5_2.png -------------------------------------------------------------------------------- /nyaa/static/img/icons/nyaa/6_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/icons/nyaa/6_1.png -------------------------------------------------------------------------------- /nyaa/static/img/icons/nyaa/6_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/icons/nyaa/6_2.png -------------------------------------------------------------------------------- /nyaa/static/img/icons/sukebei/1_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/icons/sukebei/1_1.png -------------------------------------------------------------------------------- /nyaa/static/img/icons/sukebei/1_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/icons/sukebei/1_2.png -------------------------------------------------------------------------------- /nyaa/static/img/icons/sukebei/1_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/icons/sukebei/1_3.png -------------------------------------------------------------------------------- /nyaa/static/img/icons/sukebei/1_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/icons/sukebei/1_4.png -------------------------------------------------------------------------------- /nyaa/static/img/icons/sukebei/1_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/icons/sukebei/1_5.png -------------------------------------------------------------------------------- /nyaa/static/img/icons/sukebei/2_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/icons/sukebei/2_1.png -------------------------------------------------------------------------------- /nyaa/static/img/icons/sukebei/2_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/img/icons/sukebei/2_2.png -------------------------------------------------------------------------------- /nyaa/templates/infobubble_content.html: -------------------------------------------------------------------------------- 1 | Put your announcements into infobubble_content.html! 2 | -------------------------------------------------------------------------------- /nyaa/static/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /nyaa/static/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /nyaa/static/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /nyaa/static/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /nyaa/static/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /nyaa/static/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /nyaa/static/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /nyaa/static/fonts/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyaadevs/nyaa/HEAD/nyaa/static/fonts/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from nyaa import create_app 3 | 4 | app = create_app('config') 5 | app.run(host='0.0.0.0', port=5500, debug=True) 6 | -------------------------------------------------------------------------------- /nyaa/templates/email/reset.txt: -------------------------------------------------------------------------------- 1 | {{ user.username }}, your password on {{ config.GLOBAL_SITE_NAME }} has just been successfully reset from a password-reset link. -------------------------------------------------------------------------------- /utils/batch_upload_torrent.sh: -------------------------------------------------------------------------------- 1 | up_t() { curl -F "category=1_2" -F "torrent_file=@$1" 'http://localhost:5500/upload'; } 2 | for x in test_torrent_batch/*; do up_t "$x"; done -------------------------------------------------------------------------------- /.docker/mariadb-init-sql/50-grant-binlog-access.sql: -------------------------------------------------------------------------------- 1 | GRANT REPLICATION SLAVE ON *.* TO 'nyaadev'@'%'; 2 | GRANT REPLICATION CLIENT ON *.* TO 'nyaadev'@'%'; 3 | FLUSH PRIVILEGES; 4 | -------------------------------------------------------------------------------- /nyaa/templates/email/reset-request.txt: -------------------------------------------------------------------------------- 1 | {{ user.username }}, you've requested to reset your password on {{ config.GLOBAL_SITE_NAME }}. Open the link below to change your password: 2 | 3 | {{ reset_link }} 4 | 5 | If you did not request a password reset, you may ignore this email. -------------------------------------------------------------------------------- /configs/my.cnf: -------------------------------------------------------------------------------- 1 | [mysqld] 2 | innodb_ft_min_token_size=2 3 | ft_min_word_len=2 4 | innodb_ft_cache_size = 80000000 5 | innodb_ft_total_cache_size = 1600000000 6 | max_allowed_packet = 100M 7 | 8 | [mariadb] 9 | log-bin 10 | server_id=1 11 | log-basename=master1 12 | binlog-format = row 13 | -------------------------------------------------------------------------------- /.docker/kibana.config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | server.name: kibana 4 | server.host: 'kibana' 5 | server.basePath: /kibana 6 | # server.rewriteBasePath: true 7 | # server.defaultRoute: /kibana/app/kibana 8 | elasticsearch.url: http://elasticsearch:9200 9 | xpack.monitoring.ui.container.elasticsearch.enabled: true 10 | -------------------------------------------------------------------------------- /nyaa/templates/email/verify.txt: -------------------------------------------------------------------------------- 1 | {{ user.username }}, please verify your email by clicking the link below: 2 | 3 | {{ activation_link }} 4 | (if you can't click on the link, copy and paste it to your browser's address bar) 5 | 6 | If you did not sign up for {{ config.GLOBAL_SITE_NAME }}, feel free to ignore this email. 7 | -------------------------------------------------------------------------------- /nyaa/templates/waiting.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}Awaiting Verification :: {{ config.SITE_NAME }}{% endblock %} 3 | {% block body %} 4 |

Awaiting Verification

5 |

Your account been registered. Please check your email for the verification link to activate your account.

6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /create_es.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | # create indices named "nyaa" and "sukebei", these are hardcoded 5 | curl -v -XPUT 'localhost:9200/nyaa?pretty' -H"Content-Type: application/yaml" --data-binary @es_mapping.yml 6 | curl -v -XPUT 'localhost:9200/sukebei?pretty' -H"Content-Type: application/yaml" --data-binary @es_mapping.yml 7 | -------------------------------------------------------------------------------- /es_sync_config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "save_loc": "/tmp/pos.json", 3 | "mysql_host": "127.0.0.1", 4 | "mysql_port": 3306, 5 | "mysql_user": "nyaa", 6 | "mysql_password": "some_password", 7 | "database": "nyaav2", 8 | "internal_queue_depth": 10000, 9 | "es_chunk_size": 10000, 10 | "flush_interval": 5 11 | } 12 | -------------------------------------------------------------------------------- /nyaa/templates/email/reset.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Your {{ config.GLOBAL_SITE_NAME }} password has been reset 4 | 5 | 6 |
7 | {{ user.username }}, your password on {{ config.GLOBAL_SITE_NAME }} has just been successfully reset from a password-reset link. 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /.docker/es_sync_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "save_loc": "/elasticsearch-sync/pos.json", 3 | "mysql_host": "mariadb", 4 | "mysql_port": 3306, 5 | "mysql_user": "nyaadev", 6 | "mysql_password": "ZmtB2oihHFvc39JaEDoF", 7 | "database": "nyaav2", 8 | "internal_queue_depth": 10000, 9 | "es_chunk_size": 10000, 10 | "flush_interval": 5 11 | } 12 | -------------------------------------------------------------------------------- /nyaa/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}404 Not Found :: {{ config.SITE_NAME }}{% endblock %} 3 | {% block metatags %} 4 | 5 | {% endblock %} 6 | {% block body %} 7 |

404 Not Found

8 |

The path you requested does not exist on this server.

9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /trackers.txt: -------------------------------------------------------------------------------- 1 | # These trackers will be added to all generated torrents, 2 | # to ensure the torrents' continued life in case one or two die. 3 | # One tracker per line, lines starting with # are disregarded 4 | udp://open.stealth.si:80/announce 5 | udp://tracker.opentrackr.org:1337/announce 6 | udp://tracker.coppersurfer.tk:6969/announce 7 | udp://exodus.desync.com:6969/announce 8 | -------------------------------------------------------------------------------- /nyaa/templates/flashes.html: -------------------------------------------------------------------------------- 1 | {% with messages = get_flashed_messages(with_categories=true) %} 2 | {% if messages %} 3 | {% for category, message in messages %} 4 | 8 | {% endfor %} 9 | {% endif %} 10 | {% endwith %} 11 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | Describe your issue/feature request here (you can remove all this text). Describe well and include images, if relevant! 2 | 3 | Please make sure to skim through the existing issues, your issue/request/etc may have already been noted! 4 | 5 | IMPORTANT: only submit issues that are relevant to the code. We do not offer support for any deployments of the project here; make your way to the IRC channel in such cases. 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Cache 2 | __pycache__ 3 | /nyaa/static/.webassets-cache 4 | 5 | # Virtual Environments 6 | /venv 7 | 8 | # Coverage 9 | .coverage 10 | /htmlcov 11 | 12 | # Editors 13 | /.vscode 14 | 15 | # Databases 16 | *.sql 17 | /test.db 18 | 19 | # Webserver 20 | /uwsgi.sock 21 | 22 | # Application 23 | /install/* 24 | /config.py 25 | /es_sync_config.json 26 | /test_torrent_batch 27 | 28 | # Build Output 29 | nyaa/static/js/bootstrap-select.min.js 30 | nyaa/static/js/main.min.js 31 | 32 | # Other 33 | *.swp 34 | -------------------------------------------------------------------------------- /WSGI.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # -*- coding: utf-8 -*- 3 | import gevent.monkey 4 | gevent.monkey.patch_all() 5 | 6 | from nyaa import create_app 7 | 8 | app = create_app('config') 9 | 10 | if app.config['DEBUG']: 11 | from werkzeug.debug import DebuggedApplication 12 | app.wsgi_app = DebuggedApplication(app.wsgi_app, True) 13 | 14 | if __name__ == '__main__': 15 | import gevent.pywsgi 16 | gevent_server = gevent.pywsgi.WSGIServer(("localhost", 5000), app.wsgi_app) 17 | gevent_server.serve_forever() 18 | -------------------------------------------------------------------------------- /nyaa/templates/trusted.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}Trusted :: {{ config.SITE_NAME }}{% endblock %} 3 | {% block body %} 4 | 5 |
6 |
7 |
8 | {% include "trusted_rules.html" %} 9 |
10 |
11 |
12 | 15 |
16 |
17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /nyaa/templates/email/trusted.txt: -------------------------------------------------------------------------------- 1 | {% if is_accepted %} 2 | Congratulations! Your Trusted status application on {{ config.GLOBAL_SITE_NAME }} was accepted. You can now edit your torrents and set the Trusted flag on them. 3 | {% else %} 4 | We're sorry to inform you that we've rejected your Trusted status application on {{ config.GLOBAL_SITE_NAME }}. You can re-apply for Trusted status in {{ config.TRUSTED_REAPPLY_COOLDOWN }} days if you wish to do so. 5 | {% endif %} 6 | 7 | Regards 8 | The {{ config.GLOBAL_SITE_NAME }} Moderation Team 9 | -------------------------------------------------------------------------------- /.docker/nyaa-config-partial.py: -------------------------------------------------------------------------------- 1 | # This is only a partial config file that will be appended to the end of 2 | # config.example.py to build the full config for the docker environment 3 | 4 | SITE_NAME = 'Nyaa [DEVEL]' 5 | GLOBAL_SITE_NAME = 'nyaa.devel' 6 | SQLALCHEMY_DATABASE_URI = ('mysql://nyaadev:ZmtB2oihHFvc39JaEDoF@mariadb/nyaav2?charset=utf8mb4') 7 | # MAIN_ANNOUNCE_URL = 'http://chihaya:6881/announce' 8 | # TRACKER_API_URL = 'http://chihaya:6881/api' 9 | BACKUP_TORRENT_FOLDER = '/nyaa-torrents' 10 | ES_HOSTS = ['elasticsearch:9200'] 11 | -------------------------------------------------------------------------------- /db_migrate.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import sys 4 | 5 | from flask_script import Manager 6 | from flask_migrate import Migrate, MigrateCommand 7 | 8 | from nyaa import create_app 9 | from nyaa.extensions import db 10 | 11 | app = create_app('config') 12 | migrate = Migrate(app, db) 13 | 14 | manager = Manager(app) 15 | manager.add_command("db", MigrateCommand) 16 | 17 | if __name__ == "__main__": 18 | # Patch sys.argv to default to 'db' 19 | sys.argv.insert(1, 'db') 20 | 21 | manager.run() 22 | -------------------------------------------------------------------------------- /.docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:18.04 2 | 3 | ENV LANG=en_US.utf-8 LC_ALL=en_US.utf-8 DEBIAN_FRONTEND=noninteractive 4 | RUN apt-get -y update 5 | 6 | COPY ./ /nyaa/ 7 | RUN cat /nyaa/config.example.py /nyaa/.docker/nyaa-config-partial.py > /nyaa/config.py 8 | 9 | # Requirements for running the Flask app 10 | RUN apt-get -y install build-essential git python3 python3-pip libmysqlclient-dev curl 11 | # Helpful stuff for the docker entrypoint.sh script 12 | RUN apt-get -y install mariadb-client netcat 13 | 14 | WORKDIR /nyaa 15 | RUN pip3 install -r requirements.txt 16 | 17 | CMD ["/nyaa/.docker/entrypoint.sh"] 18 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max_line_length = 100 3 | # The following line raises an exception on `pip install flake8` 4 | # So we're using the command line argument instead. 5 | # format = %(path)s [%(row)s:%(col)s] %(code)s: %(text)s 6 | 7 | [pep8] 8 | max_line_length = 100 9 | pep8_passes = 2000 10 | in_place = 1 11 | recursive = 1 12 | verbose = 1 13 | 14 | [isort] 15 | line_length = 100 16 | not_skip = __init__.py 17 | default_section = THIRDPARTY 18 | known_first_party = nyaa 19 | known_flask = 20 | flask*, 21 | jinja2, 22 | markupsafe, 23 | werkzeug, 24 | wtforms 25 | sections = STDLIB,FLASK,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 26 | -------------------------------------------------------------------------------- /uwsgi.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | # socket = [addr:port] 3 | socket = uwsgi.sock 4 | chmod-socket = 664 5 | 6 | # logging 7 | disable-logging = True 8 | #logger = file:uwsgi.log 9 | 10 | # Base application directory 11 | #chdir = . 12 | 13 | # WSGI module and callable 14 | # module = [wsgi_module_name]:[application_callable_name] 15 | module = WSGI:app 16 | 17 | # master = [master process (true of false)] 18 | master = true 19 | 20 | # debugging 21 | catch-exceptions = True 22 | 23 | # performance 24 | processes = 4 25 | buffer-size = 8192 26 | 27 | loop = gevent 28 | socket-timeout = 10 29 | gevent = 1000 30 | gevent-monkey-patch = true 31 | -------------------------------------------------------------------------------- /migrations/versions/ffd23e570f92_add_is_webseed_to_trackers.py: -------------------------------------------------------------------------------- 1 | """Add is_webseed to Trackers 2 | 3 | Revision ID: ffd23e570f92 4 | Revises: 1add911660a6 5 | Create Date: 2017-07-29 19:03:58.244769 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'ffd23e570f92' 14 | down_revision = '1add911660a6' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | op.add_column('trackers', sa.Column('is_webseed', sa.Boolean(), nullable=False)) 21 | 22 | 23 | def downgrade(): 24 | op.drop_column('trackers', 'is_webseed') 25 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # 2 | # Makes sure all files detected as text have LF as the EOL character, 3 | # and leaves all files detected as binary untouched. 4 | # 5 | 6 | * text=auto eol=lf 7 | 8 | # 9 | # Text files 10 | # 11 | 12 | # Example 13 | # *.ext text 14 | 15 | # 16 | # Binary files (binary is a macro for -text -diff) 17 | # 18 | 19 | # Minified web files 20 | *.min.css binary 21 | *.min.js binary 22 | 23 | # Images 24 | *.png binary 25 | *.jpg binary 26 | *.jpeg binary 27 | *.gif binary 28 | *.ico binary 29 | 30 | # Fonts 31 | *.svg binary 32 | *.ttf binary 33 | *.woff binary 34 | *.woff2 binary 35 | *.eot binary 36 | *.otf binary 37 | -------------------------------------------------------------------------------- /nyaa/templates/infobubble.html: -------------------------------------------------------------------------------- 1 | {# Update this to a larger timestamp if you change your announcement #} 2 | {# A value of 0 disables the announcements altogether #} 3 | {% set info_ts = 0 %} 4 | {% if info_ts > 0 %} 5 | 11 | 16 | {% endif %} 17 | -------------------------------------------------------------------------------- /nyaa/templates/email/verify.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Verify your {{ config.GLOBAL_SITE_NAME }} account 4 | 12 | 13 | 14 |
15 | {{ user.username }}, please verify your email by clicking the link below: 16 |
17 |
18 | {{ activation_link }} 19 |
20 |
21 | If you did not sign up for {{ config.GLOBAL_SITE_NAME }}, feel free to ignore this email. 22 |
23 | 24 | 25 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: "3.7" 4 | 5 | dist: xenial 6 | sudo: required 7 | 8 | matrix: 9 | fast_finish: true 10 | 11 | cache: pip 12 | 13 | services: 14 | mysql 15 | 16 | before_install: 17 | - mysql -u root -e 'CREATE DATABASE nyaav2 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;' 18 | 19 | install: 20 | - pip install -r requirements.txt 21 | - pip install pytest-cov 22 | - sed "s/mysql:\/\/test:test123@/mysql:\/\/root:@/" config.example.py > config.py 23 | - ./db_create.py 24 | - ./db_migrate.py stamp head 25 | 26 | script: 27 | - ./dev.py test --cov=nyaa --cov-report=term tests 28 | - ./dev.py lint 29 | 30 | notifications: 31 | email: false 32 | -------------------------------------------------------------------------------- /nyaa/views/site.py: -------------------------------------------------------------------------------- 1 | import flask 2 | 3 | bp = flask.Blueprint('site', __name__) 4 | 5 | 6 | # @bp.route('/about', methods=['GET']) 7 | # def about(): 8 | # return flask.render_template('about.html') 9 | 10 | 11 | @bp.route('/rules', methods=['GET']) 12 | def rules(): 13 | return flask.render_template('rules.html') 14 | 15 | 16 | @bp.route('/help', methods=['GET']) 17 | def help(): 18 | return flask.render_template('help.html') 19 | 20 | 21 | @bp.route('/xmlns/nyaa', methods=['GET']) 22 | def xmlns_nyaa(): 23 | return flask.render_template('xmlns.html') 24 | 25 | 26 | @bp.route('/trusted', methods=['GET']) 27 | def trusted(): 28 | return flask.render_template('trusted.html') 29 | -------------------------------------------------------------------------------- /.docker/uwsgi.config.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | # socket = [addr:port] 3 | socket = 0.0.0.0:5000 4 | #chmod-socket = 664 5 | 6 | die-on-term = true 7 | 8 | # logging 9 | #disable-logging = True 10 | #logger = file:uwsgi.log 11 | 12 | # Base application directory 13 | chdir = /nyaa 14 | 15 | # WSGI module and callable 16 | # module = [wsgi_module_name]:[application_callable_name] 17 | module = WSGI:app 18 | 19 | # master = [master process (true of false)] 20 | master = true 21 | 22 | # debugging 23 | catch-exceptions = true 24 | 25 | # performance 26 | processes = 4 27 | buffer-size = 8192 28 | 29 | loop = gevent 30 | socket-timeout = 10 31 | gevent = 1000 32 | gevent-monkey-patch = true 33 | 34 | py-autoreload = 2 35 | -------------------------------------------------------------------------------- /nyaa/templates/email/reset-request.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ config.GLOBAL_SITE_NAME }} password reset request 4 | 12 | 13 | 14 |
15 | {{ user.username }}, you've requested to reset your password on {{ config.GLOBAL_SITE_NAME }}. Click the link below to change your password: 16 |
17 |
18 | {{ reset_link }} 19 |
20 |
21 | If you did not request a password reset, you may ignore this email. 22 |
23 | 24 | 25 | -------------------------------------------------------------------------------- /nyaa/templates/email/trusted.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Your {{ config.GLOBAL_SITE_NAME }} Trusted Application was {{ 'accepted' if is_accepted else 'rejected' }} 4 | 5 | 6 | {% if is_accepted %} 7 |

Congratulations! Your Trusted status application on {{ config.GLOBAL_SITE_NAME }} was accepted. You can now edit your torrents and set the Trusted flag on them.

8 | {% else %} 9 |

We're sorry to inform you that we've rejected your Trusted status application on {{ config.GLOBAL_SITE_NAME }}. You can re-apply for Trusted status in {{ config.TRUSTED_REAPPLY_COOLDOWN }} days if you wish to do so.

10 | {% endif %} 11 |

Regards
12 | The {{ config.GLOBAL_SITE_NAME }} Moderation Team

13 | 14 | 15 | -------------------------------------------------------------------------------- /utils/simple_bench.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Simple benchmark tool, requires X-Timer header in the response headers 3 | import requests 4 | 5 | BASE_URL = 'http://127.0.0.1:5500/' 6 | 7 | PAGES = 10 8 | PER_PAGE = 20 9 | 10 | 11 | def do_time(url): 12 | r = requests.get(url) 13 | return float(r.headers['X-Timer']) 14 | 15 | 16 | print('Warmup:', do_time(BASE_URL)) 17 | for i in range(1, PAGES + 1): 18 | page_url = BASE_URL + '?=' + str(i) 19 | 20 | page_times = [ 21 | do_time(page_url) for _ in range(PER_PAGE) 22 | ] 23 | 24 | print('Page {:3d}: min:{:5.1f}ms max:{:5.1f}ms avg:{:5.1f}ms'.format( 25 | i, 26 | min(page_times) * 1000, 27 | max(page_times) * 1000, 28 | sum(page_times) / len(page_times) * 1000 29 | )) 30 | -------------------------------------------------------------------------------- /.docker/entrypoint-sync.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # set +x 4 | 5 | pushd /nyaa 6 | 7 | echo 'Waiting for MySQL to start up' 8 | while ! echo HELO | nc mariadb 3306 &>/dev/null; do 9 | sleep 1 10 | done 11 | echo 'DONE' 12 | 13 | echo 'Waiting for ES to start up' 14 | while ! echo HELO | nc elasticsearch 9200 &>/dev/null; do 15 | sleep 1 16 | done 17 | echo 'DONE' 18 | 19 | echo 'Waiting for ES to be ready' 20 | while ! curl -s -XGET 'elasticsearch:9200/_cluster/health?pretty=true&wait_for_status=green' &>/dev/null; do 21 | sleep 1 22 | done 23 | echo 'DONE' 24 | 25 | echo 'Waiting for sync data file to exist' 26 | while ! [ -f /elasticsearch-sync/pos.json ]; do 27 | sleep 1 28 | done 29 | echo 'DONE' 30 | 31 | echo 'Starting the sync process' 32 | /usr/bin/python3 /nyaa/sync_es.py /nyaa/.docker/es_sync_config.json 33 | -------------------------------------------------------------------------------- /migrations/versions/3001f79b7722_add_torrents.uploader_ip.py: -------------------------------------------------------------------------------- 1 | """Add uploader_ip column to torrents table. 2 | 3 | Revision ID: 3001f79b7722 4 | Revises: 5 | Create Date: 2017-05-21 18:01:35.472717 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '3001f79b7722' 14 | down_revision = '97ddefed1834' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | TABLE_PREFIXES = ('nyaa', 'sukebei') 19 | 20 | 21 | def upgrade(): 22 | 23 | for prefix in TABLE_PREFIXES: 24 | op.add_column(prefix + '_torrents', sa.Column('uploader_ip', sa.Binary(), nullable=True)) 25 | # ### end Alembic commands ### 26 | 27 | 28 | def downgrade(): 29 | for prefix in TABLE_PREFIXES: 30 | op.drop_column(prefix + '_torrents', 'uploader_ip') 31 | -------------------------------------------------------------------------------- /migrations/versions/f703f911d4ae_add_registration_ip.py: -------------------------------------------------------------------------------- 1 | """add registration IP 2 | 3 | Revision ID: f703f911d4ae 4 | Revises: f69d7fec88d6 5 | Create Date: 2018-07-09 13:04:50.652781 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'f703f911d4ae' 14 | down_revision = 'f69d7fec88d6' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('users', sa.Column('registration_ip', sa.Binary(), nullable=True)) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_column('users', 'registration_ip') 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /nyaa/templates/home.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}{% if search.term %}{{ search.term | e}}{% else %}Browse{% endif %} :: {{ config.SITE_NAME }}{% endblock %} 3 | {% block metatags %} 4 | {% if search.term %} 5 | 6 | {% else %} 7 | 8 | 9 | 10 | {% endif %} 11 | {% endblock %} 12 | {% block body %} 13 | 14 | {% if not search.term %} 15 | {% include "infobubble.html" %} 16 | {% endif %} 17 | 18 | {% include "search_results.html" %} 19 | 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /migrations/versions/cf7bf6d0e6bd_add_edited_time_to_comments.py: -------------------------------------------------------------------------------- 1 | """Add edited_time to Comments 2 | 3 | Revision ID: cf7bf6d0e6bd 4 | Revises: 500117641608 5 | Create Date: 2017-10-28 15:32:12.687378 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'cf7bf6d0e6bd' 14 | down_revision = '500117641608' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('nyaa_comments', sa.Column('edited_time', sa.DateTime(), nullable=True)) 22 | op.add_column('sukebei_comments', sa.Column('edited_time', sa.DateTime(), nullable=True)) 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade(): 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | op.drop_column('sukebei_comments', 'edited_time') 29 | op.drop_column('nyaa_comments', 'edited_time') 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /nyaa/templates/password_reset.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}Password reset :: {{ config.SITE_NAME }}{% endblock %} 3 | {% block metatags %} 4 | 5 | {% endblock %} 6 | {% block body %} 7 | {% from "_formhelpers.html" import render_field %} 8 | 9 |

Password reset

10 |
11 | {{ form.csrf_token }} 12 | 13 |
14 |
15 | {{ render_field(form.password, class_='form-control', placeholder='Password') }} 16 |
17 |
18 | 19 |
20 |
21 | {{ render_field(form.password_confirm, class_='form-control', placeholder='Password (confirm)') }} 22 |
23 |
24 | 25 |
26 |
27 | 28 |
29 |
30 |
31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Lint checker/fixer 3 | # This script is deprecated, but still works. 4 | 5 | function auto_fix() { 6 | ./dev.py fix && ./dev.py isort 7 | } 8 | 9 | 10 | function check_lint() { 11 | ./dev.py lint 12 | } 13 | 14 | # MAIN 15 | action=auto_fix # default action 16 | for arg in "$@" 17 | do 18 | case "$arg" in 19 | "-h" | "--help") 20 | echo "+ ========================= +" 21 | echo "+ This script is deprecated +" 22 | echo "+ Please use ./dev.py +" 23 | echo "+ ========================= +" 24 | echo "" 25 | echo "Lint checker/fixer" 26 | echo "" 27 | echo "Usage: $0 [-c|--check] [-h|--help]" 28 | echo " No arguments : Check and auto-fix some warnings/errors" 29 | echo " -c | --check : only check lint (don't auto-fix)" 30 | echo " -h | --help : show this help and exit" 31 | exit 0; 32 | ;; 33 | "-c" | "--check") 34 | action=check_lint 35 | ;; 36 | esac 37 | done 38 | 39 | ${action} # run selected action 40 | if [[ $? -ne 0 ]]; then exit 1; fi 41 | -------------------------------------------------------------------------------- /tests/test_api_handler.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import json 3 | 4 | from nyaa import api_handler, models 5 | from tests import NyaaTestCase 6 | from pprint import pprint 7 | 8 | 9 | class ApiHandlerTests(NyaaTestCase): 10 | 11 | # @classmethod 12 | # def setUpClass(cls): 13 | # super(ApiHandlerTests, cls).setUpClass() 14 | 15 | # @classmethod 16 | # def tearDownClass(cls): 17 | # super(ApiHandlerTests, cls).tearDownClass() 18 | 19 | def test_no_authorization(self): 20 | """ Test that API is locked unless you're logged in """ 21 | rv = self.app.get('/api/info/1') 22 | data = json.loads(rv.get_data()) 23 | self.assertDictEqual({'errors': ['Bad authorization']}, data) 24 | 25 | @unittest.skip('Not yet implemented') 26 | def test_bad_credentials(self): 27 | """ Test that API is locked unless you're logged in """ 28 | rv = self.app.get('/api/info/1') 29 | data = json.loads(rv.get_data()) 30 | self.assertDictEqual({'errors': ['Bad authorization']}, data) 31 | 32 | 33 | if __name__ == '__main__': 34 | unittest.main() 35 | -------------------------------------------------------------------------------- /nyaa/templates/password_reset_request.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}Password reset request :: {{ config.SITE_NAME }}{% endblock %} 3 | {% block metatags %} 4 | 5 | {% endblock %} 6 | {% block body %} 7 | {% from "_formhelpers.html" import render_field %} 8 | 9 |

Request password reset

10 |
11 | {{ form.csrf_token }} 12 | 13 |
14 |
15 | {{ render_field(form.email, class_='form-control', placeholder='Email address') }} 16 |
17 |
18 | 19 | {% if config.USE_RECAPTCHA %} 20 |
21 |
22 | {% for error in form.recaptcha.errors %} 23 | {{ error }} 24 | {% endfor %} 25 | {{ form.recaptcha }} 26 |
27 |
28 |
29 | 30 | {% endif %} 31 | 32 |
33 |
34 | 35 |
36 |
37 |
38 | {% endblock %} 39 | -------------------------------------------------------------------------------- /migrations/versions/b79d2fcafd88_comment_text.py: -------------------------------------------------------------------------------- 1 | """Change comment text field from VARCHAR(255) to mysql.TEXT 2 | 3 | Revision ID: b79d2fcafd88 4 | Revises: ffd23e570f92 5 | Create Date: 2017-08-14 18:57:44.165168 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import mysql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'b79d2fcafd88' 14 | down_revision = 'ffd23e570f92' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | TABLE_PREFIXES = ('nyaa', 'sukebei') 19 | 20 | def upgrade(): 21 | for prefix in TABLE_PREFIXES: 22 | op.alter_column(prefix + '_comments', 'text', 23 | existing_type=mysql.VARCHAR(charset='utf8mb4', collation='utf8mb4_bin', length=255), 24 | type_=mysql.TEXT(collation='utf8mb4_bin'), 25 | existing_nullable=False) 26 | 27 | 28 | def downgrade(): 29 | for prefix in TABLE_PREFIXES: 30 | op.alter_column(prefix + '_comments', 'text', 31 | existing_type=mysql.TEXT(collation='utf8mb4_bin'), 32 | type_=mysql.VARCHAR(charset='utf8mb4', collation='utf8mb4_bin', length=255), 33 | existing_nullable=False) 34 | -------------------------------------------------------------------------------- /nyaa/templates/adminlog.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}Admin Log :: {{ config.SITE_NAME }}{% endblock %} 3 | {% block body %} 4 | {% from "_formhelpers.html" import render_field %} 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% for log in adminlog.items %} 17 | 18 | 19 | 22 | {% if g.user.is_superadmin %} 23 | 24 | {% else %} 25 | 26 | {% endif %} 27 | 28 | 29 | {% endfor %} 30 | 31 |
#Moderator/AdminLogDate
{{ log.id }} 20 | {{ log.admin.username }} 21 |
{{ log.log }}
{{ log.log | regex_replace("IP\(.*?\)", "IP(hidden)" )}}
{{ log.created_time }}
32 |
33 | 34 | 38 | {% endblock %} 39 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """ Sets up helper class for testing """ 2 | 3 | import os 4 | import unittest 5 | 6 | from nyaa import create_app 7 | 8 | USE_MYSQL = True 9 | 10 | 11 | class NyaaTestCase(unittest.TestCase): 12 | 13 | @classmethod 14 | def setUpClass(cls): 15 | app = create_app('config') 16 | app.config['TESTING'] = True 17 | cls.app_context = app.app_context() 18 | 19 | # Use a separate database for testing 20 | # if USE_MYSQL: 21 | # cls.db_name = 'nyaav2_tests' 22 | # db_uri = 'mysql://root:@localhost/{}?charset=utf8mb4'.format(cls.db_name) 23 | # else: 24 | # cls.db_name = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'test.db') 25 | # db_uri = 'sqlite:///{}?check_same_thread=False'.format(cls.db_name) 26 | 27 | # if not os.environ.get('TRAVIS'): # Travis doesn't need a separate DB 28 | # app.config['USE_MYSQL'] = USE_MYSQL 29 | # app.config['SQLALCHEMY_DATABASE_URI'] = db_uri 30 | 31 | with cls.app_context: 32 | cls.app = app.test_client() 33 | 34 | @classmethod 35 | def tearDownClass(cls): 36 | with cls.app_context: 37 | pass 38 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.0.11 2 | appdirs==1.4.3 3 | argon2-cffi==19.1.0 4 | autopep8==1.4.4 5 | blinker==1.4 6 | cffi==1.12.3 7 | click==7.0 8 | dnspython==1.16.0 9 | elasticsearch==7.0.2 10 | elasticsearch-dsl==7.0.0 11 | flake8==3.7.8 12 | flake8-isort==2.7.0 13 | Flask==1.1.1 14 | Flask-Assets==0.12 15 | Flask-DebugToolbar==0.10.1 16 | Flask-Migrate==2.5.2 17 | flask-paginate==0.5.3 18 | Flask-Script==2.0.6 19 | Flask-SQLAlchemy==2.4.0 20 | Flask-WTF==0.14.2 21 | gevent==1.4.0 22 | greenlet==0.4.15 23 | isort==4.3.21 24 | itsdangerous==1.1.0 25 | Jinja2==2.10.1 26 | Mako==1.1.0 27 | MarkupSafe==1.1.1 28 | mysql-replication==0.19 29 | mysqlclient==1.4.3 30 | orderedset==2.0.1 31 | packaging==19.1 32 | passlib==1.7.1 33 | progressbar33==2.4 34 | py==1.8.0 35 | pycodestyle==2.5.0 36 | pycparser==2.19 37 | PyMySQL==0.9.3 38 | pyparsing==2.4.2 39 | pytest==5.0.1 40 | python-dateutil==2.8.0 41 | python-editor==1.0.4 42 | python-utils==2.3.0 43 | requests==2.22.0 44 | SQLAlchemy==1.3.6 45 | SQLAlchemy-FullText-Search==0.2.5 46 | SQLAlchemy-Utils==0.34.1 47 | statsd==3.3.0 48 | urllib3==1.25.3 49 | uWSGI==2.0.18 50 | redis==3.2.1 51 | webassets==0.12.1 52 | Werkzeug==0.15.5 53 | WTForms==2.2.1 54 | Flask-Caching==1.7.2 55 | Flask-Limiter==1.0.1 56 | -------------------------------------------------------------------------------- /utils/infodict_mysql2file.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | import os 3 | import sys 4 | 5 | import MySQLdb 6 | import MySQLdb.cursors 7 | 8 | if len(sys.argv) != 3: 9 | print("Usage: {0} ".format(sys.argv[0])) 10 | sys.exit(1) 11 | 12 | prefix = sys.argv[1] 13 | outdir = sys.argv[2] 14 | if not os.path.exists(outdir): 15 | os.makedirs(outdir) 16 | 17 | 18 | db = MySQLdb.connect(host='localhost', 19 | user='test', 20 | passwd='test123', 21 | db='nyaav2', 22 | cursorclass=MySQLdb.cursors.SSCursor) 23 | cur = db.cursor() 24 | 25 | cur.execute( 26 | """SELECT 27 | id, 28 | info_hash, 29 | info_dict 30 | FROM 31 | {0}_torrents 32 | JOIN {0}_torrents_info ON torrent_id = id 33 | """.format(prefix)) 34 | 35 | for row in cur: 36 | id = row[0] 37 | info_hash = row[1].hex().lower() 38 | info_dict = row[2] 39 | 40 | path = os.path.join(outdir, info_hash[0:2], info_hash[2:4]) 41 | if not os.path.exists(path): 42 | os.makedirs(path) 43 | path = os.path.join(path, info_hash) 44 | 45 | with open(path, 'wb') as fp: 46 | fp.write(info_dict) 47 | -------------------------------------------------------------------------------- /migrations/versions/d0eeb8049623_add_comments.py: -------------------------------------------------------------------------------- 1 | """Add comments table. 2 | 3 | Revision ID: d0eeb8049623 4 | Revises: 3001f79b7722 5 | Create Date: 2017-05-22 22:58:12.039149 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'd0eeb8049623' 14 | down_revision = '3001f79b7722' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | TABLE_PREFIXES = ('nyaa', 'sukebei') 19 | 20 | 21 | def upgrade(): 22 | for prefix in TABLE_PREFIXES: 23 | op.create_table(prefix + '_comments', 24 | sa.Column('id', sa.Integer(), nullable=False), 25 | sa.Column('torrent_id', sa.Integer(), nullable=False), 26 | sa.Column('user_id', sa.Integer(), nullable=True), 27 | sa.Column('created_time', sa.DateTime(), nullable=True), 28 | sa.Column('text', sa.String(length=255, collation='utf8mb4_bin'), nullable=False), 29 | sa.ForeignKeyConstraint(['torrent_id'], [prefix + '_torrents.id'], ondelete='CASCADE'), 30 | sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), 31 | sa.PrimaryKeyConstraint('id') 32 | ) 33 | 34 | 35 | def downgrade(): 36 | for prefix in TABLE_PREFIXES: 37 | op.drop_table(prefix + '_comments') 38 | -------------------------------------------------------------------------------- /migrations/versions/8a6a7662eb37_add_user_preferences_table.py: -------------------------------------------------------------------------------- 1 | """Add user preferences table 2 | 3 | Revision ID: 8a6a7662eb37 4 | Revises: f703f911d4ae 5 | Create Date: 2018-11-20 17:02:26.408532 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '8a6a7662eb37' 14 | down_revision = 'f703f911d4ae' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('user_preferences', 22 | sa.Column('user_id', sa.Integer(), nullable=False), 23 | sa.Column('hide_comments', sa.Boolean(), server_default=sa.sql.expression.false(), nullable=False), 24 | sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), 25 | sa.PrimaryKeyConstraint('user_id') 26 | ) 27 | 28 | connection = op.get_bind() 29 | 30 | print('Populating user_preferences...') 31 | connection.execute(sa.sql.text('INSERT INTO user_preferences (user_id) SELECT id FROM users')) 32 | print('Done.') 33 | # ### end Alembic commands ### 34 | 35 | 36 | def downgrade(): 37 | # ### commands auto generated by Alembic - please adjust! ### 38 | op.drop_table('user_preferences') 39 | # ### end Alembic commands ### 40 | -------------------------------------------------------------------------------- /nyaa/static/style.css: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | html, body, div, span, applet, object, iframe, 6 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 7 | a, abbr, acronym, address, big, cite, code, 8 | del, dfn, em, img, ins, kbd, q, s, samp, 9 | small, strike, strong, sub, sup, tt, var, 10 | b, u, i, center, 11 | dl, dt, dd, ol, ul, li, 12 | fieldset, form, label, legend, 13 | table, caption, tbody, tfoot, thead, tr, th, td, 14 | article, aside, canvas, details, embed, 15 | figure, figcaption, footer, header, hgroup, 16 | menu, nav, output, ruby, section, summary, 17 | time, mark, audio, video { 18 | margin: 0; 19 | padding: 0; 20 | border: 0; 21 | font-size: 100%; 22 | font: inherit; 23 | vertical-align: baseline; } 24 | 25 | /* HTML5 display-role reset for older browsers */ 26 | article, aside, details, figcaption, figure, 27 | footer, header, hgroup, menu, nav, section { 28 | display: block; } 29 | 30 | body { 31 | line-height: 1; } 32 | 33 | ol, ul { 34 | list-style: none; } 35 | 36 | blockquote, q { 37 | quotes: none; } 38 | 39 | blockquote:before, blockquote:after, 40 | q:before, q:after { 41 | content: ''; 42 | content: none; } 43 | 44 | table { 45 | border-collapse: collapse; 46 | border-spacing: 0; } 47 | -------------------------------------------------------------------------------- /migrations/versions/6cc823948c5a_add_trackerapi.py: -------------------------------------------------------------------------------- 1 | """Add trackerapi table 2 | 3 | Revision ID: 6cc823948c5a 4 | Revises: b61e4f6a88cc 5 | Create Date: 2018-02-11 20:57:15.244171 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '6cc823948c5a' 14 | down_revision = 'b61e4f6a88cc' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('nyaa_trackerapi', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('info_hash', sa.BINARY(length=20), nullable=False), 24 | sa.Column('method', sa.String(length=255), nullable=False), 25 | sa.PrimaryKeyConstraint('id') 26 | ) 27 | op.create_table('sukebei_trackerapi', 28 | sa.Column('id', sa.Integer(), nullable=False), 29 | sa.Column('info_hash', sa.BINARY(length=20), nullable=False), 30 | sa.Column('method', sa.String(length=255), nullable=False), 31 | sa.PrimaryKeyConstraint('id') 32 | ) 33 | # ### end Alembic commands ### 34 | 35 | 36 | def downgrade(): 37 | # ### commands auto generated by Alembic - please adjust! ### 38 | op.drop_table('sukebei_trackerapi') 39 | op.drop_table('nyaa_trackerapi') 40 | # ### end Alembic commands ### 41 | -------------------------------------------------------------------------------- /migrations/versions/500117641608_add_bans.py: -------------------------------------------------------------------------------- 1 | """Add bans table 2 | 3 | Revision ID: 500117641608 4 | Revises: b79d2fcafd88 5 | Create Date: 2017-08-17 01:44:39.205126 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '500117641608' 14 | down_revision = 'b79d2fcafd88' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | op.create_table('bans', 21 | sa.Column('id', sa.Integer(), nullable=False), 22 | sa.Column('created_time', sa.DateTime(), nullable=True), 23 | sa.Column('admin_id', sa.Integer(), nullable=False), 24 | sa.Column('user_id', sa.Integer(), nullable=True), 25 | sa.Column('user_ip', sa.Binary(length=16), nullable=True), 26 | sa.Column('reason', sa.String(length=2048), nullable=False), 27 | sa.ForeignKeyConstraint(['admin_id'], ['users.id'], ), 28 | sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), 29 | sa.PrimaryKeyConstraint('id'), 30 | ) 31 | op.create_index('user_ip_16', 'bans', ['user_ip'], unique=True, mysql_length=16) 32 | op.create_index('user_ip_4', 'bans', ['user_ip'], unique=True, mysql_length=4) 33 | 34 | 35 | def downgrade(): 36 | op.drop_index('user_ip_4', table_name='bans') 37 | op.drop_index('user_ip_16', table_name='bans') 38 | op.drop_table('bans') 39 | -------------------------------------------------------------------------------- /migrations/versions/f69d7fec88d6_add_rangebans.py: -------------------------------------------------------------------------------- 1 | """add rangebans 2 | 3 | Revision ID: f69d7fec88d6 4 | Revises: 6cc823948c5a 5 | Create Date: 2018-06-01 14:01:49.596007 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'f69d7fec88d6' 14 | down_revision = '6cc823948c5a' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('rangebans', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('cidr_string', sa.String(length=18), nullable=False), 24 | sa.Column('masked_cidr', sa.BigInteger(), nullable=False), 25 | sa.Column('mask', sa.BigInteger(), nullable=False), 26 | sa.Column('enabled', sa.Boolean(), nullable=False), 27 | sa.Column('temp', sa.DateTime(), nullable=True), 28 | sa.PrimaryKeyConstraint('id') 29 | ) 30 | op.create_index(op.f('ix_rangebans_mask'), 'rangebans', ['mask'], unique=False) 31 | op.create_index(op.f('ix_rangebans_masked_cidr'), 'rangebans', ['masked_cidr'], unique=False) 32 | # ### end Alembic commands ### 33 | 34 | 35 | def downgrade(): 36 | # ### commands auto generated by Alembic - please adjust! ### 37 | op.drop_index(op.f('ix_rangebans_masked_cidr'), table_name='rangebans') 38 | op.drop_index(op.f('ix_rangebans_mask'), table_name='rangebans') 39 | op.drop_table('rangebans') 40 | # ### end Alembic commands ### 41 | -------------------------------------------------------------------------------- /migrations/versions/1add911660a6_admin_log_added.py: -------------------------------------------------------------------------------- 1 | """Admin log added 2 | 3 | Revision ID: 1add911660a6 4 | Revises: 7f064e009cab 5 | Create Date: 2017-06-29 02:57:39.715965 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '1add911660a6' 14 | down_revision = '7f064e009cab' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('nyaa_adminlog', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('created_time', sa.DateTime(), nullable=True), 24 | sa.Column('log', sa.String(length=1024), nullable=False), 25 | sa.Column('admin_id', sa.Integer(), nullable=False), 26 | sa.ForeignKeyConstraint(['admin_id'], ['users.id'], ), 27 | sa.PrimaryKeyConstraint('id') 28 | ) 29 | op.create_table('sukebei_adminlog', 30 | sa.Column('id', sa.Integer(), nullable=False), 31 | sa.Column('created_time', sa.DateTime(), nullable=True), 32 | sa.Column('log', sa.String(length=1024), nullable=False), 33 | sa.Column('admin_id', sa.Integer(), nullable=False), 34 | sa.ForeignKeyConstraint(['admin_id'], ['users.id'], ), 35 | sa.PrimaryKeyConstraint('id') 36 | ) 37 | # ### end Alembic commands ### 38 | 39 | 40 | def downgrade(): 41 | # ### commands auto generated by Alembic - please adjust! ### 42 | op.drop_table('sukebei_adminlog') 43 | op.drop_table('nyaa_adminlog') 44 | # ### end Alembic commands ### 45 | -------------------------------------------------------------------------------- /nyaa/templates/trusted_form.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% from "_formhelpers.html" import render_field %} 3 | {% block title %}Apply for Trusted :: {{ config.SITE_NAME }}{% endblock %} 4 | {% block body %} 5 |
6 | {% if trusted_form %} 7 |
8 |
9 |

You are eligible to apply for trusted status

10 |
11 |
12 |
13 | {{ trusted_form.csrf_token }} 14 |
15 |
16 | {{ render_field(trusted_form.why_give_trusted, class_='form-control') }} 17 |
18 |
19 |
20 |
21 | {{ render_field(trusted_form.why_want_trusted, class_='form-control') }} 22 |
23 |
24 |
25 |
26 | 27 |
28 |
29 |
30 | {% else %} 31 |
32 |
33 |

You are currently not eligible to apply for trusted status

34 |
35 |
36 |
37 |
38 |

39 | You currently are not eligible to apply for trusted status for the following 40 | reason{% if deny_reasons|length > 1 %}s{% endif %}: 41 |

42 |
    43 | {% for reason in deny_reasons %} 44 |
  • {{ reason }}
  • 45 | {% endfor %} 46 |
47 |
48 |
49 | {% endif %} 50 |
51 | {% endblock %} 52 | -------------------------------------------------------------------------------- /.docker/nginx.conf: -------------------------------------------------------------------------------- 1 | 2 | user nginx; 3 | worker_processes 1; 4 | 5 | error_log /var/log/nginx/error.log warn; 6 | pid /var/run/nginx.pid; 7 | 8 | 9 | events { 10 | worker_connections 1024; 11 | } 12 | 13 | 14 | http { 15 | include /etc/nginx/mime.types; 16 | default_type application/octet-stream; 17 | charset utf-8; 18 | 19 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 20 | '$status $body_bytes_sent "$http_referer" ' 21 | '"$http_user_agent" "$http_x_forwarded_for"'; 22 | 23 | access_log /var/log/nginx/access.log main; 24 | 25 | sendfile on; 26 | #tcp_nopush on; 27 | 28 | keepalive_timeout 65; 29 | 30 | gzip on; 31 | 32 | server { 33 | listen 80; 34 | server_name localhost default; 35 | 36 | location /static { 37 | alias /nyaa-static; 38 | } 39 | 40 | # fix kibana redirecting to localhost/kibana (without the port) 41 | rewrite ^/kibana$ http://$http_host/kibana/ permanent; 42 | location /kibana/ { 43 | proxy_http_version 1.1; 44 | proxy_set_header Upgrade $http_upgrade; 45 | proxy_set_header Connection 'upgrade'; 46 | proxy_cache_bypass $http_upgrade; 47 | 48 | proxy_set_header Host 'kibana'; 49 | proxy_set_header X-Real-IP $remote_addr; 50 | 51 | proxy_pass http://kibana:5601/; 52 | } 53 | 54 | location / { 55 | include /etc/nginx/uwsgi_params; 56 | uwsgi_pass nyaa-flask:5000; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /.docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # set +x 4 | 5 | pushd /nyaa 6 | 7 | echo 'Waiting for MySQL to start up' 8 | while ! echo HELO | nc mariadb 3306 &>/dev/null; do 9 | sleep 1 10 | done 11 | echo 'DONE' 12 | 13 | if ! [ -f /elasticsearch-sync/flag-db_create ]; then 14 | python3 ./db_create.py 15 | touch /elasticsearch-sync/flag-db_create 16 | fi 17 | 18 | if ! [ -f /elasticsearch-sync/flag-db_migrate ]; then 19 | python3 ./db_migrate.py stamp head 20 | touch /elasticsearch-sync/flag-db_migrate 21 | fi 22 | 23 | echo 'Waiting for ES to start up' 24 | while ! echo HELO | nc elasticsearch 9200 &>/dev/null; do 25 | sleep 1 26 | done 27 | echo 'DONE' 28 | 29 | echo 'Waiting for ES to be ready' 30 | while ! curl -s -XGET 'elasticsearch:9200/_cluster/health?pretty=true&wait_for_status=green' &>/dev/null; do 31 | sleep 1 32 | done 33 | echo 'DONE' 34 | 35 | if ! [ -f /elasticsearch-sync/flag-create_es ]; then 36 | # @source create_es.sh 37 | # create indices named "nyaa" and "sukebei", these are hardcoded 38 | curl -v -XPUT 'elasticsearch:9200/nyaa?pretty' -H"Content-Type: application/yaml" --data-binary @es_mapping.yml 39 | curl -v -XPUT 'elasticsearch:9200/sukebei?pretty' -H"Content-Type: application/yaml" --data-binary @es_mapping.yml 40 | touch /elasticsearch-sync/flag-create_es 41 | fi 42 | 43 | if ! [ -f /elasticsearch-sync/flag-import_to_es ]; then 44 | python3 ./import_to_es.py | tee /elasticsearch-sync/import.out 45 | grep -A1 'Save the following' /elasticsearch-sync/import.out | tail -1 > /elasticsearch-sync/pos.json 46 | touch /elasticsearch-sync/flag-import_to_es 47 | fi 48 | 49 | echo 'Starting the Flask app' 50 | /usr/local/bin/uwsgi /nyaa/.docker/uwsgi.config.ini 51 | -------------------------------------------------------------------------------- /tests/test_nyaa.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | import tempfile 4 | import nyaa 5 | 6 | 7 | class NyaaTestCase(unittest.TestCase): 8 | 9 | nyaa_app = nyaa.create_app('config') 10 | 11 | def setUp(self): 12 | self.db, self.nyaa_app.config['DATABASE'] = tempfile.mkstemp() 13 | self.nyaa_app.config['TESTING'] = True 14 | self.app = self.nyaa_app.test_client() 15 | with self.nyaa_app.app_context(): 16 | nyaa.db.create_all() 17 | 18 | def tearDown(self): 19 | os.close(self.db) 20 | os.unlink(self.nyaa_app.config['DATABASE']) 21 | 22 | def test_index_url(self): 23 | rv = self.app.get('/') 24 | assert b'Browse :: Nyaa' in rv.data 25 | assert b'Guest' in rv.data 26 | 27 | def test_upload_url(self): 28 | rv = self.app.get('/upload') 29 | assert b'Upload Torrent' in rv.data 30 | assert b'You are not logged in, and are uploading anonymously.' in rv.data 31 | 32 | def test_rules_url(self): 33 | rv = self.app.get('/rules') 34 | assert b'Site Rules' in rv.data 35 | 36 | def test_help_url(self): 37 | rv = self.app.get('/help') 38 | assert b'Using the Site' in rv.data 39 | 40 | def test_rss_url(self): 41 | rv = self.app.get('/?page=rss') 42 | assert b'/xmlns/nyaa' in rv.data 43 | 44 | def test_login_url(self): 45 | rv = self.app.get('/login') 46 | assert b'Username or email address' in rv.data 47 | 48 | def test_registration_url(self): 49 | rv = self.app.get('/register') 50 | assert b'Username' in rv.data 51 | assert b'Password' in rv.data 52 | 53 | 54 | if __name__ == '__main__': 55 | unittest.main() 56 | -------------------------------------------------------------------------------- /nyaa/templates/admin_bans.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}Admin Log :: {{ config.SITE_NAME }}{% endblock %} 3 | {% block body %} 4 | {% from "_formhelpers.html" import render_field %} 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {{ form.csrf_token }} 21 | {% for ban in bans.items %} 22 | 23 | 24 | 27 | {% if ban.user %} 28 | 31 | {% else %} 32 | 33 | {% endif %} 34 | {% if g.user.is_superadmin %} 35 | 36 | {% else %} 37 | 38 | {% endif %} 39 | 40 | 41 | 44 | 45 | {% endfor %} 46 | 47 | 48 |
#Moderator/AdminUserUser IPReasonDateAction
{{ ban.id }} 25 | {{ ban.admin.username }} 26 | 29 | {{ ban.user.username }} 30 | -{{ ban.ip_string }}hidden
{{ ban.reason }}
{{ ban.created_time }} 42 | {{ form.submit(label="Unban", value=ban.id, class="btn btn-danger btn-xs") }} 43 |
49 |
50 | 51 | 55 | {% endblock %} 56 | -------------------------------------------------------------------------------- /migrations/versions/5cbcee17bece_add_trusted_applications.py: -------------------------------------------------------------------------------- 1 | """Add trusted applications 2 | 3 | Revision ID: 5cbcee17bece 4 | Revises: 8a6a7662eb37 5 | Create Date: 2018-11-05 15:16:07.497898 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import sqlalchemy_utils 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = '5cbcee17bece' 15 | down_revision = '8a6a7662eb37' 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | op.create_table('trusted_applications', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('submitter_id', sa.Integer(), nullable=False, index=True), 24 | sa.Column('created_time', sa.DateTime(), nullable=True), 25 | sa.Column('closed_time', sa.DateTime(), nullable=True), 26 | sa.Column('why_want', sa.String(length=4000), nullable=False), 27 | sa.Column('why_give', sa.String(length=4000), nullable=False), 28 | sa.Column('status', sa.Integer(), nullable=False), 29 | sa.ForeignKeyConstraint(['submitter_id'], ['users.id'], ), 30 | sa.PrimaryKeyConstraint('id') 31 | ) 32 | op.create_table('trusted_reviews', 33 | sa.Column('id', sa.Integer(), nullable=False), 34 | sa.Column('reviewer_id', sa.Integer(), nullable=False), 35 | sa.Column('app_id', sa.Integer(), nullable=False), 36 | sa.Column('created_time', sa.DateTime(), nullable=True), 37 | sa.Column('comment', sa.String(length=4000), nullable=False), 38 | sa.Column('recommendation', sa.Integer(), nullable=False), 39 | sa.ForeignKeyConstraint(['app_id'], ['trusted_applications.id'], ), 40 | sa.ForeignKeyConstraint(['reviewer_id'], ['users.id'], ), 41 | sa.PrimaryKeyConstraint('id') 42 | ) 43 | 44 | 45 | def downgrade(): 46 | op.drop_table('trusted_reviews') 47 | op.drop_table('trusted_applications') 48 | -------------------------------------------------------------------------------- /nyaa/templates/register.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}Register :: {{ config.SITE_NAME }}{% endblock %} 3 | {% block metatags %} 4 | 5 | {% endblock %} 6 | {% block body %} 7 | {% from "_formhelpers.html" import render_field %} 8 | 9 |

Register

10 |

Important: Do not use Outlook (Hotmail/Live/MSN) email addresses, they discard our verification email without sending it to spam. No support is offered if you ignore this warning.

11 |
12 | {{ form.csrf_token }} 13 | 14 |
15 |
16 | {{ render_field(form.username, class_='form-control', placeholder='Username') }} 17 |
18 |
19 | 20 |
21 |
22 | {{ render_field(form.email, class_='form-control', placeholder='Email address') }} 23 |
24 |
25 | 26 |
27 |
28 | {{ render_field(form.password, class_='form-control', placeholder='Password') }} 29 |
30 |
31 | 32 |
33 |
34 | {{ render_field(form.password_confirm, class_='form-control', placeholder='Password (confirm)') }} 35 |
36 |
37 | 38 | {% if config.USE_RECAPTCHA %} 39 |
40 |
41 | {% for error in form.recaptcha.errors %} 42 | {{ error }} 43 | {% endfor %} 44 | {{ form.recaptcha }} 45 |
46 |
47 | {% endif %} 48 | 49 |
50 | 51 |
52 |
53 | 54 |
55 |
56 |
57 | {% endblock %} 58 | 59 | -------------------------------------------------------------------------------- /.docker/README.md: -------------------------------------------------------------------------------- 1 | # Nyaa on Docker 2 | 3 | Docker infrastructure is provided to ease setting up a dev environment 4 | 5 | ## Quickstart 6 | 7 | Get started by running (from the root of the project): 8 | 9 | docker-compose -f .docker/full-stack.yml -p nyaa build nyaa-flask 10 | docker-compose -f .docker/full-stack.yml -p nyaa up -d 11 | 12 | This builds the Flask app container, then starts up the project. You can then go 13 | to [localhost:8080](http://localhost:8080/) (note that some of the 14 | services are somewhat slow to start so it may not be available for 30s or so). 15 | 16 | You can shut it down with: 17 | 18 | docker-compose -f .docker/full-stack.yml -p nyaa down 19 | 20 | ## Details 21 | 22 | The environment includes: 23 | - [nginx frontend](http://localhost:8080/) (on port 8080) 24 | - uwsgi running the flask app 25 | - the ES<>MariaDB sync process 26 | - MariaDB 27 | - ElasticSearch 28 | - [Kibana](http://localhost:8080/kibana/) (at /kibana/) 29 | 30 | MariaDB, ElasticSearch, the sync process, and uploaded torrents will 31 | persistently store their data in volumes which makes future start ups faster. 32 | 33 | To make it more useful to develop with, you can copy `.docker/full-stack.yml` and 34 | edit the copy and uncomment the `- "${NYAA_SRC_DIR}:/nyaa"` line, then 35 | `export NYAA_SRC_DIR=$(pwd)` and start up the environment using the new compose 36 | file: 37 | 38 | cp -a .docker/full-stack.yml .docker/local-dev.yml 39 | cat config.example.py .docker/nyaa-config-partial.py > ./config.py 40 | $EDITOR .docker/local-dev.yml 41 | export NYAA_SRC_DIR=$(pwd) 42 | docker-compose -f .docker/local-dev.yml -p nyaa up -d 43 | 44 | This will mount the local copy of the project files into the Flask container, 45 | which combined with live-reloading in uWSGI should let you make changes and see 46 | them take effect immediately (technically with a ~2 second delay). 47 | -------------------------------------------------------------------------------- /nyaa/views/__init__.py: -------------------------------------------------------------------------------- 1 | import flask 2 | 3 | from nyaa.views import ( # isort:skip 4 | account, 5 | admin, 6 | main, 7 | site, 8 | torrents, 9 | users, 10 | ) 11 | 12 | 13 | def _maintenance_mode_hook(): 14 | ''' Blocks POSTs, unless MAINTENANCE_MODE_LOGINS is True and the POST is for a login. ''' 15 | if flask.request.method == 'POST': 16 | allow_logins = flask.current_app.config['MAINTENANCE_MODE_LOGINS'] 17 | endpoint = flask.request.endpoint 18 | 19 | if not (allow_logins and endpoint == 'account.login'): 20 | message = 'Site is currently in maintenance mode.' 21 | 22 | # In case of an API request, return a plaintext error message 23 | if endpoint.startswith('api.'): 24 | resp = flask.make_response(message, 405) 25 | resp.headers['Content-Type'] = 'text/plain' 26 | return resp 27 | else: 28 | # Otherwise redirect to the target page and flash a message 29 | flask.flash(flask.Markup(message), 'danger') 30 | try: 31 | target_url = flask.url_for(endpoint) 32 | except Exception: 33 | # Non-GET-able endpoint, try referrer or default to home page 34 | target_url = flask.request.referrer or flask.url_for('main.home') 35 | return flask.redirect(target_url) 36 | 37 | 38 | def register_views(flask_app): 39 | """ Register the blueprints using the flask_app object """ 40 | # Add our POST blocker first 41 | if flask_app.config['MAINTENANCE_MODE']: 42 | flask_app.before_request(_maintenance_mode_hook) 43 | 44 | flask_app.register_blueprint(account.bp) 45 | flask_app.register_blueprint(admin.bp) 46 | flask_app.register_blueprint(main.bp) 47 | flask_app.register_blueprint(site.bp) 48 | flask_app.register_blueprint(torrents.bp) 49 | flask_app.register_blueprint(users.bp) 50 | -------------------------------------------------------------------------------- /.docker/full-stack.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | version: "3" 4 | services: 5 | nginx: 6 | image: nginx:1.15-alpine 7 | ports: 8 | - '8080:80' 9 | volumes: 10 | - './nginx.conf:/etc/nginx/nginx.conf:ro' 11 | - '../nyaa/static:/nyaa-static:ro' 12 | depends_on: 13 | - nyaa-flask 14 | - kibana 15 | 16 | nyaa-flask: 17 | image: local/nyaa:devel 18 | volumes: 19 | - 'nyaa-torrents:/nyaa-torrents' 20 | - 'nyaa-sync-data:/elasticsearch-sync' 21 | ## Uncomment this line to have to mount the local dir to the running 22 | ## instance for live changes (after setting NYAA_SRC_DIR env var) 23 | # - "${NYAA_SRC_DIR}:/nyaa" 24 | depends_on: 25 | - mariadb 26 | - elasticsearch 27 | build: 28 | context: ../ 29 | dockerfile: ./.docker/Dockerfile 30 | 31 | nyaa-sync: 32 | image: local/nyaa:devel 33 | volumes: 34 | - 'nyaa-sync-data:/elasticsearch-sync' 35 | command: /nyaa/.docker/entrypoint-sync.sh 36 | depends_on: 37 | - mariadb 38 | - elasticsearch 39 | restart: on-failure 40 | 41 | mariadb: 42 | image: mariadb:10.0 43 | volumes: 44 | - './mariadb-init-sql:/docker-entrypoint-initdb.d:ro' 45 | - '../configs/my.cnf:/etc/mysql/conf.d/50-binlog.cnf:ro' 46 | - 'mariadb-data:/var/lib/mysql' 47 | environment: 48 | - MYSQL_RANDOM_ROOT_PASSWORD=yes 49 | - MYSQL_USER=nyaadev 50 | - MYSQL_PASSWORD=ZmtB2oihHFvc39JaEDoF 51 | - MYSQL_DATABASE=nyaav2 52 | 53 | elasticsearch: 54 | image: elasticsearch:6.5.4 55 | volumes: 56 | - elasticsearch-data:/usr/share/elasticsearch/data 57 | depends_on: 58 | - mariadb 59 | 60 | kibana: 61 | image: kibana:6.5.4 62 | volumes: 63 | - './kibana.config.yml:/usr/share/kibana/config/kibana.yml:ro' 64 | depends_on: 65 | - elasticsearch 66 | 67 | volumes: 68 | nyaa-torrents: 69 | nyaa-sync-data: 70 | mariadb-data: 71 | elasticsearch-data: 72 | -------------------------------------------------------------------------------- /nyaa/templates/admin_trusted.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% macro render_filter_tab(name) %} 3 | 12 | {% endmacro %} 13 | {% block title %}Trusted Applications :: {{ config.SITE_NAME }}{% endblock %} 14 | {% block body %} 15 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | {% for app in apps.items %} 35 | 36 | 37 | 42 | 43 | 44 | 45 | 46 | {% endfor %} 47 | 48 |
List of {{ list_filter or 'open' }} applications
#SubmitterSubmitted onStatus
{{ app.id }} 38 | 39 | {{ app.submitter.username }} 40 | 41 | {{ app.created_time.strftime('%Y-%m-%d %H:%M') }}{{ app.status.name.capitalize() }}View
49 |
50 | 54 | {% endblock %} 55 | -------------------------------------------------------------------------------- /nyaa/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}Login :: {{ config.SITE_NAME }}{% endblock %} 3 | {% block metatags %} 4 | 5 | {% endblock %} 6 | {% block body %} 7 | {% from "_formhelpers.html" import render_field %} 8 | 9 |

Login

10 |
11 | {{ form.csrf_token }} 12 | 13 |
14 |
15 | {{ render_field(form.username, class_='form-control', placeholder='Username', autofocus='', tabindex='1') }} 16 |
17 |
18 | 19 |
20 |
21 | {# This is just render_field() exploded so that we can add the password link after the label #} 22 | {% if form.password.errors %} 23 |
24 | {% else %} 25 |
26 | {% endif %} 27 | {{ form.password.label(class='control-label') }} 28 | 29 | {% if config.ALLOW_PASSWORD_RESET: %} 30 | 31 | Forgot your password? 32 | 33 | {% endif%} 34 | 35 | {{ form.password(title=form.password.description, class_='form-control', tabindex='2') | safe }} 36 | {% if form.password.errors %} 37 |
38 | {% if form.password.errors|length < 2 %} 39 | {% for error in form.password.errors %} 40 | {{ error }} 41 | {% endfor %} 42 | {% else %} 43 |
    44 | {% for error in form.password.errors %} 45 |
  • {{ error }}
  • 46 | {% endfor %} 47 |
48 | {% endif %} 49 |
50 | {% endif %} 51 |
52 |
53 |
54 | 55 |
56 |
57 | 58 |
59 |
60 | 61 | {% endblock %} 62 | -------------------------------------------------------------------------------- /migrations/versions/2bceb2cb4d7c_add_comment_count_to_torrent.py: -------------------------------------------------------------------------------- 1 | """Add comment_count to Torrent 2 | 3 | Revision ID: 2bceb2cb4d7c 4 | Revises: d0eeb8049623 5 | Create Date: 2017-05-26 15:07:21.114331 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '2bceb2cb4d7c' 14 | down_revision = 'd0eeb8049623' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | COMMENT_UPDATE_SQL = '''UPDATE {0}_torrents 19 | SET comment_count = ( 20 | SELECT COUNT(*) FROM {0}_comments 21 | WHERE {0}_torrents.id = {0}_comments.torrent_id 22 | );''' 23 | 24 | 25 | def upgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.add_column('nyaa_torrents', sa.Column('comment_count', sa.Integer(), nullable=False)) 28 | op.create_index(op.f('ix_nyaa_torrents_comment_count'), 'nyaa_torrents', ['comment_count'], unique=False) 29 | 30 | op.add_column('sukebei_torrents', sa.Column('comment_count', sa.Integer(), nullable=False)) 31 | op.create_index(op.f('ix_sukebei_torrents_comment_count'), 'sukebei_torrents', ['comment_count'], unique=False) 32 | # ### end Alembic commands ### 33 | 34 | connection = op.get_bind() 35 | 36 | print('Updating comment counts on nyaa_torrents...') 37 | connection.execute(sa.sql.text(COMMENT_UPDATE_SQL.format('nyaa'))) 38 | print('Done.') 39 | 40 | print('Updating comment counts on sukebei_torrents...') 41 | connection.execute(sa.sql.text(COMMENT_UPDATE_SQL.format('sukebei'))) 42 | print('Done.') 43 | 44 | 45 | def downgrade(): 46 | # ### commands auto generated by Alembic - please adjust! ### 47 | op.drop_index(op.f('ix_nyaa_torrents_comment_count'), table_name='nyaa_torrents') 48 | op.drop_column('nyaa_torrents', 'comment_count') 49 | 50 | op.drop_index(op.f('ix_sukebei_torrents_comment_count'), table_name='sukebei_torrents') 51 | op.drop_column('sukebei_torrents', 'comment_count') 52 | # ### end Alembic commands ### 53 | -------------------------------------------------------------------------------- /migrations/versions/7f064e009cab_add_report_table.py: -------------------------------------------------------------------------------- 1 | """Add Report table 2 | 3 | Revision ID: 7f064e009cab 4 | Revises: 2bceb2cb4d7c 5 | Create Date: 2017-05-29 16:50:28.720980 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '7f064e009cab' 14 | down_revision = '2bceb2cb4d7c' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('nyaa_reports', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('created_time', sa.DateTime(), nullable=True), 24 | sa.Column('reason', sa.String(length=255), nullable=False), 25 | 26 | # sqlalchemy_utils.types.choice.ChoiceType() 27 | sa.Column('status', sa.Integer(), nullable=False), 28 | 29 | sa.Column('torrent_id', sa.Integer(), nullable=False), 30 | sa.Column('user_id', sa.Integer(), nullable=True), 31 | sa.ForeignKeyConstraint(['torrent_id'], ['nyaa_torrents.id'], ondelete='CASCADE'), 32 | sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), 33 | sa.PrimaryKeyConstraint('id') 34 | ) 35 | op.create_table('sukebei_reports', 36 | sa.Column('id', sa.Integer(), nullable=False), 37 | sa.Column('created_time', sa.DateTime(), nullable=True), 38 | sa.Column('reason', sa.String(length=255), nullable=False), 39 | 40 | # sqlalchemy_utils.types.choice.ChoiceType() 41 | sa.Column('status', sa.Integer(), nullable=False), 42 | 43 | sa.Column('torrent_id', sa.Integer(), nullable=False), 44 | sa.Column('user_id', sa.Integer(), nullable=True), 45 | sa.ForeignKeyConstraint(['torrent_id'], ['sukebei_torrents.id'], ondelete='CASCADE'), 46 | sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), 47 | sa.PrimaryKeyConstraint('id') 48 | ) 49 | # ### end Alembic commands ### 50 | 51 | 52 | def downgrade(): 53 | # ### commands auto generated by Alembic - please adjust! ### 54 | op.drop_table('sukebei_reports') 55 | op.drop_table('nyaa_reports') 56 | # ### end Alembic commands ### 57 | -------------------------------------------------------------------------------- /tests/test_backend.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from nyaa import backend 4 | 5 | 6 | class TestBackend(unittest.TestCase): 7 | 8 | # def setUp(self): 9 | # self.db, nyaa.app.config['DATABASE'] = tempfile.mkstemp() 10 | # nyaa.app.config['TESTING'] = True 11 | # self.app = nyaa.app.test_client() 12 | # with nyaa.app.app_context(): 13 | # nyaa.db.create_all() 14 | # 15 | # def tearDown(self): 16 | # os.close(self.db) 17 | # os.unlink(nyaa.app.config['DATABASE']) 18 | 19 | def test_replace_utf8_values(self): 20 | test_dict = { 21 | 'hash': '2346ad27d7568ba9896f1b7da6b5991251debdf2', 22 | 'title.utf-8': '¡hola! ¿qué tal?', 23 | 'filelist.utf-8': [ 24 | 'Español 101.mkv', 25 | 'ру́сский 202.mp4' 26 | ] 27 | } 28 | expected_dict = { 29 | 'hash': '2346ad27d7568ba9896f1b7da6b5991251debdf2', 30 | 'title': '¡hola! ¿qué tal?', 31 | 'filelist': [ 32 | 'Español 101.mkv', 33 | 'ру́сский 202.mp4' 34 | ] 35 | } 36 | 37 | self.assertTrue(backend._replace_utf8_values(test_dict)) 38 | self.assertDictEqual(test_dict, expected_dict) 39 | 40 | def test_replace_invalid_xml_chars(self): 41 | self.assertEqual(backend.sanitize_string('ayy\x08lmao'), 'ayy\uFFFDlmao') 42 | self.assertEqual(backend.sanitize_string('ayy\x0clmao'), 'ayy\uFFFDlmao') 43 | self.assertEqual(backend.sanitize_string('ayy\uD8FFlmao'), 'ayy\uFFFDlmao') 44 | self.assertEqual(backend.sanitize_string('ayy\uFFFElmao'), 'ayy\uFFFDlmao') 45 | self.assertEqual(backend.sanitize_string('\x08ayy\x0clmao'), '\uFFFDayy\uFFFDlmao') 46 | self.assertEqual(backend.sanitize_string('ayy\x08\x0clmao'), 'ayy\uFFFD\uFFFDlmao') 47 | self.assertEqual(backend.sanitize_string('ayy\x08\x08lmao'), 'ayy\uFFFD\uFFFDlmao') 48 | self.assertEqual(backend.sanitize_string('ぼくのぴこ'), 'ぼくのぴこ') 49 | 50 | @unittest.skip('Not yet implemented') 51 | def test_handle_torrent_upload(self): 52 | pass 53 | 54 | 55 | if __name__ == '__main__': 56 | unittest.main() 57 | -------------------------------------------------------------------------------- /nyaa/extensions.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | from flask import abort 4 | from flask.config import Config 5 | from flask_assets import Environment 6 | from flask_caching import Cache 7 | from flask_debugtoolbar import DebugToolbarExtension 8 | from flask_limiter import Limiter 9 | from flask_limiter.util import get_remote_address 10 | from flask_sqlalchemy import BaseQuery, Pagination, SQLAlchemy 11 | 12 | assets = Environment() 13 | db = SQLAlchemy() 14 | toolbar = DebugToolbarExtension() 15 | cache = Cache() 16 | limiter = Limiter(key_func=get_remote_address) 17 | 18 | 19 | class LimitedPagination(Pagination): 20 | def __init__(self, actual_count, *args, **kwargs): 21 | self.actual_count = actual_count 22 | super().__init__(*args, **kwargs) 23 | 24 | 25 | def fix_paginate(): 26 | 27 | def paginate_faste(self, page=1, per_page=50, max_page=None, step=5, count_query=None): 28 | if page < 1: 29 | abort(404) 30 | 31 | if max_page and page > max_page: 32 | abort(404) 33 | 34 | # Count all items 35 | if count_query is not None: 36 | total_query_count = count_query.scalar() 37 | else: 38 | total_query_count = self.count() 39 | actual_query_count = total_query_count 40 | if max_page: 41 | total_query_count = min(total_query_count, max_page * per_page) 42 | 43 | # Grab items on current page 44 | items = self.limit(per_page).offset((page - 1) * per_page).all() 45 | 46 | if not items and page != 1: 47 | abort(404) 48 | 49 | return LimitedPagination(actual_query_count, self, page, per_page, total_query_count, 50 | items) 51 | 52 | BaseQuery.paginate_faste = paginate_faste 53 | 54 | 55 | def _get_config(): 56 | # Workaround to get an available config object before the app is initiallized 57 | # Only needed/used in top-level and class statements 58 | # https://stackoverflow.com/a/18138250/7597273 59 | root_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) 60 | config = Config(root_path) 61 | config.from_object('config') 62 | return config 63 | 64 | 65 | config = _get_config() 66 | -------------------------------------------------------------------------------- /migrations/versions/b61e4f6a88cc_del_torrents_info.py: -------------------------------------------------------------------------------- 1 | """Remove bencoded info dicts from mysql 2 | 3 | Revision ID: b61e4f6a88cc 4 | Revises: cf7bf6d0e6bd 5 | Create Date: 2017-08-29 01:45:08.357936 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import mysql 11 | import sys 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = 'b61e4f6a88cc' 15 | down_revision = 'cf7bf6d0e6bd' 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | print("--- WARNING ---") 22 | print("This migration drops the torrent_info tables.") 23 | print("You will lose all of your .torrent files if you have not converted them beforehand.") 24 | print("Use the migration script at utils/infodict_mysql2file.py") 25 | print("Type OKAY and hit Enter to continue, CTRL-C to abort.") 26 | print("--- WARNING ---") 27 | try: 28 | if input() != "OKAY": 29 | sys.exit(1) 30 | except KeyboardInterrupt: 31 | sys.exit(1) 32 | 33 | op.drop_table('sukebei_torrents_info') 34 | op.drop_table('nyaa_torrents_info') 35 | 36 | 37 | def downgrade(): 38 | op.create_table('nyaa_torrents_info', 39 | sa.Column('info_dict', mysql.MEDIUMBLOB(), nullable=True), 40 | sa.Column('torrent_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False), 41 | sa.ForeignKeyConstraint(['torrent_id'], ['nyaa_torrents.id'], name='nyaa_torrents_info_ibfk_1', ondelete='CASCADE'), 42 | sa.PrimaryKeyConstraint('torrent_id'), 43 | mysql_collate='utf8_bin', 44 | mysql_default_charset='utf8', 45 | mysql_engine='InnoDB', 46 | mysql_row_format='COMPRESSED' 47 | ) 48 | op.create_table('sukebei_torrents_info', 49 | sa.Column('info_dict', mysql.MEDIUMBLOB(), nullable=True), 50 | sa.Column('torrent_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False), 51 | sa.ForeignKeyConstraint(['torrent_id'], ['sukebei_torrents.id'], name='sukebei_torrents_info_ibfk_1', ondelete='CASCADE'), 52 | sa.PrimaryKeyConstraint('torrent_id'), 53 | mysql_collate='utf8_bin', 54 | mysql_default_charset='utf8', 55 | mysql_engine='InnoDB', 56 | mysql_row_format='COMPRESSED' 57 | ) 58 | -------------------------------------------------------------------------------- /nyaa/templates/xmlns.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}XML Namespace :: {{ config.SITE_NAME }}{% endblock %} 3 | {% block body %} 4 |
5 |

Nyaa XML Namespace

6 |

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.

7 |

The namespace contains the following additional, informational tags:

8 |
    9 |
  • 10 |

    <nyaa:seeders> holds the current amount of seeders on the respective torrent.

    11 |
  • 12 |
  • 13 |

    <nyaa:leechers> holds the current amount of leechers on the respective torrent.

    14 |
  • 15 |
  • 16 |

    <nyaa:downloads> counts the downloads the torrent got up to the point the feed was refreshed.

    17 |
  • 18 |
  • 19 |

    <nyaa:infoHash> is the torrent's infohash, a unique identifier, in hexadecimal.

    20 |
  • 21 |
  • 22 |

    <nyaa:categoryId> contains the ID of the category containing the upload in the form category_subcategory.

    23 |
  • 24 |
  • 25 |

    <nyaa:category> contains the written name of the torrent's category in the form Category - Subcategory.

    26 |
  • 27 |
  • 28 |

    <nyaa:size> indicates the torrent's download size to one decimal place, using a magnitude prefix according to ISO/IEC 80000-13.

    29 |
  • 30 |
  • 31 |

    <nyaa:trusted> indicates whether the torrent came from a trusted uploader (YES or NO).

    32 |
  • 33 |
  • 34 |

    <nyaa:remake> indicates whether the torrent was a remake (YES or NO).

    35 |
  • 36 |
  • 37 |

    <nyaa:comments> holds the current amount of comments made on the respective torrent.

    38 |
  • 39 |
40 |
41 | {% endblock %} 42 | -------------------------------------------------------------------------------- /nyaa/templates/reports.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}Reports :: {{ config.SITE_NAME }}{% endblock %} 3 | {% block body %} 4 | {% from "_formhelpers.html" import render_field %} 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | {% for report in reports.items %} 19 | 20 | 21 | 27 | 38 | 39 | 40 | 53 | 54 | {% endfor %} 55 | 56 |
#Reported byTorrentReasonDateAction
{{ report.id }} 22 | {{ report.user.username }} 23 | {% if report.user.is_trusted %} 24 | Trusted 25 | {% endif %} 26 | 28 | {{ report.torrent.display_name }} 29 | by 30 | {{ report.torrent.user.username }} 31 | {% if g.user.is_superadmin and report.torrent.uploader_ip %} 32 | ({{ report.torrent.uploader_ip_string }}) 33 | {% endif %} 34 | {% if report.torrent.user.is_trusted %} 35 | Trusted 36 | {% endif %} 37 | {{ report.reason }}{{ report.created_time }} 41 |
42 | {{ report_action.csrf_token }} 43 | {{ report_action.torrent(value=report.torrent.id) }} 44 | {{ report_action.report(value=report.id) }} 45 |
46 | {{ report_action.action(class_="form-control") }} 47 |
48 | 49 |
50 |
51 |
52 |
57 |
58 | 59 | 63 | {% endblock %} 64 | -------------------------------------------------------------------------------- /nyaa/templates/bootstrap/pagination.html: -------------------------------------------------------------------------------- 1 | ## https://github.com/mbr/flask-bootstrap/blob/master/flask_bootstrap/templates/bootstrap/pagination.html 2 | {% macro _arg_url_for(endpoint, base) %} 3 | {# calls url_for() with a given endpoint and **base as the parameters, 4 | additionally passing on all keyword_arguments (may overwrite existing ones) 5 | #} 6 | {%- with kargs = base.copy() -%} 7 | {%- do kargs.update(kwargs) -%} 8 | {{url_for(endpoint, **kargs)}} 9 | {%- endwith %} 10 | {%- endmacro %} 11 | 12 | {% macro render_pagination(pagination, 13 | endpoint=None, 14 | prev=('«')|safe, 15 | next=('»')|safe, 16 | size=None, 17 | ellipses='…', 18 | args={} 19 | ) 20 | -%} 21 | {% with url_args = {} %} 22 | {%- do url_args.update(request.view_args if not endpoint else {}), 23 | url_args.update(request.args if not endpoint else {}), 24 | url_args.update(args) -%} 25 | {% with endpoint = endpoint or request.endpoint %} 26 | 58 | {% endwith %} 59 | {% endwith %} 60 | {% endmacro %} 61 | -------------------------------------------------------------------------------- /db_create.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sqlalchemy 3 | 4 | from nyaa import create_app, models 5 | from nyaa.extensions import db 6 | 7 | app = create_app('config') 8 | 9 | NYAA_CATEGORIES = [ 10 | ('Anime', ['Anime Music Video', 'English-translated', 'Non-English-translated', 'Raw']), 11 | ('Audio', ['Lossless', 'Lossy']), 12 | ('Literature', ['English-translated', 'Non-English-translated', 'Raw']), 13 | ('Live Action', ['English-translated', 'Idol/Promotional Video', 'Non-English-translated', 'Raw']), 14 | ('Pictures', ['Graphics', 'Photos']), 15 | ('Software', ['Applications', 'Games']), 16 | ] 17 | 18 | 19 | SUKEBEI_CATEGORIES = [ 20 | ('Art', ['Anime', 'Doujinshi', 'Games', 'Manga', 'Pictures']), 21 | ('Real Life', ['Photobooks / Pictures', 'Videos']), 22 | ] 23 | 24 | 25 | def add_categories(categories, main_class, sub_class): 26 | for main_cat_name, sub_cat_names in categories: 27 | main_cat = main_class(name=main_cat_name) 28 | for i, sub_cat_name in enumerate(sub_cat_names): 29 | # Composite keys can't autoincrement, set sub_cat id manually (1-index) 30 | sub_cat = sub_class(id=i+1, name=sub_cat_name, main_category=main_cat) 31 | db.session.add(main_cat) 32 | 33 | 34 | if __name__ == '__main__': 35 | with app.app_context(): 36 | # Test for the user table, assume db is empty if it's not created 37 | database_empty = False 38 | try: 39 | models.User.query.first() 40 | except (sqlalchemy.exc.ProgrammingError, sqlalchemy.exc.OperationalError): 41 | database_empty = True 42 | 43 | print('Creating all tables...') 44 | db.create_all() 45 | 46 | nyaa_category_test = models.NyaaMainCategory.query.first() 47 | if not nyaa_category_test: 48 | print('Adding Nyaa categories...') 49 | add_categories(NYAA_CATEGORIES, models.NyaaMainCategory, models.NyaaSubCategory) 50 | 51 | sukebei_category_test = models.SukebeiMainCategory.query.first() 52 | if not sukebei_category_test: 53 | print('Adding Sukebei categories...') 54 | add_categories(SUKEBEI_CATEGORIES, models.SukebeiMainCategory, models.SukebeiSubCategory) 55 | 56 | db.session.commit() 57 | 58 | if database_empty: 59 | print('Remember to run the following to mark the database up-to-date for Alembic:') 60 | print('./db_migrate.py stamp head') 61 | # Technically we should be able to do this here, but when you have 62 | # Flask-Migrate and Flask-SQA and everything... I didn't get it working. 63 | -------------------------------------------------------------------------------- /nyaa/static/pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /nyaa/email.py: -------------------------------------------------------------------------------- 1 | import smtplib 2 | from email.mime.multipart import MIMEMultipart 3 | from email.mime.text import MIMEText 4 | 5 | from flask import current_app as app 6 | 7 | import requests 8 | 9 | from nyaa import models 10 | 11 | 12 | class EmailHolder(object): 13 | ''' Holds email subject, recipient and content, so we have a general class for 14 | all mail backends. ''' 15 | 16 | def __init__(self, subject=None, recipient=None, text=None, html=None): 17 | self.subject = subject 18 | self.recipient = recipient # models.User or string 19 | self.text = text 20 | self.html = html 21 | 22 | def format_recipient(self): 23 | if isinstance(self.recipient, models.User): 24 | return '{} <{}>'.format(self.recipient.username, self.recipient.email) 25 | else: 26 | return self.recipient 27 | 28 | def recipient_email(self): 29 | if isinstance(self.recipient, models.User): 30 | return self.recipient.email 31 | else: 32 | return self.recipient.email 33 | 34 | def as_mimemultipart(self): 35 | msg = MIMEMultipart() 36 | msg['Subject'] = self.subject 37 | msg['From'] = app.config['MAIL_FROM_ADDRESS'] 38 | msg['To'] = self.format_recipient() 39 | 40 | msg.attach(MIMEText(self.text, 'plain')) 41 | if self.html: 42 | msg.attach(MIMEText(self.html, 'html')) 43 | 44 | return msg 45 | 46 | 47 | def send_email(email_holder): 48 | mail_backend = app.config.get('MAIL_BACKEND') 49 | if mail_backend == 'mailgun': 50 | _send_mailgun(email_holder) 51 | elif mail_backend == 'smtp': 52 | _send_smtp(email_holder) 53 | elif mail_backend: 54 | # TODO: Do this in logging.error when we have that set up 55 | print('Unknown mail backend:', mail_backend) 56 | 57 | 58 | def _send_mailgun(email_holder): 59 | mailgun_endpoint = app.config['MAILGUN_API_BASE'] + '/messages' 60 | auth = ('api', app.config['MAILGUN_API_KEY']) 61 | data = { 62 | 'from': app.config['MAIL_FROM_ADDRESS'], 63 | 'to': email_holder.format_recipient(), 64 | 'subject': email_holder.subject, 65 | 'text': email_holder.text, 66 | 'html': email_holder.html 67 | } 68 | r = requests.post(mailgun_endpoint, data=data, auth=auth) 69 | # TODO real error handling? 70 | assert r.status_code == 200 71 | 72 | 73 | def _send_smtp(email_holder): 74 | # NOTE: Unused, most likely untested! Should work, however. 75 | msg = email_holder.as_mimemultipart() 76 | 77 | server = smtplib.SMTP(app.config['SMTP_SERVER'], app.config['SMTP_PORT']) 78 | server.set_debuglevel(1) 79 | server.ehlo() 80 | server.starttls() 81 | server.ehlo() 82 | server.login(app.config['SMTP_USERNAME'], app.config['SMTP_PASSWORD']) 83 | server.sendmail(app.config['SMTP_USERNAME'], email_holder.recipient_email(), msg.as_string()) 84 | server.quit() 85 | -------------------------------------------------------------------------------- /nyaa/utils.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import hashlib 3 | import random 4 | import string 5 | from collections import OrderedDict 6 | 7 | import flask 8 | 9 | 10 | def sha1_hash(input_bytes): 11 | """ Hash given bytes with hashlib.sha1 and return the digest (as bytes) """ 12 | return hashlib.sha1(input_bytes).digest() 13 | 14 | 15 | def sorted_pathdict(input_dict): 16 | """ Sorts a parsed torrent filelist dict by alphabat, directories first """ 17 | directories = OrderedDict() 18 | files = OrderedDict() 19 | 20 | for key, value in input_dict.items(): 21 | if isinstance(value, dict): 22 | directories[key] = sorted_pathdict(value) 23 | else: 24 | files[key] = value 25 | 26 | return OrderedDict(sorted(directories.items()) + sorted(files.items())) 27 | 28 | 29 | def random_string(length, charset=None): 30 | if charset is None: 31 | charset = string.ascii_letters + string.digits 32 | return ''.join(random.choice(charset) for i in range(length)) 33 | 34 | 35 | def cached_function(f): 36 | sentinel = object() 37 | f._cached_value = sentinel 38 | 39 | @functools.wraps(f) 40 | def decorator(*args, **kwargs): 41 | if f._cached_value is sentinel: 42 | f._cached_value = f(*args, **kwargs) 43 | return f._cached_value 44 | return decorator 45 | 46 | 47 | def flatten_dict(d, result=None): 48 | if result is None: 49 | result = {} 50 | for key in d: 51 | value = d[key] 52 | if isinstance(value, dict): 53 | value1 = {} 54 | for keyIn in value: 55 | value1["/".join([key, keyIn])] = value[keyIn] 56 | flatten_dict(value1, result) 57 | elif isinstance(value, (list, tuple)): 58 | for indexB, element in enumerate(value): 59 | if isinstance(element, dict): 60 | value1 = {} 61 | index = 0 62 | for keyIn in element: 63 | newkey = "/".join([key, keyIn]) 64 | value1[newkey] = value[indexB][keyIn] 65 | index += 1 66 | for keyA in value1: 67 | flatten_dict(value1, result) 68 | else: 69 | result[key] = value 70 | return result 71 | 72 | 73 | def chain_get(source, *args): 74 | ''' Tries to return values from source by the given keys. 75 | Returns None if none match. 76 | Note: can return a None from the source. ''' 77 | sentinel = object() 78 | for key in args: 79 | value = source.get(key, sentinel) 80 | if value is not sentinel: 81 | return value 82 | return None 83 | 84 | 85 | def admin_only(f): 86 | @functools.wraps(f) 87 | def wrapper(*args, **kwargs): 88 | if flask.g.user and flask.g.user.is_superadmin: 89 | return f(*args, **kwargs) 90 | else: 91 | flask.abort(401) 92 | return wrapper 93 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from alembic import context 3 | from sqlalchemy import engine_from_config, pool 4 | from logging.config import fileConfig 5 | import logging 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Interpret the config file for Python logging. 12 | # This line sets up loggers basically. 13 | fileConfig(config.config_file_name) 14 | logger = logging.getLogger('alembic.env') 15 | 16 | # add your model's MetaData object here 17 | # for 'autogenerate' support 18 | # from myapp import mymodel 19 | # target_metadata = mymodel.Base.metadata 20 | from flask import current_app 21 | config.set_main_option('sqlalchemy.url', 22 | current_app.config.get('SQLALCHEMY_DATABASE_URI')) 23 | target_metadata = current_app.extensions['migrate'].db.metadata 24 | 25 | # other values from the config, defined by the needs of env.py, 26 | # can be acquired: 27 | # my_important_option = config.get_main_option("my_important_option") 28 | # ... etc. 29 | 30 | 31 | def run_migrations_offline(): 32 | """Run migrations in 'offline' mode. 33 | 34 | This configures the context with just a URL 35 | and not an Engine, though an Engine is acceptable 36 | here as well. By skipping the Engine creation 37 | we don't even need a DBAPI to be available. 38 | 39 | Calls to context.execute() here emit the given string to the 40 | script output. 41 | 42 | """ 43 | url = config.get_main_option("sqlalchemy.url") 44 | context.configure(url=url) 45 | 46 | with context.begin_transaction(): 47 | context.run_migrations() 48 | 49 | 50 | def run_migrations_online(): 51 | """Run migrations in 'online' mode. 52 | 53 | In this scenario we need to create an Engine 54 | and associate a connection with the context. 55 | 56 | """ 57 | 58 | # this callback is used to prevent an auto-migration from being generated 59 | # when there are no changes to the schema 60 | # reference: http://alembic.readthedocs.org/en/latest/cookbook.html 61 | def process_revision_directives(context, revision, directives): 62 | if getattr(config.cmd_opts, 'autogenerate', False): 63 | script = directives[0] 64 | if script.upgrade_ops.is_empty(): 65 | directives[:] = [] 66 | logger.info('No changes in schema detected.') 67 | 68 | engine = engine_from_config(config.get_section(config.config_ini_section), 69 | prefix='sqlalchemy.', 70 | poolclass=pool.NullPool) 71 | 72 | connection = engine.connect() 73 | context.configure(connection=connection, 74 | target_metadata=target_metadata, 75 | process_revision_directives=process_revision_directives, 76 | **current_app.extensions['migrate'].configure_args) 77 | 78 | try: 79 | with context.begin_transaction(): 80 | context.run_migrations() 81 | finally: 82 | connection.close() 83 | 84 | if context.is_offline_mode(): 85 | run_migrations_offline() 86 | else: 87 | run_migrations_online() 88 | -------------------------------------------------------------------------------- /nyaa/templates/rss.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ config.SITE_NAME }} - {{ term }} - {% if not magnet_links %}Torrent File{% else %}Magnet URI{% endif %} RSS 4 | RSS Feed for {{ term }} 5 | {{ url_for('main.home', _external=True) }} 6 | 7 | {% for torrent in torrent_query %} 8 | 9 | {{ torrent.display_name }} 10 | {% if use_elastic %} 11 | {# ElasticSearch Torrent instances #} 12 | {% if torrent.has_torrent and not magnet_links %} 13 | {{ url_for('torrents.download', torrent_id=torrent.meta.id, _external=True) }} 14 | {% else %} 15 | {{ create_magnet_from_es_torrent(torrent) }} 16 | {% endif %} 17 | {{ url_for('torrents.view', torrent_id=torrent.meta.id, _external=True) }} 18 | {{ torrent.created_time|rfc822_es }} 19 | 20 | {{- torrent.seed_count }} 21 | {{- torrent.leech_count }} 22 | {{- torrent.download_count }} 23 | {{- torrent.info_hash }} 24 | {% else %} 25 | {# Database Torrent rows #} 26 | {% if torrent.has_torrent and not magnet_links %} 27 | {{ url_for('torrents.download', torrent_id=torrent.id, _external=True) }} 28 | {% else %} 29 | {{ torrent.magnet_uri }} 30 | {% endif %} 31 | {{ url_for('torrents.view', torrent_id=torrent.id, _external=True) }} 32 | {{ torrent.created_time|rfc822 }} 33 | 34 | {{- torrent.stats.seed_count }} 35 | {{- torrent.stats.leech_count }} 36 | {{- torrent.stats.download_count }} 37 | {{- torrent.info_hash_as_hex }} 38 | {% endif %} 39 | {% set cat_id = use_elastic and ((torrent.main_category_id|string) + '_' + (torrent.sub_category_id|string)) or torrent.sub_category.id_as_string %} 40 | {{- cat_id }} 41 | {{- category_name(cat_id) }} 42 | {{- torrent.filesize | filesizeformat(True) }} 43 | {{- torrent.comment_count }} 44 | {{- torrent.trusted and 'Yes' or 'No' }} 45 | {{- torrent.remake and 'Yes' or 'No' }} 46 | {% set torrent_id = use_elastic and torrent.meta.id or torrent.id %} 47 | #{{ torrent_id }} | {{ torrent.display_name }} | {{ torrent.filesize | filesizeformat(True) }} | {{ category_name(cat_id) }} | {{ use_elastic and torrent.info_hash or torrent.info_hash_as_hex | upper }}]]> 48 | 49 | {% endfor %} 50 | 51 | 52 | -------------------------------------------------------------------------------- /nyaa/static/search.xml: -------------------------------------------------------------------------------- 1 | 2 | Nyaa.si 3 | 4 | Search torrents on Nyaa.si. 5 | UTF-8 6 | data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAHmElEQVRYw8WXXYxdVRXHf2vvc849537MvTOdmQ4zUygtpS0FIWL4iBEE9cGqAU0gojHBxAef9MFETXwhRmM00ReNia8kmhh9g0R8kISYiFjAKqYNhZa2Qzud7897zz0fey8fzqVzoS2UJ3Zyc25y99nrt/7rv9feV+J7v6t8hCMAmHn4B+BK1nrwYWmSmtDuGIoSapGhfnSCe6Ys+9KMn/xq8Yr5cSz0+1WU9NgvK4BW3XJhuQThQxFMjVtanZBGM4SmoTvbpD+XYls1FnslYQA2EAiUVkPo3JbQfrCBnU9ZfzXn+LGBAllWUrrrDxxa2LuvQdauI6Js1UJIDJIbwpplxAhrnZgbn9xNs2NwiWAbBp8IpVHKXZbG7SX8dgDQqMFo3bDW8x8YfM+UpdjVYS6pEwfQMxAZCEuwfUgbMXXxrDuPaUf0IwgC8B6kAELBGoPB7Hjg9IJjdswQRZZ+rqS5p3BVpkkEtVpAGFuKZsJc2CKwkCiIDqrmwQuYHKwFV8BiKRCAsaAGVEBM9d2LQcTuALRCh8tz+hoTJyGmVWMxs2RBwGoY4YxBQogUYgXjoXTgqT4CiICUEGTw13khSwzSALWAqZ7qgBJUBB8MKTBvRin6FqzFZh7XD8EKlAIi2AhCD1arwIVCU5SOZiR5l17cwqihIKDplLQniAEJwJuBCgMlGKigZgigdLZKQRXnDYgfvKngwJcBTqAsoKGOvfTYfnuT5dTTbAjx4RG6GtARz2SoXOhbbAAagNjqWUk/kGsAchlAnUKo1SwHhO8U1gCKlp5CDXtGCqZ7K5y6WFw2ZS+FJkKA56Fpz2JmOdODpF+t7mylgBjIBUYtZMVAjcscrsoU0aqojqohlIAqOE+Nkhm3wen54t2NKIS6lDx8g9J1hhcXBAP4HPpdaBSK5Mp6Hx4IlMdrnrQAimEAtIJQrWyVQyyeelBCZpiqO+6MNzl9PsW9p1GluRI6z9ltwzNnDVLx0i/hwY5jv3VMqfJo3fOzScidkBXg3VAJqmwFFEwN9rS61Cws5BF3tdfJu543Nq7eIrMS+r0SXxciFK9C5uBLN3oS4zHe8PUZ5eHdlkLhlW1PWyAvZQegYTNGGpZdsedcGrNZBFgXMKGbnFv44BZ5aKRkcsLxr0V4o2uZbSqlQj0Qvn1QWcmF1Ux5etETldAMhI1Cd0owYjMaps9aatnqwlrPsr/eZWX7/YPXY8ON0zUO70m4tFmSltWhdKjtmQgLasbzn0VHwyp/nvMciaGXw1wq+GEPNGsxvTJkslUymRQ8MLPNqYX8ioAiEBolsp5WIgQGfFZwcFfAha7lzDp8dsbzi/uUvIA/vBXx1PGI584r97cVxfDCsmWvKr8eHwIwpIjPiQKYTDJee7u8asaqUHghd4Y0U/JCKb3y6rzn2GLAZKx87QDsblpeW7e8vqp8YgLun4SXl+DHx+GxMeXpfbAnHDLhcs8w3jSsbjuWtz645hMjljCArZ5jeQNeX/aIwsGOcs8NBiPC7x4yPHfOsTvxzLYCnp2Db92kPHlAWM6VUnUHYLSubKRKVvrrCB4wMRpxaq5H6aEeCf84H9DzsK8tTDUqd9/QNHzziMGrcnxJeeIWODxmcF4ZCYVTm24HYKX7/jeROBScVwoHzhWsrHlKD9bA7M2jvLQaIiE8ckCueLdfKIdGhXpY/WaNYIEXN+1wH7j2aETCSN2ytFn5opsJogVguWNvwptbwkSUgRVaYTBo9kM7JTJXrFk65fiyDnfCaw/nlW6/5J3qVF3b0qkLa0XEnihjNspoaMbf5+T6LqJW+NEhuT6AYlDnHXooHExPRiRlj0whzlPGJWNtu7jqGl5hK1eOL/nLhb4wUPQ6FIB6zTI5sjO1HQvqISs8DfG8tVDQxHFkQvjnhSrEySXPwrbn3IZHFeqhsK9tLhfo8Nj7eCC0VZYAVpSVLcdUJ2ArrU6yZmzppg7n4OJSykhiuGUm4ZM3BTz1glK3JRsZnN1QujlcGLHcOmYYr8tlRU6tm6sDPPbxmEfvbnLsbI/nT+Rc2ihBYTNVJKixd6TERoZurvRKQ2c04ct3Nbht2jDdEvY0PeupMtGwjDdgqatsZMM7TOkXjmfPypUABycN3z/aZv/uiM9/LOGHR5U/Hsv4+V9S5rc9G7nlwmaMJPC5Wy37d1mO3hHQ7Xs+s99wft3znXuFzFmWujAaDS6tKjSjyk8vX3T86d89/rv6HgWSEJ64r8H+3dFloCgQvnF/zBfujPj9Sxm3zwScnHecWHT85rE6AG8ue0pf1TMrlHbHsrDlOTAunF4uiULDagrfe95xcs2QnVvFW2C8tWPCqRgkF75yd/OqRhyrGw5NWUILzRr89IsJzisgWPGERlnteg5MVCC7WwYRYbptSQs4s+7Z6pasn7rExZWCotPgHPEOwMmTBZ86XGN27Np9aXmz5G8nMh65s0Y7MdjBrTYtDP+bd9y8693vnlhwPHOyYCyGzQwevxXeXi7JSiX1AQe0P7wNDV+9t3HN4KpwftXzxD0x7USG7AR54fn0LSF2aEOfWfG8PFdyZNKSO5huCAcmQ+o1w1hD6GPIL21UR/xH/ff8/7zueff8JH+eAAAAAElFTkSuQmCC 7 | 8 | 9 | 10 | 11 | 12 | 13 | https://nyaa.si/ 14 | -------------------------------------------------------------------------------- /nyaa/static/search-sukebei.xml: -------------------------------------------------------------------------------- 1 | 2 | Sukebei (Nyaa.si) 3 | 4 | Search torrents on Sukebei (Nyaa.si). 5 | UTF-8 6 | data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAHmElEQVRYw8WXXYxdVRXHf2vvc849537MvTOdmQ4zUygtpS0FIWL4iBEE9cGqAU0gojHBxAef9MFETXwhRmM00ReNia8kmhh9g0R8kISYiFjAKqYNhZa2Qzud7897zz0fey8fzqVzoS2UJ3Zyc25y99nrt/7rv9feV+J7v6t8hCMAmHn4B+BK1nrwYWmSmtDuGIoSapGhfnSCe6Ys+9KMn/xq8Yr5cSz0+1WU9NgvK4BW3XJhuQThQxFMjVtanZBGM4SmoTvbpD+XYls1FnslYQA2EAiUVkPo3JbQfrCBnU9ZfzXn+LGBAllWUrrrDxxa2LuvQdauI6Js1UJIDJIbwpplxAhrnZgbn9xNs2NwiWAbBp8IpVHKXZbG7SX8dgDQqMFo3bDW8x8YfM+UpdjVYS6pEwfQMxAZCEuwfUgbMXXxrDuPaUf0IwgC8B6kAELBGoPB7Hjg9IJjdswQRZZ+rqS5p3BVpkkEtVpAGFuKZsJc2CKwkCiIDqrmwQuYHKwFV8BiKRCAsaAGVEBM9d2LQcTuALRCh8tz+hoTJyGmVWMxs2RBwGoY4YxBQogUYgXjoXTgqT4CiICUEGTw13khSwzSALWAqZ7qgBJUBB8MKTBvRin6FqzFZh7XD8EKlAIi2AhCD1arwIVCU5SOZiR5l17cwqihIKDplLQniAEJwJuBCgMlGKigZgigdLZKQRXnDYgfvKngwJcBTqAsoKGOvfTYfnuT5dTTbAjx4RG6GtARz2SoXOhbbAAagNjqWUk/kGsAchlAnUKo1SwHhO8U1gCKlp5CDXtGCqZ7K5y6WFw2ZS+FJkKA56Fpz2JmOdODpF+t7mylgBjIBUYtZMVAjcscrsoU0aqojqohlIAqOE+Nkhm3wen54t2NKIS6lDx8g9J1hhcXBAP4HPpdaBSK5Mp6Hx4IlMdrnrQAimEAtIJQrWyVQyyeelBCZpiqO+6MNzl9PsW9p1GluRI6z9ltwzNnDVLx0i/hwY5jv3VMqfJo3fOzScidkBXg3VAJqmwFFEwN9rS61Cws5BF3tdfJu543Nq7eIrMS+r0SXxciFK9C5uBLN3oS4zHe8PUZ5eHdlkLhlW1PWyAvZQegYTNGGpZdsedcGrNZBFgXMKGbnFv44BZ5aKRkcsLxr0V4o2uZbSqlQj0Qvn1QWcmF1Ux5etETldAMhI1Cd0owYjMaps9aatnqwlrPsr/eZWX7/YPXY8ON0zUO70m4tFmSltWhdKjtmQgLasbzn0VHwyp/nvMciaGXw1wq+GEPNGsxvTJkslUymRQ8MLPNqYX8ioAiEBolsp5WIgQGfFZwcFfAha7lzDp8dsbzi/uUvIA/vBXx1PGI584r97cVxfDCsmWvKr8eHwIwpIjPiQKYTDJee7u8asaqUHghd4Y0U/JCKb3y6rzn2GLAZKx87QDsblpeW7e8vqp8YgLun4SXl+DHx+GxMeXpfbAnHDLhcs8w3jSsbjuWtz645hMjljCArZ5jeQNeX/aIwsGOcs8NBiPC7x4yPHfOsTvxzLYCnp2Db92kPHlAWM6VUnUHYLSubKRKVvrrCB4wMRpxaq5H6aEeCf84H9DzsK8tTDUqd9/QNHzziMGrcnxJeeIWODxmcF4ZCYVTm24HYKX7/jeROBScVwoHzhWsrHlKD9bA7M2jvLQaIiE8ckCueLdfKIdGhXpY/WaNYIEXN+1wH7j2aETCSN2ytFn5opsJogVguWNvwptbwkSUgRVaYTBo9kM7JTJXrFk65fiyDnfCaw/nlW6/5J3qVF3b0qkLa0XEnihjNspoaMbf5+T6LqJW+NEhuT6AYlDnHXooHExPRiRlj0whzlPGJWNtu7jqGl5hK1eOL/nLhb4wUPQ6FIB6zTI5sjO1HQvqISs8DfG8tVDQxHFkQvjnhSrEySXPwrbn3IZHFeqhsK9tLhfo8Nj7eCC0VZYAVpSVLcdUJ2ArrU6yZmzppg7n4OJSykhiuGUm4ZM3BTz1glK3JRsZnN1QujlcGLHcOmYYr8tlRU6tm6sDPPbxmEfvbnLsbI/nT+Rc2ihBYTNVJKixd6TERoZurvRKQ2c04ct3Nbht2jDdEvY0PeupMtGwjDdgqatsZMM7TOkXjmfPypUABycN3z/aZv/uiM9/LOGHR5U/Hsv4+V9S5rc9G7nlwmaMJPC5Wy37d1mO3hHQ7Xs+s99wft3znXuFzFmWujAaDS6tKjSjyk8vX3T86d89/rv6HgWSEJ64r8H+3dFloCgQvnF/zBfujPj9Sxm3zwScnHecWHT85rE6AG8ue0pf1TMrlHbHsrDlOTAunF4uiULDagrfe95xcs2QnVvFW2C8tWPCqRgkF75yd/OqRhyrGw5NWUILzRr89IsJzisgWPGERlnteg5MVCC7WwYRYbptSQs4s+7Z6pasn7rExZWCotPgHPEOwMmTBZ86XGN27Np9aXmz5G8nMh65s0Y7MdjBrTYtDP+bd9y8693vnlhwPHOyYCyGzQwevxXeXi7JSiX1AQe0P7wNDV+9t3HN4KpwftXzxD0x7USG7AR54fn0LSF2aEOfWfG8PFdyZNKSO5huCAcmQ+o1w1hD6GPIL21UR/xH/ff8/7zueff8JH+eAAAAAElFTkSuQmCC 7 | 8 | 9 | 10 | 11 | 12 | 13 | https://sukebei.nyaa.si/ 14 | 15 | -------------------------------------------------------------------------------- /nyaa/templates/user_comments.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}Comments made by {{ user.username }} :: {{ config.SITE_NAME }}{% endblock %} 3 | {% block meta_image %}{{ user.gravatar_url() }}{% endblock %} 4 | {% block metatags %} 5 | 6 | {% endblock %} 7 | 8 | {% block body %} 9 | {% from "_formhelpers.html" import render_menu_with_button %} 10 | {% from "_formhelpers.html" import render_field %} 11 | 12 |

13 | {{ user.username }}'{{ '' if user.username[-1] == 's' else 's' }} comments 14 |

15 | 16 | {% if comments_query.items %} 17 |
18 |
19 |

20 | Total of {{ comments_query.total }} comments 21 |

22 |
23 | {% for comment in comments_query.items %} 24 |
25 |
26 |
27 |

28 | {{ comment.user.username }} 29 |

30 | {{ comment.user.userlevel_str }} 31 |
32 |
33 |
34 | {{ comment.created_time.strftime('%Y-%m-%d %H:%M UTC') }} 35 | {% if comment.edited_time %} 36 | (edited) 37 | {% endif %} 38 | on torrent #{{comment.torrent_id}} {{ comment.torrent.display_name }} 39 | {#
40 | {% if g.user.id == comment.user_id and not comment.editing_limit_exceeded %} 41 | 42 | {% endif %} 43 | {% if g.user.is_moderator or g.user.id == comment.user_id %} 44 |
45 | 46 |
47 | {% endif %} 48 |
#} 49 |
50 |
51 | {# Escape newlines into html entities because CF strips blank newlines #} 52 |
{{- comment.text | escape | replace('\r\n', '\n') | replace('\n', ' '|safe) -}}
53 |
54 |
55 |
56 |
57 | 58 | {% endfor %} 59 |
60 | 61 | {% else %} 62 |

No comments

63 | {% endif %} 64 | 65 |
66 | {% from "bootstrap/pagination.html" import render_pagination %} 67 | {{ render_pagination(comments_query) }} 68 |
69 | 70 | 71 | {% endblock %} 72 |
73 | -------------------------------------------------------------------------------- /tests/test_template_utils.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import datetime 3 | 4 | from email.utils import formatdate 5 | 6 | from tests import NyaaTestCase 7 | from nyaa.template_utils import (_jinja2_filter_rfc822, _jinja2_filter_rfc822_es, get_utc_timestamp, 8 | get_display_time, timesince, filter_truthy, category_name) 9 | 10 | 11 | class TestTemplateUtils(NyaaTestCase): 12 | 13 | # def setUp(self): 14 | # self.db, nyaa.app.config['DATABASE'] = tempfile.mkstemp() 15 | # nyaa.app.config['TESTING'] = True 16 | # self.app = nyaa.app.test_client() 17 | # with nyaa.app.app_context(): 18 | # nyaa.db.create_all() 19 | # 20 | # def tearDown(self): 21 | # os.close(self.db) 22 | # os.unlink(nyaa.app.config['DATABASE']) 23 | 24 | def test_filter_rfc822(self): 25 | # test with timezone UTC 26 | test_date = datetime.datetime(2017, 2, 15, 11, 15, 34, 100, datetime.timezone.utc) 27 | self.assertEqual(_jinja2_filter_rfc822(test_date), 'Wed, 15 Feb 2017 11:15:34 -0000') 28 | 29 | def test_filter_rfc822_es(self): 30 | # test with local timezone 31 | test_date_str = '2017-02-15T11:15:34' 32 | # this is in order to get around local time zone issues 33 | expected = formatdate(float(datetime.datetime(2017, 2, 15, 11, 15, 34, 100).timestamp())) 34 | self.assertEqual(_jinja2_filter_rfc822_es(test_date_str), expected) 35 | 36 | def test_get_utc_timestamp(self): 37 | # test with local timezone 38 | test_date_str = '2017-02-15T11:15:34' 39 | self.assertEqual(get_utc_timestamp(test_date_str), 1487157334) 40 | 41 | def test_get_display_time(self): 42 | # test with local timezone 43 | test_date_str = '2017-02-15T11:15:34' 44 | self.assertEqual(get_display_time(test_date_str), '2017-02-15 11:15') 45 | 46 | def test_timesince(self): 47 | now = datetime.datetime.utcnow() 48 | self.assertEqual(timesince(now), 'just now') 49 | self.assertEqual(timesince(now - datetime.timedelta(seconds=5)), '5 seconds ago') 50 | self.assertEqual(timesince(now - datetime.timedelta(minutes=1)), '1 minute ago') 51 | self.assertEqual( 52 | timesince(now - datetime.timedelta(minutes=38, seconds=43)), '38 minutes ago') 53 | self.assertEqual( 54 | timesince(now - datetime.timedelta(hours=2, minutes=38, seconds=51)), '2 hours ago') 55 | bigger = now - datetime.timedelta(days=3) 56 | self.assertEqual(timesince(bigger), bigger.strftime('%Y-%m-%d %H:%M UTC')) 57 | 58 | @unittest.skip('Not yet implemented') 59 | def test_static_cachebuster(self): 60 | pass 61 | 62 | @unittest.skip('Not yet implemented') 63 | def test_modify_query(self): 64 | pass 65 | 66 | def test_filter_truthy(self): 67 | my_list = [ 68 | True, False, # booleans 69 | 'hello!', '', # strings 70 | 1, 0, -1, # integers 71 | 1.0, 0.0, -1.0, # floats 72 | ['test'], [], # lists 73 | {'marco': 'polo'}, {}, # dictionaries 74 | None 75 | ] 76 | expected_result = [ 77 | True, 78 | 'hello!', 79 | 1, -1, 80 | 1.0, -1.0, 81 | ['test'], 82 | {'marco': 'polo'} 83 | ] 84 | self.assertListEqual(filter_truthy(my_list), expected_result) 85 | 86 | def test_category_name(self): 87 | with self.app_context: 88 | # Nyaa categories only 89 | self.assertEqual(category_name('1_0'), 'Anime') 90 | self.assertEqual(category_name('1_2'), 'Anime - English-translated') 91 | # Unknown category ids 92 | self.assertEqual(category_name('100_0'), '???') 93 | self.assertEqual(category_name('1_100'), '???') 94 | self.assertEqual(category_name('0_0'), '???') 95 | 96 | 97 | if __name__ == '__main__': 98 | unittest.main() 99 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from collections import OrderedDict 3 | 4 | from hashlib import sha1 5 | from nyaa import utils 6 | 7 | 8 | class TestUtils(unittest.TestCase): 9 | 10 | def test_sha1_hash(self): 11 | bencoded_test_data = b'd5:hello5:world7:numbersli1ei2eee' 12 | self.assertEqual( 13 | utils.sha1_hash(bencoded_test_data), 14 | sha1(bencoded_test_data).digest()) 15 | 16 | def test_sorted_pathdict(self): 17 | initial = { 18 | 'api_handler.py': 11805, 19 | 'routes.py': 34247, 20 | '__init__.py': 6499, 21 | 'torrents.py': 11948, 22 | 'static': { 23 | 'img': { 24 | 'nyaa.png': 1200, 25 | 'sukebei.png': 1100, 26 | }, 27 | 'js': { 28 | 'main.js': 3000, 29 | }, 30 | }, 31 | 'search.py': 5148, 32 | 'models.py': 24293, 33 | 'templates': { 34 | 'upload.html': 3000, 35 | 'home.html': 1200, 36 | 'layout.html': 23000, 37 | }, 38 | 'utils.py': 14700, 39 | } 40 | expected = OrderedDict({ 41 | 'static': OrderedDict({ 42 | 'img': OrderedDict({ 43 | 'nyaa.png': 1200, 44 | 'sukebei.png': 1100, 45 | }), 46 | 'js': OrderedDict({ 47 | 'main.js': 3000, 48 | }), 49 | }), 50 | 'templates': OrderedDict({ 51 | 'home.html': 1200, 52 | 'layout.html': 23000, 53 | 'upload.html': 3000, 54 | }), 55 | '__init__.py': 6499, 56 | 'api_handler.py': 11805, 57 | 'models.py': 24293, 58 | 'routes.py': 34247, 59 | 'search.py': 5148, 60 | 'torrents.py': 11948, 61 | 'utils.py': 14700, 62 | }) 63 | self.assertDictEqual(utils.sorted_pathdict(initial), expected) 64 | 65 | @unittest.skip('Not yet implemented') 66 | def test_cached_function(self): 67 | # TODO: Test with a function that generates something random? 68 | pass 69 | 70 | def test_flatten_dict(self): 71 | initial = OrderedDict({ 72 | 'static': OrderedDict({ 73 | 'img': OrderedDict({ 74 | 'nyaa.png': 1200, 75 | 'sukebei.png': 1100, 76 | }), 77 | 'js': OrderedDict({ 78 | 'main.js': 3000, 79 | }), 80 | 'favicon.ico': 1000, 81 | }), 82 | 'templates': [ 83 | {'home.html': 1200}, 84 | {'layout.html': 23000}, 85 | {'upload.html': 3000}, 86 | ], 87 | '__init__.py': 6499, 88 | 'api_handler.py': 11805, 89 | 'models.py': 24293, 90 | 'routes.py': 34247, 91 | 'search.py': 5148, 92 | 'torrents.py': 11948, 93 | 'utils.py': 14700, 94 | }) 95 | expected = { 96 | 'static/img/nyaa.png': 1200, 97 | 'static/img/sukebei.png': 1100, 98 | 'static/js/main.js': 3000, 99 | 'static/favicon.ico': 1000, 100 | 'templates/home.html': 1200, 101 | 'templates/layout.html': 23000, 102 | 'templates/upload.html': 3000, 103 | '__init__.py': 6499, 104 | 'api_handler.py': 11805, 105 | 'models.py': 24293, 106 | 'routes.py': 34247, 107 | 'search.py': 5148, 108 | 'utils.py': 14700, 109 | 'torrents.py': 11948, 110 | } 111 | self.assertDictEqual(utils.flatten_dict(initial), expected) 112 | 113 | 114 | if __name__ == '__main__': 115 | unittest.main() 116 | -------------------------------------------------------------------------------- /nyaa/templates/admin_trusted_view.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% from "_formhelpers.html" import render_field, render_menu_with_button %} 3 | {%- macro review_class(rec) -%} 4 | {%- if rec.name == 'ACCEPT' -%} 5 | {{ 'panel-success' -}} 6 | {%- elif rec.name == 'REJECT' -%} 7 | {{ 'panel-danger' -}} 8 | {%- elif rec.name == 'ABSTAIN' -%} 9 | {{ 'panel-default' -}} 10 | {%- endif -%} 11 | {%- endmacro -%} 12 | {% block title %}{{ app.submitter.username }}'s Application :: {{ config.SITE_NAME }}{% endblock %} 13 | {% block body %} 14 |
15 |
16 |

{{ app.submitter.username }}'s Application

17 |
18 |
19 |
20 |
21 |
22 |
Submitter
23 |
24 | 25 | {{ app.submitter.username }} 26 | 27 |
28 |
29 |
30 |
Submitted on
31 |
32 | {{ app.created_time.strftime('%Y-%m-%d %H:%M') }} 33 |
34 |
35 |
36 |
Status
37 |
{{ app.status.name.capitalize() }}
38 |
39 |
40 |
41 |
42 |
43 |
44 |

Why do you think you should be given trusted status?

45 |
46 |
47 | {{- app.why_give | escape | replace('\r\n', '\n') | replace('\n', ' '|safe) -}} 48 |
49 |
50 |
51 |
52 |
53 |
54 |

Why do you want to become a trusted user?

55 |
56 |
57 | {{- app.why_want | escape | replace('\r\n', '\n') | replace('\n', ' '|safe) -}} 58 |
59 |
60 |
61 |
62 |
63 | {%- if decision_form -%} 64 | 73 | {%- endif -%} 74 |
75 |
76 |
77 |

Reviews - {{ app.reviews | length }}

78 |
79 |
80 | {% for rev in app.reviews %} 81 |
82 |
83 |

{{ rev.reviewer.username }}'s Review

84 |
85 |
86 |
87 | {{- rev.comment | escape | replace('\r\n', '\n') | replace('\n', ' '|safe) -}} 88 |
89 |
90 | 97 |
98 | {% endfor %} 99 |
100 | {{ review_form.csrf_token }} 101 |
102 |
103 | {{ render_field(review_form.comment, class_="form-control") }} 104 |
105 |
106 |
107 |
108 | {{ render_menu_with_button(review_form.recommendation, 'Submit') }} 109 |
110 |
111 |
112 |
113 |
114 | {% endblock %} 115 | -------------------------------------------------------------------------------- /tests/test_bencode.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from nyaa import bencode 4 | 5 | 6 | class TestBencode(unittest.TestCase): 7 | 8 | def test_pairwise(self): 9 | # test list with even length 10 | initial = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] 11 | expected = [(0, 1), (2, 3), (4, 5), (6, 7), (8, 9)] 12 | 13 | for index, values in enumerate(bencode._pairwise(initial)): 14 | self.assertEqual(values, expected[index]) 15 | 16 | # test list with odd length 17 | initial = [0, 1, 2, 3, 4] 18 | expected = [(0, 1), (2, 3), 4] 19 | 20 | for index, values in enumerate(bencode._pairwise(initial)): 21 | self.assertEqual(values, expected[index]) 22 | 23 | # test non-iterable 24 | initial = b'012345' 25 | expected = [(48, 49), (50, 51), (52, 53)] # decimal ASCII 26 | for index, values in enumerate(bencode._pairwise(initial)): 27 | self.assertEqual(values, expected[index]) 28 | 29 | def test_encode(self): 30 | exception_test_cases = [ # (raw, raised_exception, expected_result_regexp) 31 | # test unsupported type 32 | (None, bencode.BencodeException, 33 | r'Unsupported type'), 34 | (1.6, bencode.BencodeException, 35 | r'Unsupported type'), 36 | ] 37 | 38 | test_cases = [ # (raw, expected_result) 39 | (100, b'i100e'), # int 40 | (-5, b'i-5e'), # int 41 | ('test', b'4:test'), # str 42 | (b'test', b'4:test'), # byte 43 | (['test', 100], b'l4:testi100ee'), # list 44 | ({'numbers': [1, 2], 'hello': 'world'}, b'd5:hello5:world7:numbersli1ei2eee') # dict 45 | ] 46 | 47 | for raw, raised_exception, expected_result_regexp in exception_test_cases: 48 | self.assertRaisesRegexp(raised_exception, expected_result_regexp, bencode.encode, raw) 49 | 50 | for raw, expected_result in test_cases: 51 | self.assertEqual(bencode.encode(raw), expected_result) 52 | 53 | def test_decode(self): 54 | exception_test_cases = [ # (raw, raised_exception, expected_result_regexp) 55 | # test malformed bencode 56 | (b'l4:hey', bencode.MalformedBencodeException, 57 | r'Read only \d+ bytes, \d+ wanted'), 58 | (b'ie', bencode.MalformedBencodeException, 59 | r'Unable to parse int'), 60 | (b'i64', bencode.MalformedBencodeException, 61 | r'EOF, expecting more integer'), 62 | (b'', bencode.MalformedBencodeException, 63 | r'EOF, expecting kind'), 64 | (b'i6-4', bencode.MalformedBencodeException, 65 | r'Unexpected input while reading an integer'), 66 | (b'4#string', bencode.MalformedBencodeException, 67 | r'Unexpected input while reading string length'), 68 | (b'4', bencode.MalformedBencodeException, 69 | r'EOF, expecting more string len'), 70 | (b'$:string', bencode.MalformedBencodeException, 71 | r'Unexpected data type'), 72 | (b'd5:world7:numbersli1ei2eee', bencode.MalformedBencodeException, 73 | r'Uneven amount of key/value pairs'), 74 | ] 75 | 76 | test_cases = [ # (raw, expected_result) 77 | (b'i100e', 100), # int 78 | (b'i-5e', -5), # int 79 | ('4:test', b'test'), # str 80 | (b'4:test', b'test'), # byte 81 | (b'15:thisisalongone!', b'thisisalongone!'), # big byte 82 | (b'l4:testi100ee', [b'test', 100]), # list 83 | (b'd5:hello5:world7:numbersli1ei2eee', {'hello': b'world', 'numbers': [1, 2]}) # dict 84 | ] 85 | 86 | for raw, raised_exception, expected_result_regexp in exception_test_cases: 87 | self.assertRaisesRegexp(raised_exception, expected_result_regexp, bencode.decode, raw) 88 | 89 | for raw, expected_result in test_cases: 90 | self.assertEqual(bencode.decode(raw), expected_result) 91 | 92 | 93 | if __name__ == '__main__': 94 | unittest.main() 95 | -------------------------------------------------------------------------------- /rangeban.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from datetime import datetime 4 | from ipaddress import ip_address 5 | import sys 6 | 7 | import click 8 | 9 | from nyaa import create_app, models 10 | from nyaa.extensions import db 11 | 12 | 13 | def is_cidr_valid(c): 14 | '''Checks whether a CIDR range string is valid.''' 15 | try: 16 | subnet, mask = c.split('/') 17 | except ValueError: 18 | return False 19 | if int(mask) < 1 or int(mask) > 32: 20 | return False 21 | try: 22 | ip = ip_address(subnet) 23 | except ValueError: 24 | return False 25 | return True 26 | 27 | 28 | def check_str(b): 29 | '''Returns a checkmark or cross depending on the condition.''' 30 | return '\u2713' if b else '\u2717' 31 | 32 | 33 | @click.group() 34 | def rangeban(): 35 | global app 36 | app = create_app('config') 37 | 38 | 39 | @rangeban.command() 40 | @click.option('--temp/--no-temp', help='Mark this entry as one that may be ' 41 | 'cleaned out occasionally.', default=False) 42 | @click.argument('cidrrange') 43 | def ban(temp, cidrrange): 44 | if not is_cidr_valid(cidrrange): 45 | click.secho('{} is not of the format xxx.xxx.xxx.xxx/xx.' 46 | .format(cidrrange), err=True, fg='red') 47 | sys.exit(1) 48 | with app.app_context(): 49 | ban = models.RangeBan(cidr_string=cidrrange, temp=datetime.utcnow() if temp else None) 50 | db.session.add(ban) 51 | db.session.commit() 52 | click.echo('Added {} for {}.'.format('temp ban' if temp else 'ban', 53 | cidrrange)) 54 | 55 | 56 | @rangeban.command() 57 | @click.argument('cidrrange') 58 | def unban(cidrrange): 59 | if not is_cidr_valid(cidrrange): 60 | click.secho('{} is not of the format xxx.xxx.xxx.xxx/xx.' 61 | .format(cidrrange), err=True, fg='red') 62 | sys.exit(1) 63 | with app.app_context(): 64 | # Dunno why this wants _cidr_string and not cidr_string, probably 65 | # due to this all being a janky piece of shit. 66 | bans = models.RangeBan.query.filter( 67 | models.RangeBan._cidr_string == cidrrange).all() 68 | if len(bans) == 0: 69 | click.echo('Ban not found.') 70 | for b in bans: 71 | click.echo('Unbanned {}'.format(b.cidr_string)) 72 | db.session.delete(b) 73 | db.session.commit() 74 | 75 | 76 | @rangeban.command() 77 | def list(): 78 | with app.app_context(): 79 | bans = models.RangeBan.query.all() 80 | if len(bans) == 0: 81 | click.echo('No bans.') 82 | else: 83 | click.secho('ID CIDR Range Enabled Temp', bold=True) 84 | for b in bans: 85 | click.echo('{0: <6} {1: <18} {2: <7} {3: <4}' 86 | .format(b.id, b.cidr_string, 87 | check_str(b.enabled), 88 | check_str(b.temp is not None))) 89 | 90 | @rangeban.command() 91 | @click.argument('banid', type=int) 92 | @click.argument('status') 93 | def enabled(banid, status): 94 | yeses = ['true', '1', 'yes', '\u2713'] 95 | noses = ['false', '0', 'no', '\u2717'] 96 | if status.lower() in yeses: 97 | set_to = True 98 | elif status.lower() in noses: 99 | set_to = False 100 | else: 101 | click.secho('Please choose one of {} or {}.' 102 | .format(yeses, noses), err=True, fg='red') 103 | sys.exit(1) 104 | with app.app_context(): 105 | ban = models.RangeBan.query.get(banid) 106 | if not ban: 107 | click.secho('No ban with id {} found.' 108 | .format(banid), err=True, fg='red') 109 | sys.exit(1) 110 | ban.enabled = set_to 111 | db.session.add(ban) 112 | db.session.commit() 113 | click.echo('{} ban {} on {}.'.format('Enabled' if set_to else 'Disabled', 114 | banid, ban._cidr_string)) 115 | 116 | 117 | 118 | if __name__ == '__main__': 119 | rangeban() 120 | -------------------------------------------------------------------------------- /nyaa/templates/profile.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}Edit Profile :: {{ config.SITE_NAME }}{% endblock %} 3 | {% block body %} 4 | {% from "_formhelpers.html" import render_field %} 5 | 6 |

Profile of {{ g.user.username }}

7 | 8 |
9 |
10 | 11 |
12 |
13 |
14 |
User ID:
{{ g.user.id }}
15 |
User Class:
{{ g.user.userlevel_str }}
16 |
User Created on:
{{ g.user.created_time }}
17 |
18 |
19 |
20 | 21 | 32 | 33 |
34 |
35 |
36 | {{ form.csrf_token }} 37 |
38 |
39 | {{ render_field(form.current_password, class_='form-control', placeholder='Current password') }} 40 |
41 |
42 |
43 |
44 | {{ render_field(form.new_password, class_='form-control', placeholder='New password') }} 45 |
46 |
47 |
48 |
49 | {{ render_field(form.password_confirm, class_='form-control', placeholder='New password (confirm)') }} 50 |
51 |
52 |
53 |
54 | {{ form.authorized_submit(class_='btn btn-primary') }} 55 |
56 |
57 |
58 |
59 |
60 |
61 | {{ form.csrf_token }} 62 |
63 |
64 | 65 |
{{ g.user.email }}
66 |
67 |
68 |
69 |
70 | {{ render_field(form.email, class_='form-control', placeholder='New email address') }} 71 |
72 |
73 |
74 |
75 | {{ render_field(form.current_password, class_='form-control', placeholder='Current password') }} 76 |
77 |
78 |
79 |
80 | {{ form.authorized_submit(class_='btn btn-primary') }} 81 |
82 |
83 |
84 |
85 |
86 |
87 | {{ form.csrf_token }} 88 |
89 |
90 | {% if g.user.preferences.hide_comments %} 91 | {{ form.hide_comments(checked='') }} 92 | {% else %} 93 | {{ form.hide_comments }} 94 | {% endif %} 95 | {{ form.hide_comments.label }} 96 |
97 |
98 |
99 |
100 | {{ form.submit_settings(class_='btn btn-primary') }} 101 |
102 |
103 |
104 |
105 |
106 | 107 |
108 | 109 | {% endblock %} 110 | -------------------------------------------------------------------------------- /nyaa/templates/rules.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}Rules :: {{ config.SITE_NAME }}{% endblock %} 3 | {% block body %} 4 | 5 |
6 |

Site Rules

7 | {# Spoilers: Your account will be banned if you repeatedly post these without using the [spoiler] tag properly. #} 8 |

Breaking any of the rules on this page may result in being banned

9 |

Shitposting and Trolling: Your account will be banned if you keep this up. Repeatedly making inaccurate/false reports falls under this rule as well.

10 |

Bumping: Your account will be banned if you keep deleting and reposting your torrents.

11 |

Flooding: If you have five or more releases of the same type to release in one go, make a batch torrent containing all of them.

12 |

URL redirection services: These are removed on sight along with their torrents.

13 |

Advertising: No.

14 |

Content restrictions: This site is for content that originates from and/or is specific to China, Japan, and/or Korea.

15 |

Other content is not allowed without exceptions and will be removed.


16 |

{{ config.EXTERNAL_URLS['main'] }} is for work-safe content only. The following rules apply:

17 |
    18 |
  • 19 |

    No pornography of any kind.

    20 |
  • 21 |
  • 22 |

    No extreme visual content. This means no scat, gore, or any other of such things.

    23 |
  • 24 |
  • 25 |

    Troll torrents are not allowed. These will be removed on sight.

    26 |
  • 27 |

28 |

{{ config.EXTERNAL_URLS['fap'] }} is the place for non-work-safe content only. Still, the following rules apply:

29 |
    30 |
  • 31 |

    No extreme real life visual content. This means no scat, gore, bestiality, or any other of such things.

    32 |
  • 33 |
  • 34 |

    Absolutely no real life child pornography of any kind.

    35 |
  • 36 |
  • 37 |

    Troll torrents are not allowed. These will be removed on sight.

    38 |
  • 39 |

40 |

Torrent information: Text files (.txt) or info files (.nfo) for torrent or release group information are preferred.

41 |

Torrents containing (.chm) or (.url) files may be removed.


42 |

Upper limits on video resolution based on source:

43 |
    44 |
  • 45 |

    DVD source video is limited to 1024x576p.

    46 |
  • 47 |
  • 48 |

    Web source video is limited to 1920x1080p or source resolution, whichever is lower.

    49 |
  • 50 |
  • 51 |

    TV source video is by default limited to 1920x1080p. SD channels, however, are limited to 480p.

    52 |
  • 53 |
  • 54 |

    Blu-ray source video is limited to 1920x1080p.

    55 |
  • 56 |
  • 57 |

    UHD source video is limited to 3840x2160p.

    58 |
  • 59 |

60 |

Naturally, untouched sources are not bound by these limits. Leaks are also not subject to any resolution limits.


61 |

Finally, a few notes concerning tagging and using other people's work:

62 |
    63 |
  • 64 |

    Do not add your own tag(s) when reuploading an original release.

    65 |
  • 66 |
  • 67 |

    Unless you are reuploading an original release, you should either avoid using tags that are not your own or make it extremely clear to everyone that you are the one responsible for the upload.

    68 |
  • 69 |
  • 70 |

    If these policies are not obeyed, then those torrents will be removed if reported by a group or person commonly seen as the owner of the tag(s). This especially applies to remake torrents.

    71 |
  • 72 |
  • 73 |

    Although only hinted at above, we may remove troll torrents tagged with A-sucks, B-is-slow, or such if reported by A or B.

    74 |
  • 75 |
  • 76 |

    Remakes which are utterly bit rate-starved are not allowed.

    77 |
  • 78 |
  • 79 |

    Remakes which add watermarks or such are not allowed.

    80 |
  • 81 |
  • 82 |

    Remakes which reencode video to XviD or worse are not allowed.

    83 |
  • 84 |
  • 85 |

    Remakes of JPG/PNG-based releases are not allowed without exceptions since there is most often no point in making such.

    86 |
  • 87 |
88 |
89 | {% endblock %} 90 | -------------------------------------------------------------------------------- /utils/api_info.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import os 4 | import re 5 | 6 | import requests 7 | 8 | NYAA_HOST = 'https://nyaa.si' 9 | SUKEBEI_HOST = 'https://sukebei.nyaa.si' 10 | 11 | API_BASE = '/api' 12 | API_INFO = API_BASE + '/info' 13 | 14 | ID_PATTERN = '^[0-9]+$' 15 | INFO_HASH_PATTERN = '^[0-9a-fA-F]{40}$' 16 | 17 | environment_epillog = ('You may also provide environment variables NYAA_API_HOST, NYAA_API_USERNAME' 18 | ' and NYAA_API_PASSWORD for connection info.') 19 | 20 | parser = argparse.ArgumentParser( 21 | description='Query torrent info on Nyaa.si', epilog=environment_epillog) 22 | 23 | conn_group = parser.add_argument_group('Connection options') 24 | 25 | conn_group.add_argument('-s', '--sukebei', default=False, 26 | action='store_true', help='Query torrent info on sukebei.Nyaa.si') 27 | 28 | conn_group.add_argument('-u', '--user', help='Username or email') 29 | conn_group.add_argument('-p', '--password', help='Password') 30 | conn_group.add_argument('--host', help='Select another api host (for debugging purposes)') 31 | 32 | resp_group = parser.add_argument_group('Response options') 33 | 34 | resp_group.add_argument('--raw', default=False, action='store_true', 35 | help='Print only raw response (JSON)') 36 | resp_group.add_argument('-m', '--magnet', default=False, 37 | action='store_true', help='Print magnet uri') 38 | 39 | 40 | req_group = parser.add_argument_group('Required arguments') 41 | req_group.add_argument('hash_or_id', metavar='HASH_OR_ID', 42 | help='Torrent ID or hash (hex, 40 characters) to query for') 43 | 44 | 45 | def easy_file_size(filesize): 46 | for prefix in ['B', 'KiB', 'MiB', 'GiB', 'TiB']: 47 | if filesize < 1024.0: 48 | return '{0:.1f} {1}'.format(filesize, prefix) 49 | filesize = filesize / 1024.0 50 | return '{0:.1f} {1}'.format(filesize, prefix) 51 | 52 | 53 | def _as_yes_no(value): 54 | return 'Yes' if value else 'No' 55 | 56 | 57 | INFO_TEMPLATE = ("Torrent #{id}: '{name}' ({formatted_filesize}) uploaded by {submitter}" 58 | "\n {creation_date} [{main_category} - {sub_category}] [{flag_info}]") 59 | FLAG_NAMES = ['Trusted', 'Complete', 'Remake'] 60 | 61 | 62 | if __name__ == '__main__': 63 | args = parser.parse_args() 64 | 65 | # Use debug host from args or environment, if set 66 | debug_host = args.host or os.getenv('NYAA_API_HOST') 67 | api_host = (debug_host or (args.sukebei and SUKEBEI_HOST or NYAA_HOST)).rstrip('/') 68 | 69 | api_query = args.hash_or_id.lower().strip() 70 | 71 | # Verify query is either a valid id or valid hash 72 | id_match = re.match(ID_PATTERN, api_query) 73 | hex_hash_match = re.match(INFO_HASH_PATTERN, api_query) 74 | 75 | if not (id_match or hex_hash_match): 76 | raise Exception("Given argument '{}' doesn't " 77 | "seem like an ID or a hex hash.".format(api_query)) 78 | 79 | if id_match: 80 | # Remove leading zeroes 81 | api_query = api_query.lstrip('0') 82 | 83 | api_info_url = api_host + API_INFO + '/' + api_query 84 | 85 | api_username = args.user or os.getenv('NYAA_API_USERNAME') 86 | api_password = args.password or os.getenv('NYAA_API_PASSWORD') 87 | 88 | if not (api_username and api_password): 89 | raise Exception('No authorization found from arguments or environment variables.') 90 | 91 | auth = (api_username, api_password) 92 | 93 | # Go! 94 | r = requests.get(api_info_url, auth=auth) 95 | 96 | if args.raw: 97 | print(r.text) 98 | else: 99 | try: 100 | response = r.json() 101 | except ValueError: 102 | print('Bad response:') 103 | print(r.text) 104 | exit(1) 105 | 106 | errors = response.get('errors') 107 | 108 | if errors: 109 | print('Info request failed:', errors) 110 | exit(1) 111 | else: 112 | formatted_filesize = easy_file_size(response.get('filesize', 0)) 113 | flag_info = ', '.join( 114 | n + ': ' + _as_yes_no(response['is_' + n.lower()]) for n in FLAG_NAMES) 115 | 116 | info_str = INFO_TEMPLATE.format(formatted_filesize=formatted_filesize, 117 | flag_info=flag_info, **response) 118 | 119 | print(info_str) 120 | if args.magnet: 121 | print(response['magnet']) 122 | -------------------------------------------------------------------------------- /es_mapping.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # CREATE DTABASE/TABLE equivalent for elasticsearch, in yaml 3 | # fo inline comments. 4 | settings: 5 | analysis: 6 | analyzer: 7 | my_search_analyzer: 8 | type: custom 9 | tokenizer: standard 10 | char_filter: 11 | - my_char_filter 12 | filter: 13 | - lowercase 14 | my_index_analyzer: 15 | type: custom 16 | tokenizer: standard 17 | char_filter: 18 | - my_char_filter 19 | filter: 20 | - resolution 21 | - lowercase 22 | - word_delimit 23 | - my_ngram 24 | - trim_zero 25 | - unique 26 | # For exact matching - separate each character for substring matching + lowercase 27 | exact_analyzer: 28 | tokenizer: exact_tokenizer 29 | filter: 30 | - lowercase 31 | # For matching full words longer than the ngram limit (15 chars) 32 | my_fullword_index_analyzer: 33 | type: custom 34 | tokenizer: standard 35 | char_filter: 36 | - my_char_filter 37 | filter: 38 | - lowercase 39 | - word_delimit 40 | # Skip tokens shorter than N characters, 41 | # since they're already indexed in the main field 42 | - fullword_min 43 | - unique 44 | 45 | tokenizer: 46 | # Splits input into characters, for exact substring matching 47 | exact_tokenizer: 48 | type: pattern 49 | pattern: "(.)" 50 | group: 1 51 | 52 | filter: 53 | my_ngram: 54 | type: edge_ngram 55 | min_gram: 1 56 | max_gram: 15 57 | fullword_min: 58 | type: length 59 | # Remember to change this if you change the max_gram below! 60 | min: 16 61 | resolution: 62 | type: pattern_capture 63 | patterns: ["(\\d+)[xX](\\d+)"] 64 | trim_zero: 65 | type: pattern_capture 66 | patterns: ["0*([0-9]*)"] 67 | word_delimit: 68 | type: word_delimiter_graph 69 | preserve_original: true 70 | split_on_numerics: false 71 | # https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-word-delimiter-graph-tokenfilter.html#word-delimiter-graph-tokenfilter-configure-parms 72 | # since we're using "trim" filters downstream, otherwise 73 | # you get weird lucene errors about startOffset 74 | adjust_offsets: false 75 | char_filter: 76 | my_char_filter: 77 | type: mapping 78 | mappings: ["-=>_", "!=>_", "_=>\\u0020"] 79 | index: 80 | # we're running a single es node, so no sharding necessary, 81 | # plus replicas don't really help either. 82 | number_of_shards: 1 83 | number_of_replicas : 0 84 | query: 85 | default_field: display_name 86 | mappings: 87 | # disable elasticsearch's "helpful" autoschema 88 | dynamic: false 89 | properties: 90 | id: 91 | type: long 92 | display_name: 93 | # TODO could do a fancier tokenizer here to parse out the 94 | # the scene convention of stuff in brackets, plus stuff like k-on 95 | type: text 96 | analyzer: my_index_analyzer 97 | fielddata: true # Is this required? 98 | fields: 99 | # Multi-field for full-word matching (when going over ngram limits) 100 | # Note: will have to be queried for, not automatic 101 | fullword: 102 | type: text 103 | analyzer: my_fullword_index_analyzer 104 | # Stored for exact phrase matching 105 | exact: 106 | type: text 107 | analyzer: exact_analyzer 108 | created_time: 109 | type: date 110 | # 111 | # Only in the ES index for generating magnet links 112 | info_hash: 113 | type: keyword 114 | index: false 115 | filesize: 116 | type: long 117 | anonymous: 118 | type: boolean 119 | trusted: 120 | type: boolean 121 | remake: 122 | type: boolean 123 | complete: 124 | type: boolean 125 | hidden: 126 | type: boolean 127 | deleted: 128 | type: boolean 129 | has_torrent: 130 | type: boolean 131 | download_count: 132 | type: long 133 | leech_count: 134 | type: long 135 | seed_count: 136 | type: long 137 | comment_count: 138 | type: long 139 | # these ids are really only for filtering, thus keyword 140 | uploader_id: 141 | type: keyword 142 | main_category_id: 143 | type: keyword 144 | sub_category_id: 145 | type: keyword 146 | -------------------------------------------------------------------------------- /nyaa/templates/_formhelpers.html: -------------------------------------------------------------------------------- 1 | {% macro render_field(field, render_label=True) %} 2 | {% if field.errors %} 3 |
4 | {% else %} 5 |
6 | {% endif %} 7 | {% if render_label %} 8 | {{ field.label(class='control-label') }} 9 | {% endif %} 10 | {{ field(title=field.description,**kwargs) | safe }} 11 | {% if field.errors %} 12 |
13 | {% if field.errors|length < 2 %} 14 | {% for error in field.errors %} 15 | {{ error }} 16 | {% endfor %} 17 | {% else %} 18 |
    19 | {% for error in field.errors %} 20 |
  • {{ error }}
  • 21 | {% endfor %} 22 |
23 | {% endif %} 24 |
25 | {% endif %} 26 |
27 | {% endmacro %} 28 | 29 | 30 | {% macro render_markdown_editor(field, field_name='') %} 31 | {% if field.errors %} 32 |
33 | {% else %} 34 |
35 | {% endif %} 36 |
37 | {{ field.label(class='control-label') }} 38 | Markdown supported 39 | 51 |
52 |
53 | {# Render this field manually, because we need to escape the inner text #} 54 | 57 | {% if field.errors %} 58 |
59 | {% if field.errors|length < 2 %} 60 | {% for error in field.errors %} 61 | {{ error }} 62 | {% endfor %} 63 | {% else %} 64 |
    65 | {% for error in field.errors %} 66 |
  • {{ error }}
  • 67 | {% endfor %} 68 |
69 | {% endif %} 70 |
71 | {% endif %} 72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | {% endmacro %} 80 | 81 | 82 | {% macro render_upload(field) %} 83 | {% if field.errors %} 84 |
85 | {% else %} 86 |
87 | {% endif %} 88 | 89 |
90 | 93 | 94 |
95 |
96 | {{ field(title=field.description,**kwargs) | safe }} 97 |
98 | {% if field.errors %} 99 |
100 | {% if field.errors|length < 2 %} 101 | {% for error in field.errors %} 102 | {{ error }} 103 | {% endfor %} 104 | {% else %} 105 |
    106 | {% for error in field.errors %} 107 |
  • {{ error }}
  • 108 | {% endfor %} 109 |
110 | {% endif %} 111 |
112 | {% endif %} 113 |
114 | {% endmacro %} 115 | 116 | {% macro render_menu_with_button(field, button_label='Apply') %} 117 | {% if field.errors %} 118 |
119 | {% else %} 120 |
121 | {% endif %} 122 | {{ field.label(class='control-label') }} 123 |
124 | {{ field(title=field.description, class_="form-control",**kwargs) | safe }} 125 |
126 | 127 |
128 |
129 | {% if field.errors %} 130 |
131 | {% if field.errors|length < 2 %} 132 | {% for error in field.errors %} 133 | {{ error }} 134 | {% endfor %} 135 | {% else %} 136 |
    137 | {% for error in field.errors %} 138 |
  • {{ error }}
  • 139 | {% endfor %} 140 |
141 | {% endif %} 142 |
143 | {% endif %} 144 |
145 | {% endmacro %} 146 | -------------------------------------------------------------------------------- /nyaa/static/css/bootstrap-xl-mod.css: -------------------------------------------------------------------------------- 1 | /* 2 | * 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. 3 | * 4 | * Author: Marc van Nieuwenhuijzen 5 | * Company: WebVakman 6 | * Site: WebVakman.nl 7 | * 8 | * Edited to be for >=1480px with container width of 1400px for Nyaa.si 9 | * Also edited to not fuck up column gutters. 10 | */ 11 | 12 | @media (min-width: 1200px) and (max-width: 1479px) { 13 | .hidden-lg { 14 | display: none !important; 15 | } 16 | } 17 | 18 | 19 | .visible-xl-block, 20 | .visible-xl-inline, 21 | .visible-xl-inline-block, 22 | .visible-xl{ 23 | display: none !important; 24 | } 25 | 26 | 27 | 28 | @media (min-width: 1480px) { 29 | .container { 30 | width: 1400px; 31 | } 32 | 33 | .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 { 34 | position: relative; 35 | min-height: 1px; 36 | padding-right: 15px; 37 | padding-left: 15px; 38 | 39 | float: left; 40 | } 41 | 42 | .col-xl-12 { 43 | width: 100%; 44 | } 45 | 46 | .col-xl-11 { 47 | width: 91.66666667%; 48 | } 49 | 50 | .col-xl-10 { 51 | width: 83.33333333%; 52 | } 53 | 54 | .col-xl-9 { 55 | width: 75%; 56 | } 57 | 58 | .col-xl-8 { 59 | width: 66.66666667%; 60 | } 61 | 62 | .col-xl-7 { 63 | width: 58.33333333%; 64 | } 65 | 66 | .col-xl-6 { 67 | width: 50%; 68 | } 69 | 70 | .col-xl-5 { 71 | width: 41.66666667%; 72 | } 73 | 74 | .col-xl-4 { 75 | width: 33.33333333%; 76 | } 77 | 78 | .col-xl-3 { 79 | width: 25%; 80 | } 81 | 82 | .col-xl-2 { 83 | width: 16.66666667%; 84 | } 85 | 86 | .col-xl-1 { 87 | width: 8.33333333%; 88 | } 89 | 90 | .col-xl-pull-12 { 91 | right: 100%; 92 | } 93 | 94 | .col-xl-pull-11 { 95 | right: 91.66666667%; 96 | } 97 | 98 | .col-xl-pull-10 { 99 | right: 83.33333333%; 100 | } 101 | 102 | .col-xl-pull-9 { 103 | right: 75%; 104 | } 105 | 106 | .col-xl-pull-8 { 107 | right: 66.66666667%; 108 | } 109 | 110 | .col-xl-pull-7 { 111 | right: 58.33333333%; 112 | } 113 | 114 | .col-xl-pull-6 { 115 | right: 50%; 116 | } 117 | 118 | .col-xl-pull-5 { 119 | right: 41.66666667%; 120 | } 121 | 122 | .col-xl-pull-4 { 123 | right: 33.33333333%; 124 | } 125 | 126 | .col-xl-pull-3 { 127 | right: 25%; 128 | } 129 | 130 | .col-xl-pull-2 { 131 | right: 16.66666667%; 132 | } 133 | 134 | .col-xl-pull-1 { 135 | right: 8.33333333%; 136 | } 137 | 138 | .col-xl-pull-0 { 139 | right: auto; 140 | } 141 | 142 | .col-xl-push-12 { 143 | left: 100%; 144 | } 145 | 146 | .col-xl-push-11 { 147 | left: 91.66666667%; 148 | } 149 | 150 | .col-xl-push-10 { 151 | left: 83.33333333%; 152 | } 153 | 154 | .col-xl-push-9 { 155 | left: 75%; 156 | } 157 | 158 | .col-xl-push-8 { 159 | left: 66.66666667%; 160 | } 161 | 162 | .col-xl-push-7 { 163 | left: 58.33333333%; 164 | } 165 | 166 | .col-xl-push-6 { 167 | left: 50%; 168 | } 169 | 170 | .col-xl-push-5 { 171 | left: 41.66666667%; 172 | } 173 | 174 | .col-xl-push-4 { 175 | left: 33.33333333%; 176 | } 177 | 178 | .col-xl-push-3 { 179 | left: 25%; 180 | } 181 | 182 | .col-xl-push-2 { 183 | left: 16.66666667%; 184 | } 185 | 186 | .col-xl-push-1 { 187 | left: 8.33333333%; 188 | } 189 | 190 | .col-xl-push-0 { 191 | left: auto; 192 | } 193 | 194 | .col-xl-offset-12 { 195 | margin-left: 100%; 196 | } 197 | 198 | .col-xl-offset-11 { 199 | margin-left: 91.66666667%; 200 | } 201 | 202 | .col-xl-offset-10 { 203 | margin-left: 83.33333333%; 204 | } 205 | 206 | .col-xl-offset-9 { 207 | margin-left: 75%; 208 | } 209 | 210 | .col-xl-offset-8 { 211 | margin-left: 66.66666667%; 212 | } 213 | 214 | .col-xl-offset-7 { 215 | margin-left: 58.33333333%; 216 | } 217 | 218 | .col-xl-offset-6 { 219 | margin-left: 50%; 220 | } 221 | 222 | .col-xl-offset-5 { 223 | margin-left: 41.66666667%; 224 | } 225 | 226 | .col-xl-offset-4 { 227 | margin-left: 33.33333333%; 228 | } 229 | 230 | .col-xl-offset-3 { 231 | margin-left: 25%; 232 | } 233 | 234 | .col-xl-offset-2 { 235 | margin-left: 16.66666667%; 236 | } 237 | 238 | .col-xl-offset-1 { 239 | margin-left: 8.33333333%; 240 | } 241 | 242 | .col-xl-offset-0 { 243 | margin-left: 0; 244 | } 245 | 246 | .visible-xl { 247 | display: block !important; 248 | } 249 | 250 | table.visible-xl { 251 | display: table; 252 | } 253 | 254 | tr.visible-xl { 255 | display: table-row !important; 256 | } 257 | 258 | th.visible-xl, td.visible-xl { 259 | display: table-cell !important; 260 | } 261 | 262 | .visible-xl-block { 263 | display: block !important; 264 | } 265 | 266 | .visible-xl-inline { 267 | display: inline !important; 268 | } 269 | 270 | .visible-xl-inline-block { 271 | display: inline-block !important; 272 | } 273 | 274 | .hidden-xl { 275 | display: none !important; 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /nyaa/torrents.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import os 3 | from urllib.parse import quote, urlencode 4 | 5 | import flask 6 | from flask import current_app as app 7 | 8 | from orderedset import OrderedSet 9 | 10 | from nyaa import bencode 11 | 12 | USED_TRACKERS = OrderedSet() 13 | 14 | 15 | def read_trackers_from_file(file_object): 16 | USED_TRACKERS.clear() 17 | 18 | for line in file_object: 19 | line = line.strip() 20 | if line and not line.startswith('#'): 21 | USED_TRACKERS.add(line) 22 | return USED_TRACKERS 23 | 24 | 25 | def read_trackers(): 26 | tracker_list_file = os.path.join(app.config['BASE_DIR'], 'trackers.txt') 27 | 28 | if os.path.exists(tracker_list_file): 29 | with open(tracker_list_file, 'r') as in_file: 30 | return read_trackers_from_file(in_file) 31 | 32 | 33 | def default_trackers(): 34 | if not USED_TRACKERS: 35 | read_trackers() 36 | return USED_TRACKERS[:] 37 | 38 | 39 | def get_trackers_and_webseeds(torrent): 40 | trackers = OrderedSet() 41 | webseeds = OrderedSet() 42 | 43 | # Our main one first 44 | main_announce_url = app.config.get('MAIN_ANNOUNCE_URL') 45 | if main_announce_url: 46 | trackers.add(main_announce_url) 47 | 48 | # then the user ones 49 | torrent_trackers = torrent.trackers # here be webseeds too 50 | for torrent_tracker in torrent_trackers: 51 | tracker = torrent_tracker.tracker 52 | 53 | # separate potential webseeds 54 | if tracker.is_webseed: 55 | webseeds.add(tracker.uri) 56 | else: 57 | trackers.add(tracker.uri) 58 | 59 | # and finally our tracker list 60 | trackers.update(default_trackers()) 61 | 62 | return list(trackers), list(webseeds) 63 | 64 | 65 | def get_default_trackers(): 66 | trackers = OrderedSet() 67 | 68 | # Our main one first 69 | main_announce_url = app.config.get('MAIN_ANNOUNCE_URL') 70 | if main_announce_url: 71 | trackers.add(main_announce_url) 72 | 73 | # and finally our tracker list 74 | trackers.update(default_trackers()) 75 | 76 | return list(trackers) 77 | 78 | 79 | @functools.lru_cache(maxsize=1024*4) 80 | def _create_magnet(display_name, info_hash, max_trackers=5, trackers=None): 81 | # Unless specified, we just use default trackers 82 | if trackers is None: 83 | trackers = get_default_trackers() 84 | 85 | magnet_parts = [ 86 | ('dn', display_name) 87 | ] 88 | magnet_parts.extend( 89 | ('tr', tracker_url) 90 | for tracker_url in trackers[:max_trackers] 91 | ) 92 | 93 | return ''.join([ 94 | 'magnet:?xt=urn:btih:', info_hash, 95 | '&', urlencode(magnet_parts, quote_via=quote) 96 | ]) 97 | 98 | 99 | def create_magnet(torrent): 100 | # Since we accept both models.Torrents and ES objects, 101 | # we need to make sure the info_hash is a hex string 102 | info_hash = torrent.info_hash 103 | if isinstance(info_hash, (bytes, bytearray)): 104 | info_hash = info_hash.hex() 105 | 106 | return _create_magnet(torrent.display_name, info_hash) 107 | 108 | 109 | def create_default_metadata_base(torrent, trackers=None, webseeds=None): 110 | if trackers is None or webseeds is None: 111 | db_trackers, db_webseeds = get_trackers_and_webseeds(torrent) 112 | 113 | trackers = db_trackers if trackers is None else trackers 114 | webseeds = db_webseeds if webseeds is None else webseeds 115 | 116 | metadata_base = { 117 | 'created by': 'NyaaV2', 118 | 'creation date': int(torrent.created_utc_timestamp), 119 | 'comment': flask.url_for('torrents.view', 120 | torrent_id=torrent.id, 121 | _external=True) 122 | # 'encoding' : 'UTF-8' # It's almost always UTF-8 and expected, but if it isn't... 123 | } 124 | 125 | if len(trackers) > 0: 126 | metadata_base['announce'] = trackers[0] 127 | if len(trackers) > 1: 128 | # Yes, it's a list of lists with a single element inside. 129 | metadata_base['announce-list'] = [[tracker] for tracker in trackers] 130 | 131 | # Add webseeds 132 | if webseeds: 133 | metadata_base['url-list'] = webseeds 134 | 135 | return metadata_base 136 | 137 | 138 | def create_bencoded_torrent(torrent, bencoded_info, metadata_base=None): 139 | ''' Creates a bencoded torrent metadata for a given torrent, 140 | optionally using a given metadata_base dict (note: 'info' key will be 141 | popped off the dict) ''' 142 | if metadata_base is None: 143 | metadata_base = create_default_metadata_base(torrent) 144 | 145 | metadata_base['encoding'] = torrent.encoding 146 | 147 | # Make sure info doesn't exist on the base 148 | metadata_base.pop('info', None) 149 | prefixed_dict = {key: metadata_base[key] for key in metadata_base if key < 'info'} 150 | suffixed_dict = {key: metadata_base[key] for key in metadata_base if key > 'info'} 151 | 152 | prefix = bencode.encode(prefixed_dict) 153 | suffix = bencode.encode(suffixed_dict) 154 | 155 | bencoded_torrent = prefix[:-1] + b'4:info' + bencoded_info + suffix[1:] 156 | 157 | return bencoded_torrent 158 | -------------------------------------------------------------------------------- /import_to_es.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Bulk load torents from mysql into elasticsearch `nyaav2` index, 4 | which is assumed to already exist. 5 | This is a one-shot deal, so you'd either need to complement it 6 | with a cron job or some binlog-reading thing (TODO) 7 | """ 8 | import sys 9 | import json 10 | 11 | # This should be progressbar33 12 | import progressbar 13 | from elasticsearch import Elasticsearch 14 | from elasticsearch.client import IndicesClient 15 | from elasticsearch import helpers 16 | 17 | from nyaa import create_app, models 18 | from nyaa.extensions import db 19 | 20 | app = create_app('config') 21 | es = Elasticsearch(hosts=app.config['ES_HOSTS'], timeout=30) 22 | ic = IndicesClient(es) 23 | 24 | def pad_bytes(in_bytes, size): 25 | return in_bytes + (b'\x00' * max(0, size - len(in_bytes))) 26 | 27 | # turn into thing that elasticsearch indexes. We flatten in 28 | # the stats (seeders/leechers) so we can order by them in es naturally. 29 | # we _don't_ dereference uploader_id to the user's display name however, 30 | # instead doing that at query time. I _think_ this is right because 31 | # we don't want to reindex all the user's torrents just because they 32 | # changed their name, and we don't really want to FTS search on the user anyway. 33 | # Maybe it's more convenient to derefence though. 34 | def mk_es(t, index_name): 35 | return { 36 | "_id": t.id, 37 | "_index": index_name, 38 | "_source": { 39 | # we're also indexing the id as a number so you can 40 | # order by it. seems like this is just equivalent to 41 | # order by created_time, but oh well 42 | "id": t.id, 43 | "display_name": t.display_name, 44 | "created_time": t.created_time, 45 | # not analyzed but included so we can render magnet links 46 | # without querying sql again. 47 | "info_hash": pad_bytes(t.info_hash, 20).hex(), 48 | "filesize": t.filesize, 49 | "uploader_id": t.uploader_id, 50 | "main_category_id": t.main_category_id, 51 | "sub_category_id": t.sub_category_id, 52 | "comment_count": t.comment_count, 53 | # XXX all the bitflags are numbers 54 | "anonymous": bool(t.anonymous), 55 | "trusted": bool(t.trusted), 56 | "remake": bool(t.remake), 57 | "complete": bool(t.complete), 58 | # TODO instead of indexing and filtering later 59 | # could delete from es entirely. Probably won't matter 60 | # for at least a few months. 61 | "hidden": bool(t.hidden), 62 | "deleted": bool(t.deleted), 63 | "has_torrent": t.has_torrent, 64 | # Stats 65 | "download_count": t.stats.download_count, 66 | "leech_count": t.stats.leech_count, 67 | "seed_count": t.stats.seed_count, 68 | } 69 | } 70 | 71 | # page through an sqlalchemy query, like the per_fetch but 72 | # doesn't break the eager joins its doing against the stats table. 73 | # annoying that this isn't built in somehow. 74 | def page_query(query, limit=sys.maxsize, batch_size=10000, progress_bar=None): 75 | start = 0 76 | while True: 77 | # XXX very inelegant way to do this, i'm confus 78 | stop = min(limit, start + batch_size) 79 | if stop == start: 80 | break 81 | things = query.slice(start, stop) 82 | if not things: 83 | break 84 | had_things = False 85 | for thing in things: 86 | had_things = True 87 | yield(thing) 88 | if not had_things or stop == limit: 89 | break 90 | if progress_bar: 91 | progress_bar.update(start) 92 | start = min(limit, start + batch_size) 93 | 94 | FLAVORS = [ 95 | ('nyaa', models.NyaaTorrent), 96 | ('sukebei', models.SukebeiTorrent) 97 | ] 98 | 99 | # Get binlog status from mysql 100 | with app.app_context(): 101 | master_status = db.engine.execute('SHOW MASTER STATUS;').fetchone() 102 | 103 | position_json = { 104 | 'log_file': master_status[0], 105 | 'log_pos': master_status[1] 106 | } 107 | 108 | print('Save the following in the file configured in your ES sync config JSON:') 109 | print(json.dumps(position_json)) 110 | 111 | for flavor, torrent_class in FLAVORS: 112 | print('Importing torrents for index', flavor, 'from', torrent_class) 113 | bar = progressbar.ProgressBar( 114 | maxval=torrent_class.query.count(), 115 | widgets=[ progressbar.SimpleProgress(), 116 | ' [', progressbar.Timer(), '] ', 117 | progressbar.Bar(), 118 | ' (', progressbar.ETA(), ') ', 119 | ]) 120 | 121 | # turn off refreshes while bulk loading 122 | ic.put_settings(body={'index': {'refresh_interval': '-1'}}, index=flavor) 123 | 124 | bar.start() 125 | helpers.bulk(es, (mk_es(t, flavor) for t in page_query(torrent_class.query, progress_bar=bar)), chunk_size=10000) 126 | bar.finish() 127 | 128 | # Refresh the index immideately 129 | ic.refresh(index=flavor) 130 | print('Index refresh done.') 131 | 132 | # restore to near-enough real time 133 | ic.put_settings(body={'index': {'refresh_interval': '30s'}}, index=flavor) 134 | -------------------------------------------------------------------------------- /nyaa/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import string 4 | 5 | import flask 6 | from flask_assets import Bundle # noqa F401 7 | 8 | from nyaa.api_handler import api_blueprint 9 | from nyaa.extensions import assets, cache, db, fix_paginate, limiter, toolbar 10 | from nyaa.template_utils import bp as template_utils_bp 11 | from nyaa.template_utils import caching_url_for 12 | from nyaa.utils import random_string 13 | from nyaa.views import register_views 14 | 15 | # Replace the Flask url_for with our cached version, since there's no real harm in doing so 16 | # (caching_url_for has stored a reference to the OG url_for, so we won't recurse) 17 | # Touching globals like this is a bit dirty, but nicer than replacing every url_for usage 18 | flask.url_for = caching_url_for 19 | 20 | 21 | def create_app(config): 22 | """ Nyaa app factory """ 23 | app = flask.Flask(__name__) 24 | app.config.from_object(config) 25 | 26 | # Don't refresh cookie each request 27 | app.config['SESSION_REFRESH_EACH_REQUEST'] = False 28 | 29 | # Debugging 30 | if app.config['DEBUG']: 31 | app.config['DEBUG_TB_INTERCEPT_REDIRECTS'] = False 32 | toolbar.init_app(app) 33 | app.logger.setLevel(logging.DEBUG) 34 | 35 | # Forbid caching 36 | @app.after_request 37 | def forbid_cache(request): 38 | request.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0' 39 | request.headers['Pragma'] = 'no-cache' 40 | request.headers['Expires'] = '0' 41 | return request 42 | 43 | # Add a timer header to the requests when debugging 44 | # This gives us a simple way to benchmark requests off-app 45 | import time 46 | 47 | @app.before_request 48 | def timer_before_request(): 49 | flask.g.request_start_time = time.time() 50 | 51 | @app.after_request 52 | def timer_after_request(request): 53 | request.headers['X-Timer'] = time.time() - flask.g.request_start_time 54 | return request 55 | 56 | else: 57 | app.logger.setLevel(logging.WARNING) 58 | 59 | # Logging 60 | if 'LOG_FILE' in app.config: 61 | from logging.handlers import RotatingFileHandler 62 | app.log_handler = RotatingFileHandler( 63 | app.config['LOG_FILE'], maxBytes=10000, backupCount=1) 64 | app.logger.addHandler(app.log_handler) 65 | 66 | # Log errors and display a message to the user in production mdode 67 | if not app.config['DEBUG']: 68 | @app.errorhandler(500) 69 | def internal_error(exception): 70 | random_id = random_string(8, string.ascii_uppercase + string.digits) 71 | # Pst. Not actually unique, but don't tell anyone! 72 | app.logger.error('Exception occurred! Unique ID: %s', random_id, exc_info=exception) 73 | markup_source = ' '.join([ 74 | 'An error occurred!', 75 | 'Debug information has been logged.', 76 | 'Please pass along this ID: {}'.format(random_id) 77 | ]) 78 | 79 | flask.flash(flask.Markup(markup_source), 'danger') 80 | return flask.redirect(flask.url_for('main.home')) 81 | 82 | # Get git commit hash 83 | app.config['COMMIT_HASH'] = None 84 | master_head = os.path.abspath(os.path.join(os.path.dirname(__file__), 85 | '../.git/refs/heads/master')) 86 | if os.path.isfile(master_head): 87 | with open(master_head, 'r') as head: 88 | app.config['COMMIT_HASH'] = head.readline().strip() 89 | 90 | # Enable the jinja2 do extension. 91 | app.jinja_env.add_extension('jinja2.ext.do') 92 | app.jinja_env.lstrip_blocks = True 93 | app.jinja_env.trim_blocks = True 94 | 95 | # The default jinja_env has the OG Flask url_for (from before we replaced it), 96 | # so update the globals with our version 97 | app.jinja_env.globals['url_for'] = flask.url_for 98 | 99 | # Database 100 | fix_paginate() # This has to be before the database is initialized 101 | app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False 102 | app.config['MYSQL_DATABASE_CHARSET'] = 'utf8mb4' 103 | db.init_app(app) 104 | 105 | # Assets 106 | assets.init_app(app) 107 | assets._named_bundles = {} # Hack to fix state carrying over in tests 108 | main_js = Bundle('js/main.js', filters='rjsmin', output='js/main.min.js') 109 | bs_js = Bundle('js/bootstrap-select.js', filters='rjsmin', 110 | output='js/bootstrap-select.min.js') 111 | assets.register('main_js', main_js) 112 | assets.register('bs_js', bs_js) 113 | # css = Bundle('style.scss', filters='libsass', 114 | # output='style.css', depends='**/*.scss') 115 | # assets.register('style_all', css) 116 | 117 | # Blueprints 118 | app.register_blueprint(template_utils_bp) 119 | app.register_blueprint(api_blueprint) 120 | register_views(app) 121 | 122 | # Pregenerate some URLs to avoid repeat url_for calls 123 | if 'SERVER_NAME' in app.config and app.config['SERVER_NAME']: 124 | with app.app_context(): 125 | url = flask.url_for('static', filename='img/avatar/default.png', _external=True) 126 | app.config['DEFAULT_GRAVATAR_URL'] = url 127 | 128 | # Cache 129 | cache.init_app(app, config=app.config) 130 | 131 | # Rate Limiting, reads app.config itself 132 | limiter.init_app(app) 133 | 134 | return app 135 | -------------------------------------------------------------------------------- /nyaa/templates/upload.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}Upload Torrent :: {{ config.SITE_NAME }}{% endblock %} 3 | {% block metatags %} 4 | 5 | {% endblock %} 6 | {% block body %} 7 | {% from "_formhelpers.html" import render_field %} 8 | {% from "_formhelpers.html" import render_upload %} 9 | {% from "_formhelpers.html" import render_markdown_editor %} 10 | 11 |

Upload Torrent

12 | {% if not g.user %} 13 |

You are not logged in, and are uploading anonymously.

14 | {% endif %} 15 | 16 |
Drop here!
17 |
18 | {{ upload_form.csrf_token }} 19 | 20 | {% if config.ENFORCE_MAIN_ANNOUNCE_URL %}

Important: Please include {{ config.MAIN_ANNOUNCE_URL }} in your trackers.

{% endif %} 21 |

Important: Make sure you have read the rules before uploading!

22 |
23 | 24 | {% if show_ratelimit %} 25 | {% set ratelimit_class = 'danger' if upload_form.ratelimit.errors else 'warning' %} 26 |
27 |
28 | 36 |
37 |
38 | {% endif %} 39 | 40 | {% if upload_form.rangebanned.errors %} 41 |
42 |
43 | 48 |
49 |
50 | {% endif %} 51 | 52 |
53 |
54 | {{ render_upload(upload_form.torrent_file, accept=".torrent") }} 55 |
56 |
57 |
58 |
59 | {{ render_field(upload_form.display_name, class_='form-control', placeholder='Display name') }} 60 |
61 |
62 | {{ render_field(upload_form.category, class_='form-control')}} 63 |
64 |
65 |
66 |
67 | {{ render_field(upload_form.information, class_='form-control', placeholder='Your website or IRC channel') }} 68 |
69 |
70 |
71 | 72 |
73 | 80 | 86 |
87 | 88 |
89 | 95 | 101 | {% if g.user.is_trusted %} 102 | 108 | {% endif %} 109 |
110 |
111 |
112 |
113 |
114 | {{ render_markdown_editor(upload_form.description, field_name='description') }} 115 |
116 |
117 | 118 | {% if config.USE_RECAPTCHA and (not g.user or g.user.age < config['ACCOUNT_RECAPTCHA_AGE']) %} 119 |
120 |
121 | {% for error in upload_form.recaptcha.errors %} 122 | {{ error }} 123 | {% endfor %} 124 | {{ upload_form.recaptcha }} 125 |
126 |
127 | {% endif %} 128 | 129 |
130 |
131 |
132 | 133 |
134 |
135 |
136 | {% endblock %} 137 | -------------------------------------------------------------------------------- /nyaa/template_utils.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import os.path 3 | import re 4 | from datetime import datetime 5 | from email.utils import formatdate 6 | 7 | import flask 8 | from werkzeug.urls import url_encode 9 | 10 | from nyaa.backend import get_category_id_map 11 | from nyaa.torrents import create_magnet 12 | 13 | app = flask.current_app 14 | bp = flask.Blueprint('template-utils', __name__) 15 | _static_cache = {} # For static_cachebuster 16 | 17 | 18 | # ######################## CONTEXT PROCESSORS ######################## 19 | 20 | # For processing ES links 21 | @bp.app_context_processor 22 | def create_magnet_from_es_torrent(): 23 | # Since ES entries look like ducks, we can use the create_magnet as-is 24 | return dict(create_magnet_from_es_torrent=create_magnet) 25 | 26 | 27 | # ######################### TEMPLATE GLOBALS ######################### 28 | 29 | flask_url_for = flask.url_for 30 | 31 | 32 | @functools.lru_cache(maxsize=1024 * 4) 33 | def _caching_url_for(endpoint, **values): 34 | return flask_url_for(endpoint, **values) 35 | 36 | 37 | @bp.app_template_global() 38 | def caching_url_for(*args, **kwargs): 39 | try: 40 | # lru_cache requires the arguments to be hashable. 41 | # Majority of the time, they are! But there are some small edge-cases, 42 | # like our copypasted pagination, parameters can be lists. 43 | # Attempt caching first: 44 | return _caching_url_for(*args, **kwargs) 45 | except TypeError: 46 | # Then fall back to the original url_for. 47 | # We could convert the lists to tuples, but the savings are marginal. 48 | return flask_url_for(*args, **kwargs) 49 | 50 | 51 | @bp.app_template_global() 52 | def static_cachebuster(filename): 53 | """ Adds a ?t= cachebuster to the given path, if the file exists. 54 | Results are cached in memory and persist until app restart! """ 55 | # Instead of timestamps, we could use commit hashes (we already load it in __init__) 56 | # But that'd mean every static resource would get cache busted. This lets unchanged items 57 | # stay in the cache. 58 | 59 | if app.debug: 60 | # Do not bust cache on debug (helps debugging) 61 | return flask.url_for('static', filename=filename) 62 | 63 | # Get file mtime if not already cached. 64 | if filename not in _static_cache: 65 | file_path = os.path.join(app.static_folder, filename) 66 | file_mtime = None 67 | if os.path.exists(file_path): 68 | file_mtime = int(os.path.getmtime(file_path)) 69 | 70 | _static_cache[filename] = file_mtime 71 | 72 | return flask.url_for('static', filename=filename, t=_static_cache[filename]) 73 | 74 | 75 | @bp.app_template_global() 76 | def modify_query(**new_values): 77 | args = flask.request.args.copy() 78 | 79 | args.pop('p', None) 80 | 81 | for key, value in new_values.items(): 82 | args[key] = value 83 | 84 | return '{}?{}'.format(flask.request.path, url_encode(args)) 85 | 86 | 87 | @bp.app_template_global() 88 | def filter_truthy(input_list): 89 | """ Jinja2 can't into list comprehension so this is for 90 | the search_results.html template """ 91 | return [item for item in input_list if item] 92 | 93 | 94 | @bp.app_template_global() 95 | def category_name(cat_id): 96 | """ Given a category id (eg. 1_2), returns a category name (eg. Anime - English-translated) """ 97 | return ' - '.join(get_category_id_map().get(cat_id, ['???'])) 98 | 99 | 100 | # ######################### TEMPLATE FILTERS ######################### 101 | 102 | @bp.app_template_filter('utc_time') 103 | def get_utc_timestamp(datetime_str): 104 | """ Returns a UTC POSIX timestamp, as seconds """ 105 | UTC_EPOCH = datetime.utcfromtimestamp(0) 106 | return int((datetime.strptime(datetime_str, '%Y-%m-%dT%H:%M:%S') - UTC_EPOCH).total_seconds()) 107 | 108 | 109 | @bp.app_template_filter('utc_timestamp') 110 | def get_utc_timestamp_seconds(datetime_instance): 111 | """ Returns a UTC POSIX timestamp, as seconds """ 112 | UTC_EPOCH = datetime.utcfromtimestamp(0) 113 | return int((datetime_instance - UTC_EPOCH).total_seconds()) 114 | 115 | 116 | @bp.app_template_filter('display_time') 117 | def get_display_time(datetime_str): 118 | return datetime.strptime(datetime_str, '%Y-%m-%dT%H:%M:%S').strftime('%Y-%m-%d %H:%M') 119 | 120 | 121 | @bp.app_template_filter('rfc822') 122 | def _jinja2_filter_rfc822(date, fmt=None): 123 | return formatdate(date.timestamp()) 124 | 125 | 126 | @bp.app_template_filter('rfc822_es') 127 | def _jinja2_filter_rfc822_es(datestr, fmt=None): 128 | return formatdate(datetime.strptime(datestr, '%Y-%m-%dT%H:%M:%S').timestamp()) 129 | 130 | 131 | @bp.app_template_filter() 132 | def timesince(dt, default='just now'): 133 | """ 134 | Returns string representing "time since" e.g. 135 | 3 minutes ago, 5 hours ago etc. 136 | Date and time (UTC) are returned if older than 1 day. 137 | """ 138 | 139 | now = datetime.utcnow() 140 | diff = now - dt 141 | 142 | periods = ( 143 | (diff.days, 'day', 'days'), 144 | (diff.seconds / 3600, 'hour', 'hours'), 145 | (diff.seconds / 60, 'minute', 'minutes'), 146 | (diff.seconds, 'second', 'seconds'), 147 | ) 148 | 149 | if diff.days >= 1: 150 | return dt.strftime('%Y-%m-%d %H:%M UTC') 151 | else: 152 | for period, singular, plural in periods: 153 | 154 | if period >= 1: 155 | return '%d %s ago' % (period, singular if int(period) == 1 else plural) 156 | 157 | return default 158 | 159 | 160 | @bp.app_template_filter() 161 | def regex_replace(s, find, replace): 162 | """A non-optimal implementation of a regex filter""" 163 | return re.sub(find, replace, s) 164 | -------------------------------------------------------------------------------- /dev.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | This tool is designed to assist developers run common tasks, such as 5 | checking the code for lint issues, auto fixing some lint issues and running tests. 6 | It imports modules lazily (as-needed basis), so it runs faster! 7 | """ 8 | import sys 9 | 10 | LINT_PATHS = [ 11 | 'nyaa/', 12 | 'utils/', 13 | ] 14 | TEST_PATHS = ['tests'] 15 | 16 | 17 | def print_cmd(cmd, args): 18 | """ Prints the command and args as you would run them manually. """ 19 | print('Running: {0}\n'.format( 20 | ' '.join([('\'' + a + '\'' if ' ' in a else a) for a in [cmd] + args]))) 21 | sys.stdout.flush() # Make sure stdout is flushed before continuing. 22 | 23 | 24 | def check_config_values(): 25 | """ Verify that all max_line_length values match. """ 26 | import configparser 27 | config = configparser.ConfigParser() 28 | config.read('setup.cfg') 29 | 30 | # Max line length: 31 | flake8 = config.get('flake8', 'max_line_length', fallback=None) 32 | autopep8 = config.get('pycodestyle', 'max_line_length', fallback=None) 33 | isort = config.get('isort', 'line_length', fallback=None) 34 | 35 | values = (v for v in (flake8, autopep8, isort) if v is not None) 36 | found = next(values, False) 37 | if not found: 38 | print('Warning: No max line length setting set in setup.cfg.') 39 | return False 40 | elif any(v != found for v in values): 41 | print('Warning: Max line length settings differ in setup.cfg.') 42 | return False 43 | 44 | return True 45 | 46 | 47 | def print_help(): 48 | print('Nyaa Development Helper') 49 | print('=======================\n') 50 | print('Usage: {0} command [different arguments]'.format(sys.argv[0])) 51 | print('Command can be one of the following:\n') 52 | print(' lint | check : do a lint check (flake8 + flake8-isort)') 53 | print(' fix | autolint : try and auto-fix lint (autopep8)') 54 | print(' isort : fix import sorting (isort)') 55 | print(' test | pytest : run tests (pytest)') 56 | print(' help | -h | --help : show this help and exit') 57 | print('') 58 | print('You may pass different arguments to the script that is being run.') 59 | print('For example: {0} test tests/ --verbose'.format(sys.argv[0])) 60 | print('') 61 | return 1 62 | 63 | 64 | if __name__ == '__main__': 65 | assert sys.version_info >= (3, 6), "Python 3.6 is required" 66 | 67 | check_config_values() 68 | 69 | if len(sys.argv) < 2: 70 | sys.exit(print_help()) 71 | 72 | cmd = sys.argv[1].lower() 73 | if cmd in ('help', '-h', '--help'): 74 | sys.exit(print_help()) 75 | 76 | args = sys.argv[2:] 77 | run_default = not (args or set(('--version', '-h', '--help')).intersection(args)) 78 | 79 | # Flake8 - lint and common errors checker 80 | # When combined with flake8-isort, also checks for unsorted imports. 81 | if cmd in ('lint', 'check'): 82 | if run_default: 83 | # Putting format in the setup.cfg file breaks `pip install flake8` 84 | settings = ['--format', '%(path)s [%(row)s:%(col)s] %(code)s: %(text)s', 85 | '--show-source'] 86 | args = LINT_PATHS + settings + args 87 | 88 | print_cmd('flake8', args) 89 | try: 90 | from flake8.main.application import Application as Flake8 91 | except ImportError as err: 92 | print('Unable to load module: {0!r}'.format(err)) 93 | result = False 94 | else: 95 | f8 = Flake8() 96 | f8.run(args) 97 | result = f8.result_count == 0 98 | 99 | if not result: 100 | print("The code requires some changes.") 101 | else: 102 | print("Looks good!") 103 | finally: 104 | sys.exit(int(not result)) 105 | 106 | # AutoPEP8 - auto code linter for most simple errors. 107 | if cmd in ('fix', 'autolint'): 108 | if run_default: 109 | args = LINT_PATHS + args 110 | 111 | print_cmd('autopep8', args) 112 | try: 113 | from autopep8 import main as autopep8 114 | except ImportError as err: 115 | print('Unable to load module: {0!r}'.format(err)) 116 | result = False 117 | else: 118 | args = [''] + args # Workaround 119 | result = autopep8(args) 120 | finally: 121 | sys.exit(result) 122 | 123 | # isort - automate import sorting. 124 | if cmd in ('isort', ): 125 | if run_default: 126 | args = LINT_PATHS + ['-rc'] + args 127 | 128 | print_cmd('isort', args) 129 | try: 130 | from isort.main import main as isort 131 | except ImportError as err: 132 | print('Unable to load module: {0!r}'.format(err)) 133 | result = False 134 | else: 135 | # Need to patch sys.argv for argparse in isort 136 | sys.argv.remove(cmd) 137 | sys.argv = [sys.argv[0] + ' ' + cmd] + args 138 | result = isort() 139 | finally: 140 | sys.exit(result) 141 | 142 | # py.test - test runner 143 | if cmd in ('test', 'pytest'): 144 | if run_default: 145 | args = TEST_PATHS + args 146 | 147 | print_cmd('pytest', args) 148 | try: 149 | from pytest import main as pytest 150 | except ImportError as err: 151 | print('Unable to load module: {0!r}'.format(err)) 152 | result = False 153 | else: 154 | result = pytest(args) 155 | result = result == 0 156 | finally: 157 | sys.exit(int(not result)) 158 | 159 | sys.exit(print_help()) 160 | -------------------------------------------------------------------------------- /nyaa/bencode.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | 3 | 4 | def _pairwise(iterable): 5 | """ Returns items from an iterable two at a time, ala 6 | [0, 1, 2, 3, ...] -> [(0, 1), (2, 3), ...] """ 7 | iterable = iter(iterable) 8 | return zip(iterable, iterable) 9 | 10 | 11 | __all__ = ['encode', 'decode', 'BencodeException', 'MalformedBencodeException'] 12 | 13 | # https://wiki.theory.org/BitTorrentSpecification#Bencoding 14 | 15 | 16 | class BencodeException(Exception): 17 | pass 18 | 19 | 20 | class MalformedBencodeException(BencodeException): 21 | pass 22 | 23 | 24 | # bencode types 25 | _DIGITS = b'0123456789' 26 | _B_INT = b'i' 27 | _B_LIST = b'l' 28 | _B_DICT = b'd' 29 | _B_END = b'e' 30 | 31 | 32 | # Decoding of bencoded data 33 | 34 | def _bencode_decode(file_object, decode_keys_as_utf8=True): 35 | """ Decodes a bencoded value, raising a MalformedBencodeException on errors. 36 | decode_keys_as_utf8 controls decoding dict keys as utf8 (which they 37 | almost always are) """ 38 | if isinstance(file_object, str): 39 | file_object = file_object.encode('utf8') 40 | if isinstance(file_object, bytes): 41 | file_object = BytesIO(file_object) 42 | 43 | def create_ex(msg): 44 | return MalformedBencodeException( 45 | '{0} at position {1} (0x{1:02X} hex)'.format(msg, file_object.tell())) 46 | 47 | def _read_list(): 48 | """ Decodes values from stream until a None is returned ('e') """ 49 | items = [] 50 | while True: 51 | value = _bencode_decode(file_object, decode_keys_as_utf8=decode_keys_as_utf8) 52 | if value is None: 53 | break 54 | items.append(value) 55 | return items 56 | 57 | kind = file_object.read(1) 58 | if not kind: 59 | raise create_ex('EOF, expecting kind') 60 | 61 | if kind == _B_INT: # Integer 62 | int_bytes = b'' 63 | while True: 64 | c = file_object.read(1) 65 | if not c: 66 | raise create_ex('EOF, expecting more integer') 67 | elif c == _B_END: 68 | try: 69 | return int(int_bytes.decode('utf8')) 70 | except Exception: 71 | raise create_ex('Unable to parse int') 72 | 73 | # not a digit OR '-' in the middle of the int 74 | if (c not in _DIGITS + b'-') or (c == b'-' and int_bytes): 75 | raise create_ex('Unexpected input while reading an integer: ' + repr(c)) 76 | else: 77 | int_bytes += c 78 | 79 | elif kind == _B_LIST: # List 80 | return _read_list() 81 | 82 | elif kind == _B_DICT: # Dictionary 83 | keys_and_values = _read_list() 84 | if len(keys_and_values) % 2 != 0: 85 | raise MalformedBencodeException('Uneven amount of key/value pairs') 86 | 87 | # "Technically" the bencode dictionary keys are bytestrings, 88 | # but real-world they're always(?) UTF-8. 89 | decoded_dict = dict((decode_keys_as_utf8 and k.decode('utf8') or k, v) 90 | for k, v in _pairwise(keys_and_values)) 91 | return decoded_dict 92 | 93 | # List/dict end, but make sure input is not just 'e' 94 | elif kind == _B_END and file_object.tell() > 0: 95 | return None 96 | 97 | elif kind in _DIGITS: # Bytestring 98 | str_len_bytes = kind # keep first digit 99 | # Read string length until a ':' 100 | while True: 101 | c = file_object.read(1) 102 | if not c: 103 | raise create_ex('EOF, expecting more string len') 104 | if c in _DIGITS: 105 | str_len_bytes += c 106 | elif c == b':': 107 | break 108 | else: 109 | raise create_ex('Unexpected input while reading string length: ' + repr(c)) 110 | try: 111 | str_len = int(str_len_bytes.decode()) 112 | except Exception: 113 | raise create_ex('Unable to parse bytestring length') 114 | 115 | bytestring = file_object.read(str_len) 116 | if len(bytestring) != str_len: 117 | raise create_ex('Read only {} bytes, {} wanted'.format(len(bytestring), str_len)) 118 | 119 | return bytestring 120 | else: 121 | raise create_ex('Unexpected data type ({})'.format(repr(kind))) 122 | 123 | 124 | # Bencoding 125 | 126 | def _bencode_int(value): 127 | """ Encode an integer, eg 64 -> i64e """ 128 | return _B_INT + str(value).encode('utf8') + _B_END 129 | 130 | 131 | def _bencode_bytes(value): 132 | """ Encode a bytestring (strings as UTF-8), eg 'hello' -> 5:hello """ 133 | if isinstance(value, str): 134 | value = value.encode('utf8') 135 | return str(len(value)).encode('utf8') + b':' + value 136 | 137 | 138 | def _bencode_list(value): 139 | """ Encode a list, eg [64, "hello"] -> li64e5:helloe """ 140 | return _B_LIST + b''.join(_bencode(item) for item in value) + _B_END 141 | 142 | 143 | def _bencode_dict(value): 144 | """ Encode a dict, which is keys and values interleaved as a list, 145 | eg {"hello":123}-> d5:helloi123ee """ 146 | dict_keys = sorted(value.keys()) # Sort keys as per spec 147 | return _B_DICT + b''.join( 148 | _bencode_bytes(key) + _bencode(value[key]) for key in dict_keys) + _B_END 149 | 150 | 151 | def _bencode(value): 152 | """ Bencode any supported value (int, bytes, str, list, dict) """ 153 | if isinstance(value, int): 154 | return _bencode_int(value) 155 | elif isinstance(value, (str, bytes)): 156 | return _bencode_bytes(value) 157 | elif isinstance(value, list): 158 | return _bencode_list(value) 159 | elif isinstance(value, dict): 160 | return _bencode_dict(value) 161 | 162 | raise BencodeException('Unsupported type ' + str(type(value))) 163 | 164 | 165 | # The functions call themselves 166 | encode = _bencode 167 | decode = _bencode_decode 168 | -------------------------------------------------------------------------------- /nyaa/templates/user.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block title %}{{ user.username }} :: {{ config.SITE_NAME }}{% endblock %} 3 | {% block meta_image %}{{ user.gravatar_url() }}{% endblock %} 4 | {% block metatags %} 5 | {% if search.term %} 6 | 7 | {% else %} 8 | 9 | {% endif %} 10 | {% endblock %} 11 | 12 | {% block body %} 13 | {% from "_formhelpers.html" import render_menu_with_button %} 14 | {% from "_formhelpers.html" import render_field %} 15 | 16 | {% if g.user and g.user.is_moderator %} 17 |

User Information


18 |
19 |
20 | 21 | View all comments 22 |
23 |
24 |
25 |
26 |
27 |
User ID:
28 |
{{ user.id }}
29 |
Account created on:
30 |
{{ user.created_time.strftime('%Y-%m-%d %H:%M UTC') }}
31 |
Email address:
32 |
{{ user.email }}
33 |
User class:
34 |
{{ user.userlevel_str }}
35 |
User status:
36 |
{{ user.userstatus_str }} 37 | {%- if g.user.is_superadmin -%} 38 |
Last login IP:
39 |
{{ user.ip_string }}
40 |
Registration IP:
41 |
{{ user.reg_ip_string }}
42 | {%- endif -%} 43 |
44 |
45 |
46 | {% if admin_form %} 47 |
48 | {{ admin_form.csrf_token }} 49 |
50 |
51 | {{ render_menu_with_button(admin_form.user_class) }} 52 |
53 |
54 | {% if not user.is_active %} 55 |
56 |
57 | {{ admin_form.activate_user(class="btn btn-primary") }} 58 |
59 |
60 | {% endif %} 61 |
62 |
63 | {% endif %} 64 |
65 | {% if ban_form %} 66 |
67 |
68 |
69 |

Danger Zone

70 |
71 |
72 |
73 |
74 |
75 | {{ ban_form.csrf_token }} 76 | {% if user.is_banned %} 77 | This user is banned. 78 | {% endif %} 79 | {% if ipbanned %} 80 | This user is ip banned. 81 | {% endif %} 82 | {% if not user.is_banned and not bans %} 83 | This user is not banned. 84 | {% endif %} 85 |
86 |
87 | {% if user.is_banned or bans %} 88 |
89 |
90 |
    91 | {% for ban in bans %} 92 |
  • #{{ ban.id }} 93 | by {{ ban.admin.username }} 94 | for {{ ban.reason }}
  • 95 | {% endfor %} 96 |
97 |
98 |
99 |
100 |
101 | {{ ban_form.unban(class="btn btn-info") }} 102 |
103 |
104 | {% endif %} 105 | 106 | {% if not user.is_banned or not ipbanned %} 107 | {% if user.is_banned or bans %} 108 |
109 | {% endif %} 110 |
111 |
112 | {{ render_field(ban_form.reason, class_="form-control", placeholder="Specify a ban reason.") }}
113 |
114 |
115 |
116 |
117 | {% if not user.is_banned %} 118 | {{ ban_form.ban_user(value="Ban User", class="btn btn-danger") }} 119 | {% else %} 120 | 121 | {% endif %} 122 |
123 |
124 | {% if not ipbanned %} 125 | {{ ban_form.ban_userip(value="Ban User+IP", class="btn btn-danger") }} 126 | {% else %} 127 | 128 | {% endif %} 129 |
130 |
131 | {% endif %} 132 |
133 | {% if g.user.is_superadmin %} 134 |
135 |
136 | {{ nuke_form.csrf_token }} 137 |
138 |
139 | {{ nuke_form.nuke_torrents(class="btn btn-danger", formaction=url_for('users.nuke_user_torrents', user_name=user.username)) }} 140 |
141 |
142 | {{ nuke_form.nuke_comments(class="btn btn-danger", formaction=url_for('users.nuke_user_comments', user_name=user.username)) }} 143 |
144 |
145 |
146 | {% endif %} 147 |
148 |
149 |
150 | {% endif %} 151 |
152 | {% endif %} 153 | 154 |
155 |

156 | Browsing {{ user.username }}'{{ '' if user.username[-1] == 's' else 's' }} torrents 157 | {% if torrent_query.actual_count is number and not search.term: %} 158 | ({{ torrent_query.actual_count }}) 159 | {% endif %} 160 |

161 | 162 | {% include "search_results.html" %} 163 | 164 | {% endblock %} 165 |
166 | --------------------------------------------------------------------------------