mirror of
https://github.com/ProjectSynthoria/SynthoriaArchive.git
synced 2025-03-11 15:06:55 +02:00
Updated for NyaaV3
This commit is contained in:
parent
4fe0ff5b1a
commit
f3031cd480
39 changed files with 752 additions and 577 deletions
|
@ -1,4 +1,6 @@
|
|||
# Nyaa on Docker
|
||||
> [!CAUTION]
|
||||
> Docker deployment is out of date and currently unsupported in NyaaV3.
|
||||
|
||||
Docker infrastructure is provided to ease setting up a dev environment
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
"mysql_port": 3306,
|
||||
"mysql_user": "nyaadev",
|
||||
"mysql_password": "ZmtB2oihHFvc39JaEDoF",
|
||||
"database": "nyaav2",
|
||||
"database": "nyaav3",
|
||||
"internal_queue_depth": 10000,
|
||||
"es_chunk_size": 10000,
|
||||
"flush_interval": 5
|
||||
|
|
|
@ -48,7 +48,7 @@ services:
|
|||
- MYSQL_RANDOM_ROOT_PASSWORD=yes
|
||||
- MYSQL_USER=nyaadev
|
||||
- MYSQL_PASSWORD=ZmtB2oihHFvc39JaEDoF
|
||||
- MYSQL_DATABASE=nyaav2
|
||||
- MYSQL_DATABASE=nyaav3
|
||||
|
||||
elasticsearch:
|
||||
image: elasticsearch:6.5.4
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
SITE_NAME = 'Nyaa [DEVEL]'
|
||||
GLOBAL_SITE_NAME = 'nyaa.devel'
|
||||
SQLALCHEMY_DATABASE_URI = ('mysql://nyaadev:ZmtB2oihHFvc39JaEDoF@mariadb/nyaav2?charset=utf8mb4')
|
||||
SQLALCHEMY_DATABASE_URI = ('mysql://nyaadev:ZmtB2oihHFvc39JaEDoF@mariadb/nyaav3?charset=utf8mb4')
|
||||
# MAIN_ANNOUNCE_URL = 'http://chihaya:6881/announce'
|
||||
# TRACKER_API_URL = 'http://chihaya:6881/api'
|
||||
BACKUP_TORRENT_FOLDER = '/nyaa-torrents'
|
||||
|
|
6
.github/issue_template.md
vendored
6
.github/issue_template.md
vendored
|
@ -1,5 +1,3 @@
|
|||
Describe your issue/feature request here (you can remove all this text). Describe well and include images, if relevant!
|
||||
Describe your issue/feature request here (you can remove all this text). Describe well and include images if relevant.
|
||||
|
||||
Please make sure to skim through the existing issues, your issue/request/etc may have already been noted!
|
||||
|
||||
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.
|
||||
Please make sure to skim through the existing issues, as your issue/request/etc. may have already been noted!
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
language: python
|
||||
|
||||
python: "3.7"
|
||||
python: "3.13"
|
||||
|
||||
dist: xenial
|
||||
sudo: required
|
||||
dist: jammy
|
||||
|
||||
matrix:
|
||||
fast_finish: true
|
||||
|
@ -14,7 +13,7 @@ services:
|
|||
mysql
|
||||
|
||||
before_install:
|
||||
- mysql -u root -e 'CREATE DATABASE nyaav2 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;'
|
||||
- mysql -u root -e 'CREATE DATABASE nyaav3 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;'
|
||||
|
||||
install:
|
||||
- pip install -r requirements.txt
|
||||
|
|
85
README.md
85
README.md
|
@ -1,47 +1,54 @@
|
|||
# NyaaV2 [](https://travis-ci.org/nyaadevs/nyaa)
|
||||
# NyaaV3 [](https://www.python.org) 
|
||||
|
||||
## Setting up for development
|
||||
This project uses Python 3.7. There are features used that do not exist in 3.6, so make sure to use Python 3.7.
|
||||
This guide also assumes you 1) are using Linux and 2) are somewhat capable with the commandline.
|
||||
It's not impossible to run Nyaa on Windows, but this guide doesn't focus on that.
|
||||
This project uses Python 3.13. The codebase has been updated from the original Python 3.7 version to use modern Python features and updated dependencies.
|
||||
This guide assumes you are using Linux and are somewhat capable with the commandline.
|
||||
Running Nyaa on Windows may be possible, but it's currently unsupported.
|
||||
|
||||
### Major changes from NyaaV2
|
||||
- Updated from Python 3.7 to Python 3.13
|
||||
- Updated all dependencies to their latest versions
|
||||
- Modernized code patterns for Flask 3.0 and SQLAlchemy 2.0
|
||||
- Replaced deprecated Flask-Script, orderedset and `flask.Markup` with Flask CLI, orderly-set and markupsafe
|
||||
- Implemented mail error handling
|
||||
|
||||
### Code Quality:
|
||||
- Before we get any deeper, remember to follow PEP8 style guidelines and run `./dev.py lint` before committing to see a list of warnings/problems.
|
||||
- You may also use `./dev.py fix && ./dev.py isort` to automatically fix some of the issues reported by the previous command.
|
||||
- Before we get any deeper, remember to follow PEP8 style guidelines and run `python dev.py lint` before committing to see a list of warnings/problems.
|
||||
- You may also use `python dev.py fix && python dev.py isort` to automatically fix some of the issues reported by the previous command.
|
||||
- Other than PEP8, try to keep your code clean and easy to understand, as well. It's only polite!
|
||||
|
||||
### Running Tests
|
||||
The `tests` folder contains tests for the the `nyaa` module and the webserver. To run the tests:
|
||||
- Make sure that you are in the python virtual environment.
|
||||
- Run `./dev.py test` while in the repository directory.
|
||||
- Make sure that you are in the Python virtual environment.
|
||||
- Run `python dev.py test` while in the repository directory.
|
||||
|
||||
### Setting up Pyenv
|
||||
pyenv eases the use of different Python versions, and as not all Linux distros offer 3.7 packages, it's right up our alley.
|
||||
- Install dependencies https://github.com/pyenv/pyenv/wiki/Common-build-problems
|
||||
- Install `pyenv` https://github.com/pyenv/pyenv/blob/master/README.md#installation
|
||||
- Install `pyenv-virtualenv` https://github.com/pyenv/pyenv-virtualenv/blob/master/README.md
|
||||
- Install Python 3.7.2 with `pyenv` and create a virtualenv for the project:
|
||||
- `pyenv install 3.7.2`
|
||||
- `pyenv virtualenv 3.7.2 nyaa`
|
||||
pyenv eases the use of different Python versions, and as not all Linux distros offer 3.13 packages, it's right up our alley.
|
||||
- Install [dependencies](https://github.com/pyenv/pyenv/wiki/Common-build-problems)
|
||||
- Install [pyenv](https://github.com/pyenv/pyenv/blob/master/README.md#installation)
|
||||
- Install [pyenv-virtualenv](https://github.com/pyenv/pyenv-virtualenv/blob/master/README.md)
|
||||
- Install Python 3.13 with `pyenv` and create a virtualenv for the project:
|
||||
- `pyenv install 3.13.2`
|
||||
- `pyenv virtualenv 3.13.2 nyaa`
|
||||
- `pyenv activate nyaa`
|
||||
- Install dependencies with `pip install -r requirements.txt`
|
||||
- Copy `config.example.py` into `config.py`
|
||||
- Change `SITE_FLAVOR` in your `config.py` depending on which instance you want to host
|
||||
|
||||
### Setting up MySQL/MariaDB database
|
||||
You *may* use SQLite but the current support for it in this project is outdated and rather unsupported.
|
||||
> [!WARNING]
|
||||
> You *may* use SQLite but it is currently untested and unsupported.
|
||||
- Enable `USE_MYSQL` flag in config.py
|
||||
- Install latest mariadb by following instructions here https://downloads.mariadb.org/mariadb/repositories/
|
||||
- Tested versions: `mysql Ver 15.1 Distrib 10.0.30-MariaDB, for debian-linux-gnu (x86_64) using readline 5.2`
|
||||
- Install MariaDB by following instructions [here](https://downloads.mariadb.org/mariadb/repositories/)
|
||||
- Run the following commands logged in as your root db user (substitute for your own `config.py` values if desired):
|
||||
- `CREATE USER 'test'@'localhost' IDENTIFIED BY 'test123';`
|
||||
- `GRANT ALL PRIVILEGES ON *.* TO 'test'@'localhost';`
|
||||
- `FLUSH PRIVILEGES;`
|
||||
- `CREATE DATABASE nyaav2 DEFAULT CHARACTER SET utf8 COLLATE utf8_bin;`
|
||||
- `CREATE DATABASE nyaav3 DEFAULT CHARACTER SET utf8 COLLATE utf8_bin;`
|
||||
|
||||
### Finishing up
|
||||
- Run `python db_create.py` to create the database and import categories
|
||||
- Follow the advice of `db_create.py` and run `./db_migrate.py stamp head` to mark the database version for Alembic
|
||||
- Follow the advice of `db_create.py` and run `python db_migrate.py stamp head` to mark the database version for Alembic
|
||||
- Start the dev server with `python run.py`
|
||||
- When you are finished developing, deactivate your virtualenv with `pyenv deactivate` or `source deactivate` (or just close your shell session)
|
||||
|
||||
|
@ -50,32 +57,35 @@ Continue below to learn about database migrations and enabling the advanced sear
|
|||
|
||||
|
||||
## Database migrations
|
||||
- Database migrations are done with [flask-Migrate](https://flask-migrate.readthedocs.io/), a wrapper around [Alembic](http://alembic.zzzcomputing.com/en/latest/).
|
||||
> [!WARNING]
|
||||
> The database migration feature has been updated but will no longer be supported in NyaaV3.
|
||||
- Database migrations are done with [Flask-Migrate](https://flask-migrate.readthedocs.io/), a wrapper around [Alembic](http://alembic.zzzcomputing.com/en/latest/).
|
||||
- The migration system has been updated to use Flask CLI instead of the deprecated Flask-Script.
|
||||
- If someone has made changes in the database schema and included a new migration script:
|
||||
- If your database has never been marked by Alembic (you're on a database from before the migrations), run `./db_migrate.py stamp head` before pulling the new migration script(s).
|
||||
- If you already have the new scripts, check the output of `./db_migrate.py history` instead and choose a hash that matches your current database state, then run `./db_migrate.py stamp <hash>`.
|
||||
- If your database has never been marked by Alembic (you're on a database from before the migrations), run `python db_migrate.py db stamp head` before pulling the new migration script(s).
|
||||
- If you already have the new scripts, check the output of `python db_migrate.py db history` instead and choose a hash that matches your current database state, then run `python db_migrate.py db stamp <hash>`.
|
||||
- Update your branch (eg. `git fetch && git rebase origin/master`)
|
||||
- Run `./db_migrate.py upgrade head` to run the migration. Done!
|
||||
- Run `python db_migrate.py db upgrade head` to run the migration. Done!
|
||||
- If *you* have made a change in the database schema:
|
||||
- Save your changes in `models.py` and ensure the database schema matches the previous version (ie. your new tables/columns are not added to the live database)
|
||||
- Run `./db_migrate.py migrate -m "Short description of changes"` to automatically generate a migration script for the changes
|
||||
- Run `python db_migrate.py db migrate -m "Short description of changes"` to automatically generate a migration script for the changes
|
||||
- Check the script (`migrations/versions/...`) and make sure it works! Alembic may not able to notice all changes.
|
||||
- Run `./db_migrate.py upgrade` to run the migration and verify the upgrade works.
|
||||
- (Run `./db_migrate.py downgrade` to verify the downgrade works as well, then upgrade again)
|
||||
- Run `python db_migrate.py db upgrade` to run the migration and verify the upgrade works.
|
||||
- (Run `python db_migrate.py db downgrade` to verify the downgrade works as well, then upgrade again)
|
||||
|
||||
|
||||
## Setting up and enabling Elasticsearch
|
||||
|
||||
### Installing Elasticsearch
|
||||
- Install JDK with `sudo apt-get install openjdk-8-jdk`
|
||||
- Install [Elasticsearch](https://www.elastic.co/downloads/elasticsearch)
|
||||
- [From packages...](https://www.elastic.co/guide/en/elasticsearch/reference/current/deb.html)
|
||||
- Install Elasticsearch
|
||||
- [From packages](https://www.elastic.co/guide/en/elasticsearch/reference/current/deb.html)
|
||||
- Enable the service:
|
||||
- `sudo systemctl enable elasticsearch.service`
|
||||
- `sudo systemctl start elasticsearch.service`
|
||||
- or [simply extracting the archives and running the files](https://www.elastic.co/guide/en/elasticsearch/reference/current/_installation.html), if you don't feel like permantently installing ES
|
||||
- or [simply extracting the archives and running the files](https://www.elastic.co/guide/en/elasticsearch/reference/current/_installation.html), if you don't feel like permanently installing ES
|
||||
- Run `curl -XGET 'localhost:9200'` and make sure ES is running
|
||||
- Optional: install [Kibana](https://www.elastic.co/products/kibana) as a search debug frontend for ES
|
||||
- Install [Kibana](https://www.elastic.co/products/kibana) as a search debug frontend for ES (*optional*)
|
||||
|
||||
### Enabling MySQL Binlogging
|
||||
- Edit your MariaDB/MySQL server configuration and add the following under `[mariadb]`:
|
||||
|
@ -113,3 +123,16 @@ However, take note that binglog is not necessary for simple ES testing and devel
|
|||
|
||||
You're done! The script should now be feeding updates from the database to Elasticsearch.
|
||||
Take note, however, that the specified ES index refresh interval is 30 seconds, which may feel like a long time on local development. Feel free to adjust it or [poke Elasticsearch yourself!](https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-refresh.html)
|
||||
|
||||
## License
|
||||
This project is licensed under the GNU General Public License v3.0 (GPL-3.0). See the [LICENSE](LICENSE) file for more details.
|
||||
|
||||
## Disclaimer
|
||||
> [!CAUTION]
|
||||
> **This project was created as a learning experience, and while it’s a torrent tracker, I can’t control how people choose to use it.**
|
||||
|
||||
By using this software, you're agreeing to a few things:
|
||||
- I'm not responsible for any legal issues that might come up from using this tracker, especially if it's used to share copyrighted content without permission.
|
||||
- It's your responsibility to make sure you're following the laws in your area when using this software.
|
||||
|
||||
**Please use this project wisely and stay on the right side of the law.** Happy coding!
|
9
WSGI.py
9
WSGI.py
|
@ -1,11 +1,16 @@
|
|||
#!/usr/bin/python3
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
WSGI entry point for the Nyaa application.
|
||||
Compatible with Python 3.13.
|
||||
"""
|
||||
import gevent.monkey
|
||||
gevent.monkey.patch_all()
|
||||
|
||||
from nyaa import create_app
|
||||
from flask import Flask
|
||||
|
||||
app = create_app('config')
|
||||
app: Flask = create_app('config')
|
||||
|
||||
if app.config['DEBUG']:
|
||||
from werkzeug.debug import DebuggedApplication
|
||||
|
|
|
@ -42,6 +42,12 @@ EXTERNAL_URLS = {'fap':'***', 'main':'***'}
|
|||
CSRF_SESSION_KEY = '***'
|
||||
SECRET_KEY = '***'
|
||||
|
||||
# Session cookie configuration
|
||||
SESSION_COOKIE_NAME = 'nyaav3_session'
|
||||
SESSION_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
|
||||
# Present a recaptcha for anonymous uploaders
|
||||
USE_RECAPTCHA = False
|
||||
# Require email validation
|
||||
|
@ -82,7 +88,7 @@ RECAPTCHA_PRIVATE_KEY = '***'
|
|||
|
||||
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
if USE_MYSQL:
|
||||
SQLALCHEMY_DATABASE_URI = ('mysql://test:test123@localhost/nyaav2?charset=utf8mb4')
|
||||
SQLALCHEMY_DATABASE_URI = ('mysql://test:test123@localhost/nyaav3?charset=utf8mb4')
|
||||
else:
|
||||
SQLALCHEMY_DATABASE_URI = (
|
||||
'sqlite:///' + os.path.join(BASE_DIR, 'test.db') + '?check_same_thread=False')
|
||||
|
|
34
db_create.py
34
db_create.py
|
@ -1,12 +1,19 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database creation script for Nyaa.
|
||||
Compatible with Python 3.13 and SQLAlchemy 2.0.
|
||||
"""
|
||||
from typing import List, Tuple, Type
|
||||
|
||||
import sqlalchemy
|
||||
from sqlalchemy import select
|
||||
|
||||
from nyaa import create_app, models
|
||||
from nyaa.extensions import db
|
||||
|
||||
app = create_app('config')
|
||||
|
||||
NYAA_CATEGORIES = [
|
||||
NYAA_CATEGORIES: List[Tuple[str, List[str]]] = [
|
||||
('Anime', ['Anime Music Video', 'English-translated', 'Non-English-translated', 'Raw']),
|
||||
('Audio', ['Lossless', 'Lossy']),
|
||||
('Literature', ['English-translated', 'Non-English-translated', 'Raw']),
|
||||
|
@ -16,13 +23,23 @@ NYAA_CATEGORIES = [
|
|||
]
|
||||
|
||||
|
||||
SUKEBEI_CATEGORIES = [
|
||||
SUKEBEI_CATEGORIES: List[Tuple[str, List[str]]] = [
|
||||
('Art', ['Anime', 'Doujinshi', 'Games', 'Manga', 'Pictures']),
|
||||
('Real Life', ['Photobooks / Pictures', 'Videos']),
|
||||
]
|
||||
|
||||
|
||||
def add_categories(categories, main_class, sub_class):
|
||||
def add_categories(categories: List[Tuple[str, List[str]]],
|
||||
main_class: Type[models.MainCategoryBase],
|
||||
sub_class: Type[models.SubCategoryBase]) -> None:
|
||||
"""
|
||||
Add categories to the database.
|
||||
|
||||
Args:
|
||||
categories: List of tuples containing main category name and list of subcategory names
|
||||
main_class: Main category model class
|
||||
sub_class: Subcategory model class
|
||||
"""
|
||||
for main_cat_name, sub_cat_names in categories:
|
||||
main_cat = main_class(name=main_cat_name)
|
||||
for i, sub_cat_name in enumerate(sub_cat_names):
|
||||
|
@ -36,19 +53,24 @@ if __name__ == '__main__':
|
|||
# Test for the user table, assume db is empty if it's not created
|
||||
database_empty = False
|
||||
try:
|
||||
models.User.query.first()
|
||||
stmt = select(models.User).limit(1)
|
||||
db.session.execute(stmt).scalar_one_or_none()
|
||||
except (sqlalchemy.exc.ProgrammingError, sqlalchemy.exc.OperationalError):
|
||||
database_empty = True
|
||||
|
||||
print('Creating all tables...')
|
||||
db.create_all()
|
||||
|
||||
nyaa_category_test = models.NyaaMainCategory.query.first()
|
||||
# Check if Nyaa categories exist
|
||||
stmt = select(models.NyaaMainCategory).limit(1)
|
||||
nyaa_category_test = db.session.execute(stmt).scalar_one_or_none()
|
||||
if not nyaa_category_test:
|
||||
print('Adding Nyaa categories...')
|
||||
add_categories(NYAA_CATEGORIES, models.NyaaMainCategory, models.NyaaSubCategory)
|
||||
|
||||
sukebei_category_test = models.SukebeiMainCategory.query.first()
|
||||
# Check if Sukebei categories exist
|
||||
stmt = select(models.SukebeiMainCategory).limit(1)
|
||||
sukebei_category_test = db.session.execute(stmt).scalar_one_or_none()
|
||||
if not sukebei_category_test:
|
||||
print('Adding Sukebei categories...')
|
||||
add_categories(SUKEBEI_CATEGORIES, models.SukebeiMainCategory, models.SukebeiSubCategory)
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Database migration script for Nyaa.
|
||||
Compatible with Python 3.13 and Flask-Migrate 4.0.
|
||||
"""
|
||||
import sys
|
||||
from typing import List
|
||||
|
||||
from flask_script import Manager
|
||||
from flask_migrate import Migrate, MigrateCommand
|
||||
from flask_migrate import Migrate
|
||||
from flask.cli import FlaskGroup
|
||||
|
||||
from nyaa import create_app
|
||||
from nyaa.extensions import db
|
||||
|
@ -11,11 +16,17 @@ from nyaa.extensions import db
|
|||
app = create_app('config')
|
||||
migrate = Migrate(app, db)
|
||||
|
||||
manager = Manager(app)
|
||||
manager.add_command("db", MigrateCommand)
|
||||
def create_cli_app():
|
||||
return app
|
||||
|
||||
cli = FlaskGroup(create_app=create_cli_app)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Patch sys.argv to default to 'db'
|
||||
sys.argv.insert(1, 'db')
|
||||
if len(sys.argv) > 1 and sys.argv[1] not in ['--help', '-h']:
|
||||
if sys.argv[1] not in ['db', 'routes', 'shell', 'run']:
|
||||
args: List[str] = sys.argv.copy()
|
||||
args.insert(1, 'db')
|
||||
sys.argv = args
|
||||
|
||||
manager.run()
|
||||
cli()
|
||||
|
|
13
dev.py
13
dev.py
|
@ -4,8 +4,11 @@
|
|||
This tool is designed to assist developers run common tasks, such as
|
||||
checking the code for lint issues, auto fixing some lint issues and running tests.
|
||||
It imports modules lazily (as-needed basis), so it runs faster!
|
||||
|
||||
Compatible with Python 3.13.
|
||||
"""
|
||||
import sys
|
||||
from typing import List, Optional, Generator, Any, Union
|
||||
|
||||
LINT_PATHS = [
|
||||
'nyaa/',
|
||||
|
@ -14,14 +17,14 @@ LINT_PATHS = [
|
|||
TEST_PATHS = ['tests']
|
||||
|
||||
|
||||
def print_cmd(cmd, args):
|
||||
def print_cmd(cmd: str, args: List[str]) -> None:
|
||||
""" Prints the command and args as you would run them manually. """
|
||||
print('Running: {0}\n'.format(
|
||||
' '.join([('\'' + a + '\'' if ' ' in a else a) for a in [cmd] + args])))
|
||||
sys.stdout.flush() # Make sure stdout is flushed before continuing.
|
||||
|
||||
|
||||
def check_config_values():
|
||||
def check_config_values() -> bool:
|
||||
""" Verify that all max_line_length values match. """
|
||||
import configparser
|
||||
config = configparser.ConfigParser()
|
||||
|
@ -32,7 +35,7 @@ def check_config_values():
|
|||
autopep8 = config.get('pycodestyle', 'max_line_length', fallback=None)
|
||||
isort = config.get('isort', 'line_length', fallback=None)
|
||||
|
||||
values = (v for v in (flake8, autopep8, isort) if v is not None)
|
||||
values: Generator[str, None, None] = (v for v in (flake8, autopep8, isort) if v is not None)
|
||||
found = next(values, False)
|
||||
if not found:
|
||||
print('Warning: No max line length setting set in setup.cfg.')
|
||||
|
@ -44,7 +47,7 @@ def check_config_values():
|
|||
return True
|
||||
|
||||
|
||||
def print_help():
|
||||
def print_help() -> int:
|
||||
print('Nyaa Development Helper')
|
||||
print('=======================\n')
|
||||
print('Usage: {0} command [different arguments]'.format(sys.argv[0]))
|
||||
|
@ -62,7 +65,7 @@ def print_help():
|
|||
|
||||
|
||||
if __name__ == '__main__':
|
||||
assert sys.version_info >= (3, 6), "Python 3.6 is required"
|
||||
assert sys.version_info >= (3, 13), "Python 3.13 is required"
|
||||
|
||||
check_config_values()
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
"mysql_port": 3306,
|
||||
"mysql_user": "nyaa",
|
||||
"mysql_password": "some_password",
|
||||
"database": "nyaav2",
|
||||
"database": "nyaav3",
|
||||
"internal_queue_depth": 10000,
|
||||
"es_chunk_size": 10000,
|
||||
"flush_interval": 5
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env python
|
||||
"""
|
||||
Bulk load torents from mysql into elasticsearch `nyaav2` index,
|
||||
Bulk load torents from mysql into elasticsearch `nyaav3` index,
|
||||
which is assumed to already exist.
|
||||
This is a one-shot deal, so you'd either need to complement it
|
||||
with a cron job or some binlog-reading thing (TODO)
|
||||
|
|
|
@ -1 +1,4 @@
|
|||
> [!WARNING]
|
||||
> No longer supported in NyaaV3.
|
||||
|
||||
Generic single-database configuration.
|
|
@ -1,8 +1,10 @@
|
|||
import logging
|
||||
import os
|
||||
import string
|
||||
from typing import Any, Optional
|
||||
|
||||
import flask
|
||||
from flask import Flask
|
||||
from flask_assets import Bundle # noqa F401
|
||||
|
||||
from nyaa.api_handler import api_blueprint
|
||||
|
@ -18,11 +20,17 @@ from nyaa.views import register_views
|
|||
flask.url_for = caching_url_for
|
||||
|
||||
|
||||
def create_app(config):
|
||||
def create_app(config: Any) -> Flask:
|
||||
""" Nyaa app factory """
|
||||
app = flask.Flask(__name__)
|
||||
app.config.from_object(config)
|
||||
|
||||
# Session cookie configuration
|
||||
app.config['SESSION_COOKIE_NAME'] = 'nyaav3_session'
|
||||
app.config['SESSION_COOKIE_SECURE'] = True
|
||||
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||
|
||||
# Don't refresh cookie each request
|
||||
app.config['SESSION_REFRESH_EACH_REQUEST'] = False
|
||||
|
||||
|
@ -34,24 +42,24 @@ def create_app(config):
|
|||
|
||||
# Forbid caching
|
||||
@app.after_request
|
||||
def forbid_cache(request):
|
||||
request.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0'
|
||||
request.headers['Pragma'] = 'no-cache'
|
||||
request.headers['Expires'] = '0'
|
||||
return request
|
||||
def forbid_cache(response: flask.Response) -> flask.Response:
|
||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
|
||||
# Add a timer header to the requests when debugging
|
||||
# This gives us a simple way to benchmark requests off-app
|
||||
import time
|
||||
|
||||
@app.before_request
|
||||
def timer_before_request():
|
||||
def timer_before_request() -> None:
|
||||
flask.g.request_start_time = time.time()
|
||||
|
||||
@app.after_request
|
||||
def timer_after_request(request):
|
||||
request.headers['X-Timer'] = time.time() - flask.g.request_start_time
|
||||
return request
|
||||
def timer_after_request(response: flask.Response) -> flask.Response:
|
||||
response.headers['X-Timer'] = str(time.time() - flask.g.request_start_time)
|
||||
return response
|
||||
|
||||
else:
|
||||
app.logger.setLevel(logging.WARNING)
|
||||
|
@ -63,17 +71,17 @@ def create_app(config):
|
|||
app.config['LOG_FILE'], maxBytes=10000, backupCount=1)
|
||||
app.logger.addHandler(app.log_handler)
|
||||
|
||||
# Log errors and display a message to the user in production mdode
|
||||
# Log errors and display a message to the user in production mode
|
||||
if not app.config['DEBUG']:
|
||||
@app.errorhandler(500)
|
||||
def internal_error(exception):
|
||||
def internal_error(exception: Exception) -> flask.Response:
|
||||
random_id = random_string(8, string.ascii_uppercase + string.digits)
|
||||
# Pst. Not actually unique, but don't tell anyone!
|
||||
app.logger.error('Exception occurred! Unique ID: %s', random_id, exc_info=exception)
|
||||
app.logger.error(f'Exception occurred! Unique ID: {random_id}', exc_info=exception)
|
||||
markup_source = ' '.join([
|
||||
'<strong>An error occurred!</strong>',
|
||||
'Debug information has been logged.',
|
||||
'Please pass along this ID: <kbd>{}</kbd>'.format(random_id)
|
||||
f'Please pass along this ID: <kbd>{random_id}</kbd>'
|
||||
])
|
||||
|
||||
flask.flash(flask.Markup(markup_source), 'danger')
|
||||
|
@ -101,10 +109,15 @@ def create_app(config):
|
|||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.config['MYSQL_DATABASE_CHARSET'] = 'utf8mb4'
|
||||
db.init_app(app)
|
||||
|
||||
# Import the fixed Ban.banned method
|
||||
with app.app_context():
|
||||
import nyaa.fixed_ban
|
||||
|
||||
# Assets
|
||||
assets.init_app(app)
|
||||
assets._named_bundles = {} # Hack to fix state carrying over in tests
|
||||
if hasattr(assets, '_named_bundles'):
|
||||
assets._named_bundles = {} # Hack to fix state carrying over in tests
|
||||
main_js = Bundle('js/main.js', filters='rjsmin', output='js/main.min.js')
|
||||
bs_js = Bundle('js/bootstrap-select.js', filters='rjsmin',
|
||||
output='js/bootstrap-select.min.js')
|
||||
|
|
|
@ -5,10 +5,10 @@ from datetime import datetime, timedelta
|
|||
from ipaddress import ip_address
|
||||
|
||||
import flask
|
||||
from werkzeug import secure_filename
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
import sqlalchemy
|
||||
from orderedset import OrderedSet
|
||||
from orderly_set import OrderlySet
|
||||
|
||||
from nyaa import models, utils
|
||||
from nyaa.extensions import db
|
||||
|
@ -304,7 +304,7 @@ def handle_torrent_upload(upload_form, uploading_user=None, fromAPI=False):
|
|||
db.session.flush()
|
||||
|
||||
# Store the users trackers
|
||||
trackers = OrderedSet()
|
||||
trackers = OrderlySet()
|
||||
announce = torrent_data.torrent_dict.get('announce', b'').decode('ascii')
|
||||
if announce:
|
||||
trackers.add(announce)
|
||||
|
@ -319,12 +319,12 @@ def handle_torrent_upload(upload_form, uploading_user=None, fromAPI=False):
|
|||
webseed_list = torrent_data.torrent_dict.get('url-list') or []
|
||||
if isinstance(webseed_list, bytes):
|
||||
webseed_list = [webseed_list] # qB doesn't contain a sole url in a list
|
||||
webseeds = OrderedSet(webseed.decode('utf-8') for webseed in webseed_list)
|
||||
webseeds = OrderlySet(webseed.decode('utf-8') for webseed in webseed_list)
|
||||
|
||||
# Remove our trackers, maybe? TODO ?
|
||||
|
||||
# Search for/Add trackers in DB
|
||||
db_trackers = OrderedSet()
|
||||
db_trackers = OrderlySet()
|
||||
for announce in trackers:
|
||||
tracker = models.Trackers.by_uri(announce)
|
||||
|
||||
|
|
99
nyaa/custom_pagination.py
Normal file
99
nyaa/custom_pagination.py
Normal file
|
@ -0,0 +1,99 @@
|
|||
from typing import Any, List, Optional, Sequence, TypeVar, Union
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
class CustomPagination:
|
||||
"""
|
||||
A custom pagination class that mimics the interface of Flask-SQLAlchemy's Pagination
|
||||
but doesn't rely on the _query_items method.
|
||||
"""
|
||||
|
||||
def __init__(self, query: Any, page: int, per_page: int, total: int, items: List[T]):
|
||||
"""
|
||||
Initialize a new CustomPagination object.
|
||||
|
||||
Args:
|
||||
query: The query object (not used, but kept for compatibility)
|
||||
page: The current page number (1-indexed)
|
||||
per_page: The number of items per page
|
||||
total: The total number of items
|
||||
items: The items on the current page
|
||||
"""
|
||||
self.query = query
|
||||
self.page = page
|
||||
self.per_page = per_page
|
||||
self.total = total
|
||||
self.items = items
|
||||
|
||||
# For compatibility with LimitedPagination
|
||||
self.actual_count = total
|
||||
|
||||
@property
|
||||
def has_prev(self) -> bool:
|
||||
"""Return True if there is a previous page."""
|
||||
return self.page > 1
|
||||
|
||||
@property
|
||||
def has_next(self) -> bool:
|
||||
"""Return True if there is a next page."""
|
||||
return self.page < self.pages
|
||||
|
||||
@property
|
||||
def pages(self) -> int:
|
||||
"""The total number of pages."""
|
||||
if self.per_page == 0 or self.total == 0:
|
||||
return 0
|
||||
return max(1, (self.total + self.per_page - 1) // self.per_page)
|
||||
|
||||
@property
|
||||
def prev_num(self) -> Optional[int]:
|
||||
"""The previous page number, or None if this is the first page."""
|
||||
if self.has_prev:
|
||||
return self.page - 1
|
||||
return None
|
||||
|
||||
@property
|
||||
def next_num(self) -> Optional[int]:
|
||||
"""The next page number, or None if this is the last page."""
|
||||
if self.has_next:
|
||||
return self.page + 1
|
||||
return None
|
||||
|
||||
@property
|
||||
def first(self) -> int:
|
||||
"""The number of the first item on the page, starting from 1, or 0 if there are no items."""
|
||||
if not self.items:
|
||||
return 0
|
||||
return (self.page - 1) * self.per_page + 1
|
||||
|
||||
@property
|
||||
def last(self) -> int:
|
||||
"""The number of the last item on the page, starting from 1, inclusive, or 0 if there are no items."""
|
||||
if not self.items:
|
||||
return 0
|
||||
return min(self.total, self.page * self.per_page)
|
||||
|
||||
def iter_pages(self, left_edge: int = 2, left_current: int = 2,
|
||||
right_current: int = 5, right_edge: int = 2) -> Sequence[Optional[int]]:
|
||||
"""
|
||||
Yield page numbers for a pagination widget.
|
||||
|
||||
Skipped pages between the edges and middle are represented by a None.
|
||||
"""
|
||||
last = 0
|
||||
for num in range(1, self.pages + 1):
|
||||
if (num <= left_edge or
|
||||
(num > self.page - left_current - 1 and num < self.page + right_current) or
|
||||
num > self.pages - right_edge):
|
||||
if last + 1 != num:
|
||||
yield None
|
||||
yield num
|
||||
last = num
|
||||
|
||||
def __iter__(self):
|
||||
"""Iterate over the items on the current page."""
|
||||
return iter(self.items)
|
||||
|
||||
def __len__(self):
|
||||
"""Return the number of items on the current page."""
|
||||
return len(self.items)
|
|
@ -45,29 +45,58 @@ class EmailHolder(object):
|
|||
|
||||
|
||||
def send_email(email_holder):
|
||||
"""Send an email using the configured mail backend."""
|
||||
mail_backend = app.config.get('MAIL_BACKEND')
|
||||
if mail_backend == 'mailgun':
|
||||
_send_mailgun(email_holder)
|
||||
elif mail_backend == 'smtp':
|
||||
_send_smtp(email_holder)
|
||||
elif mail_backend:
|
||||
# TODO: Do this in logging.error when we have that set up
|
||||
print('Unknown mail backend:', mail_backend)
|
||||
|
||||
if not mail_backend:
|
||||
app.logger.warning('No mail backend configured, skipping email send')
|
||||
return False
|
||||
|
||||
try:
|
||||
if mail_backend == 'mailgun':
|
||||
success = _send_mailgun(email_holder)
|
||||
elif mail_backend == 'smtp':
|
||||
success = _send_smtp(email_holder)
|
||||
else:
|
||||
app.logger.error(f'Unknown mail backend: {mail_backend}')
|
||||
return False
|
||||
|
||||
if not success:
|
||||
app.logger.error(f'Failed to send email using {mail_backend} backend')
|
||||
return False
|
||||
|
||||
app.logger.info(f'Email successfully sent using {mail_backend} backend')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f'Error sending email: {str(e)}')
|
||||
return False
|
||||
|
||||
|
||||
def _send_mailgun(email_holder):
|
||||
mailgun_endpoint = app.config['MAILGUN_API_BASE'] + '/messages'
|
||||
auth = ('api', app.config['MAILGUN_API_KEY'])
|
||||
data = {
|
||||
'from': app.config['MAIL_FROM_ADDRESS'],
|
||||
'to': email_holder.format_recipient(),
|
||||
'subject': email_holder.subject,
|
||||
'text': email_holder.text,
|
||||
'html': email_holder.html
|
||||
}
|
||||
r = requests.post(mailgun_endpoint, data=data, auth=auth)
|
||||
# TODO real error handling?
|
||||
assert r.status_code == 200
|
||||
"""Send an email using Mailgun API with proper error handling."""
|
||||
try:
|
||||
mailgun_endpoint = app.config['MAILGUN_API_BASE'] + '/messages'
|
||||
auth = ('api', app.config['MAILGUN_API_KEY'])
|
||||
data = {
|
||||
'from': app.config['MAIL_FROM_ADDRESS'],
|
||||
'to': email_holder.format_recipient(),
|
||||
'subject': email_holder.subject,
|
||||
'text': email_holder.text,
|
||||
'html': email_holder.html
|
||||
}
|
||||
|
||||
r = requests.post(mailgun_endpoint, data=data, auth=auth)
|
||||
|
||||
if r.status_code != 200:
|
||||
app.logger.error(f'Mailgun API error: {r.status_code} - {r.text}')
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f'Error sending email via Mailgun: {str(e)}')
|
||||
return False
|
||||
|
||||
|
||||
def _send_smtp(email_holder):
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import os.path
|
||||
from typing import Any, Optional, Sequence, TypeVar, Union
|
||||
|
||||
from flask import abort
|
||||
from flask.config import Config
|
||||
|
@ -7,7 +8,9 @@ from flask_caching import Cache
|
|||
from flask_debugtoolbar import DebugToolbarExtension
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
from flask_sqlalchemy import BaseQuery, Pagination, SQLAlchemy
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_sqlalchemy.pagination import Pagination
|
||||
from sqlalchemy.orm import Query
|
||||
|
||||
assets = Environment()
|
||||
db = SQLAlchemy()
|
||||
|
@ -15,16 +18,28 @@ toolbar = DebugToolbarExtension()
|
|||
cache = Cache()
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
# Type variable for query results
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class LimitedPagination(Pagination):
|
||||
def __init__(self, actual_count, *args, **kwargs):
|
||||
def __init__(self, actual_count: int, *args: Any, **kwargs: Any) -> None:
|
||||
self.actual_count = actual_count
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
def fix_paginate():
|
||||
|
||||
def paginate_faste(self, page=1, per_page=50, max_page=None, step=5, count_query=None):
|
||||
def fix_paginate() -> None:
|
||||
"""Add custom pagination method to SQLAlchemy Query."""
|
||||
|
||||
def paginate_faste(
|
||||
self: Query[T],
|
||||
page: int = 1,
|
||||
per_page: int = 50,
|
||||
max_page: Optional[int] = None,
|
||||
step: int = 5,
|
||||
count_query: Optional[Query[int]] = None
|
||||
) -> LimitedPagination:
|
||||
"""Custom pagination that supports max_page and count_query."""
|
||||
if page < 1:
|
||||
abort(404)
|
||||
|
||||
|
@ -36,6 +51,10 @@ def fix_paginate():
|
|||
total_query_count = count_query.scalar()
|
||||
else:
|
||||
total_query_count = self.count()
|
||||
|
||||
if total_query_count is None:
|
||||
total_query_count = 0
|
||||
|
||||
actual_query_count = total_query_count
|
||||
if max_page:
|
||||
total_query_count = min(total_query_count, max_page * per_page)
|
||||
|
@ -49,17 +68,20 @@ def fix_paginate():
|
|||
return LimitedPagination(actual_query_count, self, page, per_page, total_query_count,
|
||||
items)
|
||||
|
||||
BaseQuery.paginate_faste = paginate_faste
|
||||
# Monkey patch the Query class
|
||||
setattr(Query, 'paginate_faste', paginate_faste)
|
||||
|
||||
|
||||
def _get_config():
|
||||
# Workaround to get an available config object before the app is initiallized
|
||||
# Only needed/used in top-level and class statements
|
||||
# https://stackoverflow.com/a/18138250/7597273
|
||||
def _get_config() -> Config:
|
||||
"""
|
||||
Workaround to get an available config object before the app is initialized.
|
||||
Only needed/used in top-level and class statements.
|
||||
https://stackoverflow.com/a/18138250/7597273
|
||||
"""
|
||||
root_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
config = Config(root_path)
|
||||
config.from_object('config')
|
||||
return config
|
||||
config_obj = Config(root_path)
|
||||
config_obj.from_object('config')
|
||||
return config_obj
|
||||
|
||||
|
||||
config = _get_config()
|
||||
|
|
26
nyaa/fixed_ban.py
Normal file
26
nyaa/fixed_ban.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
from typing import Optional, Union
|
||||
from sqlalchemy import or_, select
|
||||
from nyaa.extensions import db
|
||||
from nyaa.models import Ban
|
||||
|
||||
# Fix the banned method to return a query object instead of a list
|
||||
@classmethod
|
||||
def fixed_banned(cls, user_id: Optional[int], user_ip: Optional[bytes]):
|
||||
"""Check if a user or IP is banned.
|
||||
|
||||
Returns a query object that can be further filtered or used with .first(), .all(), etc.
|
||||
"""
|
||||
if not user_id and not user_ip:
|
||||
# Return an empty query that will return no results
|
||||
return db.session.query(cls).filter(cls.id < 0)
|
||||
|
||||
clauses = []
|
||||
if user_id:
|
||||
clauses.append(cls.user_id == user_id)
|
||||
if user_ip:
|
||||
clauses.append(cls.user_ip == user_ip)
|
||||
|
||||
return db.session.query(cls).filter(or_(*clauses))
|
||||
|
||||
# Replace the original method with our fixed version
|
||||
Ban.banned = fixed_banned
|
|
@ -11,7 +11,8 @@ from wtforms import (BooleanField, HiddenField, PasswordField, SelectField, Stri
|
|||
SubmitField, TextAreaField)
|
||||
from wtforms.validators import (DataRequired, Email, EqualTo, Length, Optional, Regexp,
|
||||
StopValidation, ValidationError)
|
||||
from wtforms.widgets import HTMLString # For DisabledSelectField
|
||||
# from wtforms.widgets import HTMLString # For DisabledSelectField
|
||||
from markupsafe import Markup
|
||||
from wtforms.widgets import Select as SelectWidget # For DisabledSelectField
|
||||
from wtforms.widgets import html_params
|
||||
|
||||
|
@ -223,7 +224,7 @@ class DisabledSelectWidget(SelectWidget):
|
|||
extra = disabled and {'disabled': ''} or {}
|
||||
html.append(self.render_option(val, label, selected, **extra))
|
||||
html.append('</select>')
|
||||
return HTMLString(''.join(html))
|
||||
return Markup(''.join(html))
|
||||
|
||||
|
||||
class DisabledSelectField(SelectField):
|
||||
|
@ -265,7 +266,7 @@ class InlineButtonWidget(object):
|
|||
kwargs.setdefault('type', self.input_type)
|
||||
if not label:
|
||||
label = field.label.text
|
||||
return HTMLString('<button %s>' % self.html_params(name=field.name, **kwargs) + label)
|
||||
return Markup('<button %s>' % self.html_params(name=field.name, **kwargs) + label)
|
||||
|
||||
|
||||
class StringSubmitField(StringField):
|
||||
|
|
204
nyaa/models.py
204
nyaa/models.py
|
@ -5,13 +5,14 @@ from datetime import datetime
|
|||
from enum import Enum, IntEnum
|
||||
from hashlib import md5
|
||||
from ipaddress import ip_address
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
from urllib.parse import unquote as unquote_url
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import flask
|
||||
from markupsafe import escape as escape_markup
|
||||
|
||||
from sqlalchemy import ForeignKeyConstraint, Index, func
|
||||
from sqlalchemy import ForeignKeyConstraint, Index, func, select
|
||||
from sqlalchemy.ext import declarative
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from sqlalchemy_fulltext import FullText
|
||||
|
@ -31,7 +32,7 @@ if config['USE_MYSQL']:
|
|||
COL_UTF8MB4_BIN = 'utf8mb4_bin'
|
||||
COL_ASCII_GENERAL_CI = 'ascii_general_ci'
|
||||
else:
|
||||
BinaryType = db.Binary
|
||||
BinaryType = db.LargeBinary
|
||||
TextType = db.String
|
||||
MediumBlobType = db.BLOB
|
||||
COL_UTF8_GENERAL_CI = 'NOCASE'
|
||||
|
@ -48,23 +49,29 @@ class DeclarativeHelperBase(object):
|
|||
__tablename__ and providing class methods for renaming references. '''
|
||||
# See http://docs.sqlalchemy.org/en/latest/orm/extensions/declarative/api.html
|
||||
|
||||
__tablename_base__ = None
|
||||
__flavor__ = None
|
||||
__tablename_base__: Optional[str] = None
|
||||
__flavor__: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def _table_prefix_string(cls):
|
||||
def _table_prefix_string(cls) -> str:
|
||||
if cls.__flavor__ is None:
|
||||
raise ValueError("__flavor__ must be set")
|
||||
return cls.__flavor__.lower() + '_'
|
||||
|
||||
@classmethod
|
||||
def _table_prefix(cls, table_name):
|
||||
def _table_prefix(cls, table_name: str) -> str:
|
||||
return cls._table_prefix_string() + table_name
|
||||
|
||||
@classmethod
|
||||
def _flavor_prefix(cls, table_name):
|
||||
def _flavor_prefix(cls, table_name: str) -> str:
|
||||
if cls.__flavor__ is None:
|
||||
raise ValueError("__flavor__ must be set")
|
||||
return cls.__flavor__ + table_name
|
||||
|
||||
@declarative.declared_attr
|
||||
def __tablename__(cls):
|
||||
def __tablename__(cls) -> str:
|
||||
if cls.__tablename_base__ is None:
|
||||
raise ValueError("__tablename_base__ must be set")
|
||||
return cls._table_prefix(cls.__tablename_base__)
|
||||
|
||||
|
||||
|
@ -72,22 +79,22 @@ class FlagProperty(object):
|
|||
''' This class will act as a wrapper between the given flag and the class's
|
||||
flag collection. '''
|
||||
|
||||
def __init__(self, flag, flags_attr='flags'):
|
||||
def __init__(self, flag: int, flags_attr: str = 'flags'):
|
||||
self._flag = flag
|
||||
self._flags_attr_name = flags_attr
|
||||
|
||||
def _get_flags(self, instance):
|
||||
def _get_flags(self, instance: Any) -> int:
|
||||
return getattr(instance, self._flags_attr_name)
|
||||
|
||||
def _set_flags(self, instance, value):
|
||||
def _set_flags(self, instance: Any, value: int) -> None:
|
||||
return setattr(instance, self._flags_attr_name, value)
|
||||
|
||||
def __get__(self, instance, owner_class):
|
||||
def __get__(self, instance: Any, owner_class: Any) -> bool:
|
||||
if instance is None:
|
||||
raise AttributeError()
|
||||
return bool(self._get_flags(instance) & self._flag)
|
||||
|
||||
def __set__(self, instance, value):
|
||||
def __set__(self, instance: Any, value: bool) -> None:
|
||||
new_flags = (self._get_flags(instance) & ~self._flag) | (bool(value) and self._flag)
|
||||
self._set_flags(instance, new_flags)
|
||||
|
||||
|
@ -124,7 +131,7 @@ class TorrentBase(DeclarativeHelperBase):
|
|||
# Even though this is same for both tables, declarative requires this
|
||||
return db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
|
||||
uploader_ip = db.Column(db.Binary(length=16), default=None, nullable=True)
|
||||
uploader_ip = db.Column(db.LargeBinary(length=16), default=None, nullable=True)
|
||||
has_torrent = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
comment_count = db.Column(db.Integer, default=0, nullable=False, index=True)
|
||||
|
@ -198,15 +205,22 @@ class TorrentBase(DeclarativeHelperBase):
|
|||
def __repr__(self):
|
||||
return '<{0} #{1.id} \'{1.display_name}\' {1.filesize}b>'.format(type(self).__name__, self)
|
||||
|
||||
def update_comment_count(self):
|
||||
self.comment_count = db.session.query(func.count(
|
||||
Comment.id)).filter_by(torrent_id=self.id).first()[0]
|
||||
def update_comment_count(self) -> int:
|
||||
"""Update the comment count for this torrent and return the new count."""
|
||||
stmt = select(func.count(Comment.id)).filter_by(torrent_id=self.id)
|
||||
result = db.session.execute(stmt).scalar_one_or_none() or 0
|
||||
self.comment_count = result
|
||||
return self.comment_count
|
||||
|
||||
@classmethod
|
||||
def update_comment_count_db(cls, torrent_id):
|
||||
cls.query.filter_by(id=torrent_id).update({'comment_count': db.session.query(
|
||||
func.count(Comment.id)).filter_by(torrent_id=torrent_id).as_scalar()}, False)
|
||||
def update_comment_count_db(cls, torrent_id: int) -> None:
|
||||
"""Update the comment count in the database for the given torrent ID."""
|
||||
stmt = select(func.count(Comment.id)).filter_by(torrent_id=torrent_id)
|
||||
count = db.session.execute(stmt).scalar_one_or_none() or 0
|
||||
|
||||
# Use the new update() style
|
||||
stmt = db.update(cls).filter_by(id=torrent_id).values(comment_count=count)
|
||||
db.session.execute(stmt)
|
||||
|
||||
@property
|
||||
def created_utc_timestamp(self):
|
||||
|
@ -272,15 +286,20 @@ class TorrentBase(DeclarativeHelperBase):
|
|||
# Class methods
|
||||
|
||||
@classmethod
|
||||
def by_id(cls, id):
|
||||
return cls.query.get(id)
|
||||
def by_id(cls, id: int) -> Optional['TorrentBase']:
|
||||
"""Get a torrent by its ID."""
|
||||
stmt = select(cls).filter_by(id=id)
|
||||
return db.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
@classmethod
|
||||
def by_info_hash(cls, info_hash):
|
||||
return cls.query.filter_by(info_hash=info_hash).first()
|
||||
def by_info_hash(cls, info_hash: bytes) -> Optional['TorrentBase']:
|
||||
"""Get a torrent by its info hash."""
|
||||
stmt = select(cls).filter_by(info_hash=info_hash)
|
||||
return db.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
@classmethod
|
||||
def by_info_hash_hex(cls, info_hash_hex):
|
||||
def by_info_hash_hex(cls, info_hash_hex: str) -> Optional['TorrentBase']:
|
||||
"""Get a torrent by its hex-encoded info hash."""
|
||||
info_hash_bytes = bytearray.fromhex(info_hash_hex)
|
||||
return cls.by_info_hash(info_hash_bytes)
|
||||
|
||||
|
@ -332,8 +351,10 @@ class Trackers(db.Model):
|
|||
disabled = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
@classmethod
|
||||
def by_uri(cls, uri):
|
||||
return cls.query.filter_by(uri=uri).first()
|
||||
def by_uri(cls, uri: str) -> Optional['Trackers']:
|
||||
"""Get a tracker by its URI."""
|
||||
stmt = select(cls).filter_by(uri=uri)
|
||||
return db.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
class TorrentTrackersBase(DeclarativeHelperBase):
|
||||
|
@ -356,8 +377,10 @@ class TorrentTrackersBase(DeclarativeHelperBase):
|
|||
return db.relationship('Trackers', uselist=False, lazy='joined')
|
||||
|
||||
@classmethod
|
||||
def by_torrent_id(cls, torrent_id):
|
||||
return cls.query.filter_by(torrent_id=torrent_id).order_by(cls.order.desc())
|
||||
def by_torrent_id(cls, torrent_id: int) -> List['TorrentTrackersBase']:
|
||||
"""Get all trackers for a torrent, ordered by their order field."""
|
||||
stmt = select(cls).filter_by(torrent_id=torrent_id).order_by(cls.order.desc())
|
||||
return db.session.execute(stmt).scalars().all()
|
||||
|
||||
|
||||
class MainCategoryBase(DeclarativeHelperBase):
|
||||
|
@ -382,8 +405,10 @@ class MainCategoryBase(DeclarativeHelperBase):
|
|||
return '_'.join(str(x) for x in self.get_category_ids())
|
||||
|
||||
@classmethod
|
||||
def by_id(cls, id):
|
||||
return cls.query.get(id)
|
||||
def by_id(cls, id: int) -> Optional['MainCategoryBase']:
|
||||
"""Get a main category by its ID."""
|
||||
stmt = select(cls).filter_by(id=id)
|
||||
return db.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
class SubCategoryBase(DeclarativeHelperBase):
|
||||
|
@ -411,8 +436,10 @@ class SubCategoryBase(DeclarativeHelperBase):
|
|||
return '_'.join(str(x) for x in self.get_category_ids())
|
||||
|
||||
@classmethod
|
||||
def by_category_ids(cls, main_cat_id, sub_cat_id):
|
||||
return cls.query.get((sub_cat_id, main_cat_id))
|
||||
def by_category_ids(cls, main_cat_id: int, sub_cat_id: int) -> Optional['SubCategoryBase']:
|
||||
"""Get a subcategory by its main category ID and subcategory ID."""
|
||||
stmt = select(cls).filter_by(id=sub_cat_id, main_category_id=main_cat_id)
|
||||
return db.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
class CommentBase(DeclarativeHelperBase):
|
||||
|
@ -493,8 +520,8 @@ class User(db.Model):
|
|||
|
||||
created_time = db.Column(db.DateTime(timezone=False), default=datetime.utcnow)
|
||||
last_login_date = db.Column(db.DateTime(timezone=False), default=None, nullable=True)
|
||||
last_login_ip = db.Column(db.Binary(length=16), default=None, nullable=True)
|
||||
registration_ip = db.Column(db.Binary(length=16), default=None, nullable=True)
|
||||
last_login_ip = db.Column(db.LargeBinary(length=16), default=None, nullable=True)
|
||||
registration_ip = db.Column(db.LargeBinary(length=16), default=None, nullable=True)
|
||||
|
||||
nyaa_torrents = db.relationship('NyaaTorrent', back_populates='user', lazy='dynamic')
|
||||
nyaa_comments = db.relationship('NyaaComment', back_populates='user', lazy='dynamic')
|
||||
|
@ -597,22 +624,26 @@ class User(db.Model):
|
|||
return str(ip_address(self.registration_ip))
|
||||
|
||||
@classmethod
|
||||
def by_id(cls, id):
|
||||
return cls.query.get(id)
|
||||
def by_id(cls, id: int) -> Optional['User']:
|
||||
"""Get a user by their ID."""
|
||||
stmt = select(cls).filter_by(id=id)
|
||||
return db.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
@classmethod
|
||||
def by_username(cls, username):
|
||||
def by_username(cls, username: str) -> Optional['User']:
|
||||
"""Get a user by their username."""
|
||||
def isascii(s): return len(s) == len(s.encode())
|
||||
if not isascii(username):
|
||||
return None
|
||||
|
||||
user = cls.query.filter_by(username=username).first()
|
||||
return user
|
||||
stmt = select(cls).filter_by(username=username)
|
||||
return db.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
@classmethod
|
||||
def by_email(cls, email):
|
||||
user = cls.query.filter_by(email=email).first()
|
||||
return user
|
||||
def by_email(cls, email: str) -> Optional['User']:
|
||||
"""Get a user by their email."""
|
||||
stmt = select(cls).filter_by(email=email)
|
||||
return db.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
@classmethod
|
||||
def by_username_or_email(cls, username_or_email):
|
||||
|
@ -649,20 +680,28 @@ class User(db.Model):
|
|||
return (self.created_time - UTC_EPOCH).total_seconds()
|
||||
|
||||
@property
|
||||
def satisfies_trusted_reqs(self):
|
||||
def satisfies_trusted_reqs(self) -> bool:
|
||||
"""Check if the user meets the requirements to be trusted."""
|
||||
num_total = 0
|
||||
downloads_total = 0
|
||||
for ts_flavor, t_flavor in ((NyaaStatistic, NyaaTorrent),
|
||||
(SukebeiStatistic, SukebeiTorrent)):
|
||||
uploads = db.session.query(func.count(t_flavor.id)).\
|
||||
# Count uploads that aren't remakes
|
||||
stmt = select(func.count(t_flavor.id)).\
|
||||
filter(t_flavor.user == self).\
|
||||
filter(t_flavor.flags.op('&')(int(TorrentFlags.REMAKE)).is_(False)).scalar()
|
||||
dls = db.session.query(func.sum(ts_flavor.download_count)).\
|
||||
filter(t_flavor.flags.op('&')(int(TorrentFlags.REMAKE)).is_(False))
|
||||
uploads = db.session.execute(stmt).scalar_one_or_none() or 0
|
||||
|
||||
# Sum download counts for user's torrents that aren't remakes
|
||||
stmt = select(func.sum(ts_flavor.download_count)).\
|
||||
join(t_flavor).\
|
||||
filter(t_flavor.user == self).\
|
||||
filter(t_flavor.flags.op('&')(int(TorrentFlags.REMAKE)).is_(False)).scalar()
|
||||
num_total += uploads or 0
|
||||
downloads_total += dls or 0
|
||||
filter(t_flavor.flags.op('&')(int(TorrentFlags.REMAKE)).is_(False))
|
||||
dls = db.session.execute(stmt).scalar_one_or_none() or 0
|
||||
|
||||
num_total += uploads
|
||||
downloads_total += dls
|
||||
|
||||
return (num_total >= config['TRUSTED_MIN_UPLOADS'] and
|
||||
downloads_total >= config['TRUSTED_MIN_DOWNLOADS'])
|
||||
|
||||
|
@ -711,7 +750,8 @@ class AdminLogBase(DeclarativeHelperBase):
|
|||
|
||||
@classmethod
|
||||
def all_logs(cls):
|
||||
return cls.query
|
||||
"""Get a query for all admin logs."""
|
||||
return db.session.query(cls)
|
||||
|
||||
|
||||
class ReportStatus(IntEnum):
|
||||
|
@ -760,17 +800,26 @@ class ReportBase(DeclarativeHelperBase):
|
|||
return (self.created_time - UTC_EPOCH).total_seconds()
|
||||
|
||||
@classmethod
|
||||
def by_id(cls, id):
|
||||
return cls.query.get(id)
|
||||
def by_id(cls, id: int) -> Optional['ReportBase']:
|
||||
"""Get a report by its ID."""
|
||||
stmt = select(cls).filter_by(id=id)
|
||||
return db.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
@classmethod
|
||||
def not_reviewed(cls, page):
|
||||
reports = cls.query.filter_by(status=0).paginate(page=page, per_page=20)
|
||||
return reports
|
||||
def not_reviewed(cls, page: int):
|
||||
"""Get paginated reports that haven't been reviewed yet."""
|
||||
# Note: paginate is a Flask-SQLAlchemy extension method, not standard SQLAlchemy
|
||||
# We'll keep using it for now, but it should be updated to use the new pagination API
|
||||
# in a future update
|
||||
stmt = select(cls).filter_by(status=0)
|
||||
return db.paginate(stmt, page=page, per_page=20)
|
||||
|
||||
@classmethod
|
||||
def remove_reviewed(cls, id):
|
||||
return cls.query.filter(cls.torrent_id == id, cls.status == 0).delete()
|
||||
def remove_reviewed(cls, id: int) -> int:
|
||||
"""Remove all reports for a torrent that haven't been reviewed yet."""
|
||||
stmt = db.delete(cls).filter(cls.torrent_id == id, cls.status == 0)
|
||||
result = db.session.execute(stmt)
|
||||
return result.rowcount
|
||||
|
||||
|
||||
class Ban(db.Model):
|
||||
|
@ -780,7 +829,7 @@ class Ban(db.Model):
|
|||
created_time = db.Column(db.DateTime(timezone=False), default=datetime.utcnow)
|
||||
admin_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
user_ip = db.Column(db.Binary(length=16), nullable=True)
|
||||
user_ip = db.Column(db.LargeBinary(length=16), nullable=True)
|
||||
reason = db.Column(db.String(length=2048), nullable=False)
|
||||
|
||||
admin = db.relationship('User', uselist=False, lazy='joined', foreign_keys=[admin_id])
|
||||
|
@ -801,20 +850,27 @@ class Ban(db.Model):
|
|||
|
||||
@classmethod
|
||||
def all_bans(cls):
|
||||
return cls.query
|
||||
"""Get a query for all bans."""
|
||||
return db.session.query(cls)
|
||||
|
||||
@classmethod
|
||||
def by_id(cls, id):
|
||||
return cls.query.get(id)
|
||||
def by_id(cls, id: int) -> Optional['Ban']:
|
||||
"""Get a ban by its ID."""
|
||||
stmt = select(cls).filter_by(id=id)
|
||||
return db.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
@classmethod
|
||||
def banned(cls, user_id, user_ip):
|
||||
def banned(cls, user_id: Optional[int], user_ip: Optional[bytes]):
|
||||
"""Check if a user or IP is banned."""
|
||||
if user_id:
|
||||
if user_ip:
|
||||
return cls.query.filter((cls.user_id == user_id) | (cls.user_ip == user_ip))
|
||||
return cls.query.filter(cls.user_id == user_id)
|
||||
stmt = select(cls).filter((cls.user_id == user_id) | (cls.user_ip == user_ip))
|
||||
return db.session.execute(stmt).scalars().all()
|
||||
stmt = select(cls).filter(cls.user_id == user_id)
|
||||
return db.session.execute(stmt).scalars().all()
|
||||
if user_ip:
|
||||
return cls.query.filter(cls.user_ip == user_ip)
|
||||
stmt = select(cls).filter(cls.user_ip == user_ip)
|
||||
return db.session.execute(stmt).scalars().all()
|
||||
return None
|
||||
|
||||
|
||||
|
@ -858,15 +914,17 @@ class RangeBan(db.Model):
|
|||
self._cidr_string = s
|
||||
|
||||
@classmethod
|
||||
def is_rangebanned(cls, ip):
|
||||
def is_rangebanned(cls, ip: bytes) -> bool:
|
||||
"""Check if an IP is within a banned range."""
|
||||
if len(ip) > 4:
|
||||
raise NotImplementedError("IPv6 is unsupported.")
|
||||
elif len(ip) < 4:
|
||||
raise ValueError("Not an IP address.")
|
||||
ip_int = int.from_bytes(ip, 'big')
|
||||
q = cls.query.filter(cls.mask.op('&')(ip_int) == cls.masked_cidr,
|
||||
cls.enabled)
|
||||
return q.count() > 0
|
||||
stmt = select(cls).filter(cls.mask.op('&')(ip_int) == cls.masked_cidr,
|
||||
cls.enabled)
|
||||
count = db.session.execute(select(func.count()).select_from(stmt.subquery())).scalar_one()
|
||||
return count > 0
|
||||
|
||||
|
||||
class TrustedApplicationStatus(IntEnum):
|
||||
|
@ -915,8 +973,10 @@ class TrustedApplication(db.Model):
|
|||
return (self.created_time - UTC_EPOCH).total_seconds()
|
||||
|
||||
@classmethod
|
||||
def by_id(cls, id):
|
||||
return cls.query.get(id)
|
||||
def by_id(cls, id: int) -> Optional['TrustedApplication']:
|
||||
"""Get a trusted application by its ID."""
|
||||
stmt = select(cls).filter_by(id=id)
|
||||
return db.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
|
||||
class TrustedRecommendation(IntEnum):
|
||||
|
|
393
nyaa/search.py
393
nyaa/search.py
|
@ -3,15 +3,16 @@ import re
|
|||
import shlex
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
import flask
|
||||
from flask_sqlalchemy import Pagination
|
||||
from nyaa.custom_pagination import CustomPagination
|
||||
|
||||
import sqlalchemy
|
||||
from sqlalchemy import select, func, bindparam
|
||||
import sqlalchemy_fulltext.modes as FullTextMode
|
||||
from elasticsearch import Elasticsearch
|
||||
from elasticsearch_dsl import Q, Search
|
||||
from sqlalchemy.ext import baked
|
||||
from sqlalchemy_fulltext import FullTextSearch
|
||||
|
||||
from nyaa import models
|
||||
|
@ -30,7 +31,7 @@ SERACH_PAGINATE_DISPLAY_MSG = ('Displaying results {start}-{end} out of {total}
|
|||
_index_name_cache = {}
|
||||
|
||||
|
||||
def _get_index_name(column):
|
||||
def _get_index_name(column) -> Optional[str]:
|
||||
''' Returns an index name for a given column, or None.
|
||||
Only considers single-column indexes.
|
||||
Results are cached in memory (until app restart). '''
|
||||
|
@ -43,7 +44,7 @@ def _get_index_name(column):
|
|||
try:
|
||||
column_table = sqlalchemy.Table(column_table_name,
|
||||
sqlalchemy.MetaData(),
|
||||
autoload=True, autoload_with=db.engine)
|
||||
autoload_with=db.engine)
|
||||
except sqlalchemy.exc.NoSuchTableError:
|
||||
# Trust the developer to notice this?
|
||||
pass
|
||||
|
@ -60,7 +61,8 @@ def _get_index_name(column):
|
|||
return table_indexes.get(column.name)
|
||||
|
||||
|
||||
def _generate_query_string(term, category, filter, user):
|
||||
def _generate_query_string(term: Optional[str], category: Optional[str],
|
||||
filter: Optional[str], user: Optional[int]) -> Dict[str, str]:
|
||||
params = {}
|
||||
if term:
|
||||
params['q'] = str(term)
|
||||
|
@ -370,16 +372,23 @@ class QueryPairCaller(object):
|
|||
return wrapper
|
||||
|
||||
|
||||
def search_db(term='', user=None, sort='id', order='desc', category='0_0',
|
||||
quality_filter='0', page=1, rss=False, admin=False,
|
||||
logged_in_user=None, per_page=75):
|
||||
def search_db(term: str = '', user: Optional[int] = None, sort: str = 'id',
|
||||
order: str = 'desc', category: str = '0_0',
|
||||
quality_filter: str = '0', page: int = 1, rss: bool = False,
|
||||
admin: bool = False, logged_in_user: Optional[models.User] = None,
|
||||
per_page: int = 75) -> Union[CustomPagination, List[models.Torrent]]:
|
||||
"""
|
||||
Search the database for torrents matching the given criteria.
|
||||
|
||||
This is the SQLAlchemy 2.0 compatible version of the search function.
|
||||
"""
|
||||
if page > 4294967295:
|
||||
flask.abort(404)
|
||||
|
||||
MAX_PAGES = app.config.get("MAX_PAGES", 0)
|
||||
|
||||
same_user = False
|
||||
if logged_in_user:
|
||||
if logged_in_user and user:
|
||||
same_user = logged_in_user.id == user
|
||||
|
||||
# Logged in users should always be able to view their full listing.
|
||||
|
@ -427,10 +436,10 @@ def search_db(term='', user=None, sort='id', order='desc', category='0_0',
|
|||
flask.abort(400)
|
||||
|
||||
if user:
|
||||
user = models.User.by_id(user)
|
||||
if not user:
|
||||
user_obj = models.User.by_id(user)
|
||||
if not user_obj:
|
||||
flask.abort(404)
|
||||
user = user.id
|
||||
user = user_obj.id
|
||||
|
||||
main_category = None
|
||||
sub_category = None
|
||||
|
@ -460,22 +469,22 @@ def search_db(term='', user=None, sort='id', order='desc', category='0_0',
|
|||
|
||||
model_class = models.TorrentNameSearch if term else models.Torrent
|
||||
|
||||
query = db.session.query(model_class)
|
||||
|
||||
# This is... eh. Optimize the COUNT() query since MySQL is bad at that.
|
||||
# See http://docs.sqlalchemy.org/en/rel_1_1/orm/query.html#sqlalchemy.orm.query.Query.count
|
||||
# Wrap the queries into the helper class to deduplicate code and apply filters to both in one go
|
||||
count_query = db.session.query(sqlalchemy.func.count(model_class.id))
|
||||
qpc = QueryPairCaller(query, count_query)
|
||||
# Create the base query
|
||||
query = select(model_class)
|
||||
count_query = select(func.count(model_class.id))
|
||||
|
||||
# User view (/user/username)
|
||||
if user:
|
||||
qpc.filter(models.Torrent.uploader_id == user)
|
||||
query = query.where(models.Torrent.uploader_id == user)
|
||||
count_query = count_query.where(models.Torrent.uploader_id == user)
|
||||
|
||||
if not admin:
|
||||
# Hide all DELETED torrents if regular user
|
||||
qpc.filter(models.Torrent.flags.op('&')(
|
||||
int(models.TorrentFlags.DELETED)).is_(False))
|
||||
deleted_filter = models.Torrent.flags.op('&')(
|
||||
int(models.TorrentFlags.DELETED)).is_(False)
|
||||
query = query.where(deleted_filter)
|
||||
count_query = count_query.where(deleted_filter)
|
||||
|
||||
# If logged in user is not the same as the user being viewed,
|
||||
# show only torrents that aren't hidden or anonymous
|
||||
#
|
||||
|
@ -485,277 +494,99 @@ def search_db(term='', user=None, sort='id', order='desc', category='0_0',
|
|||
# On RSS pages in user view,
|
||||
# show only torrents that aren't hidden or anonymous no matter what
|
||||
if not same_user or rss:
|
||||
qpc.filter(models.Torrent.flags.op('&')(
|
||||
int(models.TorrentFlags.HIDDEN | models.TorrentFlags.ANONYMOUS)).is_(False))
|
||||
hidden_anon_filter = models.Torrent.flags.op('&')(
|
||||
int(models.TorrentFlags.HIDDEN | models.TorrentFlags.ANONYMOUS)).is_(False)
|
||||
query = query.where(hidden_anon_filter)
|
||||
count_query = count_query.where(hidden_anon_filter)
|
||||
# General view (homepage, general search view)
|
||||
else:
|
||||
if not admin:
|
||||
# Hide all DELETED torrents if regular user
|
||||
qpc.filter(models.Torrent.flags.op('&')(
|
||||
int(models.TorrentFlags.DELETED)).is_(False))
|
||||
deleted_filter = models.Torrent.flags.op('&')(
|
||||
int(models.TorrentFlags.DELETED)).is_(False)
|
||||
query = query.where(deleted_filter)
|
||||
count_query = count_query.where(deleted_filter)
|
||||
|
||||
# If logged in, show all torrents that aren't hidden unless they belong to you
|
||||
# On RSS pages, show all public torrents and nothing more.
|
||||
if logged_in_user and not rss:
|
||||
qpc.filter(
|
||||
hidden_or_user_filter = (
|
||||
(models.Torrent.flags.op('&')(int(models.TorrentFlags.HIDDEN)).is_(False)) |
|
||||
(models.Torrent.uploader_id == logged_in_user.id))
|
||||
(models.Torrent.uploader_id == logged_in_user.id)
|
||||
)
|
||||
query = query.where(hidden_or_user_filter)
|
||||
count_query = count_query.where(hidden_or_user_filter)
|
||||
# Otherwise, show all torrents that aren't hidden
|
||||
else:
|
||||
qpc.filter(models.Torrent.flags.op('&')(
|
||||
int(models.TorrentFlags.HIDDEN)).is_(False))
|
||||
hidden_filter = models.Torrent.flags.op('&')(
|
||||
int(models.TorrentFlags.HIDDEN)).is_(False)
|
||||
query = query.where(hidden_filter)
|
||||
count_query = count_query.where(hidden_filter)
|
||||
|
||||
if main_category:
|
||||
qpc.filter(models.Torrent.main_category_id == main_cat_id)
|
||||
main_cat_filter = models.Torrent.main_category_id == main_cat_id
|
||||
query = query.where(main_cat_filter)
|
||||
count_query = count_query.where(main_cat_filter)
|
||||
elif sub_category:
|
||||
qpc.filter((models.Torrent.main_category_id == main_cat_id) &
|
||||
(models.Torrent.sub_category_id == sub_cat_id))
|
||||
sub_cat_filter = (
|
||||
(models.Torrent.main_category_id == main_cat_id) &
|
||||
(models.Torrent.sub_category_id == sub_cat_id)
|
||||
)
|
||||
query = query.where(sub_cat_filter)
|
||||
count_query = count_query.where(sub_cat_filter)
|
||||
|
||||
if filter_tuple:
|
||||
qpc.filter(models.Torrent.flags.op('&')(
|
||||
int(filter_tuple[0])).is_(filter_tuple[1]))
|
||||
filter_condition = models.Torrent.flags.op('&')(
|
||||
int(filter_tuple[0])).is_(filter_tuple[1])
|
||||
query = query.where(filter_condition)
|
||||
count_query = count_query.where(filter_condition)
|
||||
|
||||
if term:
|
||||
for item in shlex.split(term, posix=False):
|
||||
if len(item) >= 2:
|
||||
qpc.filter(FullTextSearch(
|
||||
item, models.TorrentNameSearch, FullTextMode.NATURAL))
|
||||
query, count_query = qpc.items
|
||||
fulltext_filter = FullTextSearch(
|
||||
item, models.TorrentNameSearch, FullTextMode.NATURAL)
|
||||
query = query.where(fulltext_filter)
|
||||
count_query = count_query.where(fulltext_filter)
|
||||
|
||||
# Sort and order
|
||||
if sort_column.class_ != models.Torrent:
|
||||
index_name = _get_index_name(sort_column)
|
||||
query = query.join(sort_column.class_)
|
||||
query = query.with_hint(sort_column.class_, 'USE INDEX ({0})'.format(index_name))
|
||||
|
||||
query = query.order_by(getattr(sort_column, order)())
|
||||
|
||||
# Add index hint for MySQL if available
|
||||
if index_name and hasattr(db.engine.dialect, 'name') and db.engine.dialect.name == 'mysql':
|
||||
# In SQLAlchemy 2.0, we use execution_options instead of with_hint
|
||||
# This is MySQL specific - for other databases, different approaches would be needed
|
||||
query = query.execution_options(
|
||||
mysql_hint=f"USE INDEX ({index_name})"
|
||||
)
|
||||
|
||||
if order_ == 'desc':
|
||||
query = query.order_by(sort_column.desc())
|
||||
else:
|
||||
query = query.order_by(sort_column.asc())
|
||||
|
||||
if rss:
|
||||
query = query.limit(per_page)
|
||||
return db.session.execute(query).scalars().all()
|
||||
else:
|
||||
query = query.paginate_faste(page, per_page=per_page, step=5, count_query=count_query,
|
||||
max_page=MAX_PAGES)
|
||||
|
||||
return query
|
||||
|
||||
|
||||
# Baked queries follow
|
||||
|
||||
class BakedPair(object):
|
||||
def __init__(self, *items):
|
||||
self.items = list(items)
|
||||
|
||||
def __iadd__(self, other):
|
||||
for item in self.items:
|
||||
item += other
|
||||
|
||||
return self
|
||||
|
||||
|
||||
bakery = baked.bakery()
|
||||
|
||||
|
||||
BAKED_SORT_KEYS = {
|
||||
'id': models.Torrent.id,
|
||||
'size': models.Torrent.filesize,
|
||||
'comments': models.Torrent.comment_count,
|
||||
'seeders': models.Statistic.seed_count,
|
||||
'leechers': models.Statistic.leech_count,
|
||||
'downloads': models.Statistic.download_count
|
||||
}
|
||||
|
||||
BAKED_SORT_LAMBDAS = {
|
||||
'id-asc': lambda q: q.order_by(models.Torrent.id.asc()),
|
||||
'id-desc': lambda q: q.order_by(models.Torrent.id.desc()),
|
||||
|
||||
'size-asc': lambda q: q.order_by(models.Torrent.filesize.asc()),
|
||||
'size-desc': lambda q: q.order_by(models.Torrent.filesize.desc()),
|
||||
|
||||
'comments-asc': lambda q: q.order_by(models.Torrent.comment_count.asc()),
|
||||
'comments-desc': lambda q: q.order_by(models.Torrent.comment_count.desc()),
|
||||
|
||||
# This is a bit stupid, but programmatically generating these mixed up the baked keys, so deal.
|
||||
'seeders-asc': lambda q: q.join(models.Statistic).with_hint(
|
||||
models.Statistic, 'USE INDEX (idx_nyaa_statistics_seed_count)'
|
||||
).order_by(models.Statistic.seed_count.asc(), models.Torrent.id.asc()),
|
||||
'seeders-desc': lambda q: q.join(models.Statistic).with_hint(
|
||||
models.Statistic, 'USE INDEX (idx_nyaa_statistics_seed_count)'
|
||||
).order_by(models.Statistic.seed_count.desc(), models.Torrent.id.desc()),
|
||||
|
||||
'leechers-asc': lambda q: q.join(models.Statistic).with_hint(
|
||||
models.Statistic, 'USE INDEX (idx_nyaa_statistics_leech_count)'
|
||||
).order_by(models.Statistic.leech_count.asc(), models.Torrent.id.asc()),
|
||||
'leechers-desc': lambda q: q.join(models.Statistic).with_hint(
|
||||
models.Statistic, 'USE INDEX (idx_nyaa_statistics_leech_count)'
|
||||
).order_by(models.Statistic.leech_count.desc(), models.Torrent.id.desc()),
|
||||
|
||||
'downloads-asc': lambda q: q.join(models.Statistic).with_hint(
|
||||
models.Statistic, 'USE INDEX (idx_nyaa_statistics_download_count)'
|
||||
).order_by(models.Statistic.download_count.asc(), models.Torrent.id.asc()),
|
||||
'downloads-desc': lambda q: q.join(models.Statistic).with_hint(
|
||||
models.Statistic, 'USE INDEX (idx_nyaa_statistics_download_count)'
|
||||
).order_by(models.Statistic.download_count.desc(), models.Torrent.id.desc()),
|
||||
}
|
||||
|
||||
|
||||
BAKED_FILTER_LAMBDAS = {
|
||||
'0': None,
|
||||
'1': lambda q: (
|
||||
q.filter(models.Torrent.flags.op('&')(models.TorrentFlags.REMAKE.value).is_(False))
|
||||
),
|
||||
'2': lambda q: (
|
||||
q.filter(models.Torrent.flags.op('&')(models.TorrentFlags.TRUSTED.value).is_(True))
|
||||
),
|
||||
'3': lambda q: (
|
||||
q.filter(models.Torrent.flags.op('&')(models.TorrentFlags.COMPLETE.value).is_(True))
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def search_db_baked(term='', user=None, sort='id', order='desc', category='0_0',
|
||||
quality_filter='0', page=1, rss=False, admin=False,
|
||||
logged_in_user=None, per_page=75):
|
||||
if page > 4294967295:
|
||||
flask.abort(404)
|
||||
|
||||
MAX_PAGES = app.config.get("MAX_PAGES", 0)
|
||||
|
||||
if MAX_PAGES and page > MAX_PAGES:
|
||||
flask.abort(flask.Response("You've exceeded the maximum number of pages. Please "
|
||||
"make your search query less broad.", 403))
|
||||
|
||||
sort_lambda = BAKED_SORT_LAMBDAS.get('{}-{}'.format(sort, order).lower())
|
||||
if not sort_lambda:
|
||||
flask.abort(400)
|
||||
|
||||
sentinel = object()
|
||||
filter_lambda = BAKED_FILTER_LAMBDAS.get(quality_filter.lower(), sentinel)
|
||||
if filter_lambda is sentinel:
|
||||
flask.abort(400)
|
||||
|
||||
if user:
|
||||
user = models.User.by_id(user)
|
||||
if not user:
|
||||
# Get the total count
|
||||
total_count = db.session.execute(count_query).scalar_one()
|
||||
|
||||
# Apply pagination
|
||||
query = query.limit(per_page).offset((page - 1) * per_page)
|
||||
items = db.session.execute(query).scalars().all()
|
||||
|
||||
if not items and page != 1:
|
||||
flask.abort(404)
|
||||
user = user.id
|
||||
|
||||
# Create a pagination object
|
||||
return CustomPagination(query, page, per_page, total_count, items)
|
||||
|
||||
main_cat_id = 0
|
||||
sub_cat_id = 0
|
||||
|
||||
if category:
|
||||
cat_match = re.match(r'^(\d+)_(\d+)$', category)
|
||||
if not cat_match:
|
||||
flask.abort(400)
|
||||
|
||||
main_cat_id = int(cat_match.group(1))
|
||||
sub_cat_id = int(cat_match.group(2))
|
||||
|
||||
if main_cat_id > 0:
|
||||
if sub_cat_id > 0:
|
||||
sub_category = models.SubCategory.by_category_ids(main_cat_id, sub_cat_id)
|
||||
if not sub_category:
|
||||
flask.abort(400)
|
||||
else:
|
||||
main_category = models.MainCategory.by_id(main_cat_id)
|
||||
if not main_category:
|
||||
flask.abort(400)
|
||||
|
||||
# Force sort by id desc if rss
|
||||
if rss:
|
||||
sort_lambda = BAKED_SORT_LAMBDAS['id-desc']
|
||||
|
||||
same_user = False
|
||||
if logged_in_user:
|
||||
same_user = logged_in_user.id == user
|
||||
|
||||
if term:
|
||||
query = bakery(lambda session: session.query(models.TorrentNameSearch))
|
||||
count_query = bakery(lambda session: session.query(
|
||||
sqlalchemy.func.count(models.TorrentNameSearch.id)))
|
||||
else:
|
||||
query = bakery(lambda session: session.query(models.Torrent))
|
||||
# This is... eh. Optimize the COUNT() query since MySQL is bad at that.
|
||||
# See http://docs.sqlalchemy.org/en/rel_1_1/orm/query.html#sqlalchemy.orm.query.Query.count
|
||||
# Wrap the queries into the helper class to deduplicate code and
|
||||
# apply filters to both in one go
|
||||
count_query = bakery(lambda session: session.query(
|
||||
sqlalchemy.func.count(models.Torrent.id)))
|
||||
|
||||
qpc = BakedPair(query, count_query)
|
||||
bp = sqlalchemy.bindparam
|
||||
|
||||
baked_params = {}
|
||||
|
||||
# User view (/user/username)
|
||||
if user:
|
||||
qpc += lambda q: q.filter(models.Torrent.uploader_id == bp('user'))
|
||||
baked_params['user'] = user
|
||||
|
||||
if not admin:
|
||||
# Hide all DELETED torrents if regular user
|
||||
qpc += lambda q: q.filter(models.Torrent.flags.op('&')
|
||||
(int(models.TorrentFlags.DELETED)).is_(False))
|
||||
# If logged in user is not the same as the user being viewed,
|
||||
# show only torrents that aren't hidden or anonymous
|
||||
#
|
||||
# If logged in user is the same as the user being viewed,
|
||||
# show all torrents including hidden and anonymous ones
|
||||
#
|
||||
# On RSS pages in user view,
|
||||
# show only torrents that aren't hidden or anonymous no matter what
|
||||
if not same_user or rss:
|
||||
qpc += lambda q: (
|
||||
q.filter(
|
||||
models.Torrent.flags.op('&')(
|
||||
int(models.TorrentFlags.HIDDEN | models.TorrentFlags.ANONYMOUS)
|
||||
).is_(False)
|
||||
)
|
||||
)
|
||||
# General view (homepage, general search view)
|
||||
else:
|
||||
if not admin:
|
||||
# Hide all DELETED torrents if regular user
|
||||
qpc += lambda q: q.filter(models.Torrent.flags.op('&')
|
||||
(int(models.TorrentFlags.DELETED)).is_(False))
|
||||
# If logged in, show all torrents that aren't hidden unless they belong to you
|
||||
# On RSS pages, show all public torrents and nothing more.
|
||||
if logged_in_user and not rss:
|
||||
qpc += lambda q: q.filter(
|
||||
(models.Torrent.flags.op('&')(int(models.TorrentFlags.HIDDEN)).is_(False)) |
|
||||
(models.Torrent.uploader_id == bp('logged_in_user'))
|
||||
)
|
||||
baked_params['logged_in_user'] = logged_in_user
|
||||
# Otherwise, show all torrents that aren't hidden
|
||||
else:
|
||||
qpc += lambda q: q.filter(models.Torrent.flags.op('&')
|
||||
(int(models.TorrentFlags.HIDDEN)).is_(False))
|
||||
|
||||
if sub_cat_id:
|
||||
qpc += lambda q: q.filter(
|
||||
(models.Torrent.main_category_id == bp('main_cat_id')),
|
||||
(models.Torrent.sub_category_id == bp('sub_cat_id'))
|
||||
)
|
||||
baked_params['main_cat_id'] = main_cat_id
|
||||
baked_params['sub_cat_id'] = sub_cat_id
|
||||
elif main_cat_id:
|
||||
qpc += lambda q: q.filter(models.Torrent.main_category_id == bp('main_cat_id'))
|
||||
baked_params['main_cat_id'] = main_cat_id
|
||||
|
||||
if filter_lambda:
|
||||
qpc += filter_lambda
|
||||
|
||||
if term:
|
||||
raise Exception('Baked search does not support search terms')
|
||||
|
||||
# Sort and order
|
||||
query += sort_lambda
|
||||
|
||||
if rss:
|
||||
query += lambda q: q.limit(bp('per_page'))
|
||||
baked_params['per_page'] = per_page
|
||||
|
||||
return query(db.session()).params(**baked_params).all()
|
||||
|
||||
return baked_paginate(query, count_query, baked_params,
|
||||
page, per_page=per_page, step=5, max_page=MAX_PAGES)
|
||||
# Alias for backward compatibility
|
||||
search_db_baked = search_db
|
||||
|
||||
|
||||
class ShoddyLRU(object):
|
||||
|
@ -800,33 +631,33 @@ class ShoddyLRU(object):
|
|||
LRU_CACHE = ShoddyLRU(256, 60)
|
||||
|
||||
|
||||
def baked_paginate(query, count_query, params, page=1, per_page=50, max_page=None, step=5):
|
||||
def paginate_query(query, count_query, page=1, per_page=50, max_page=None):
|
||||
"""
|
||||
Paginate a SQLAlchemy 2.0 query.
|
||||
|
||||
This is a replacement for the baked_paginate function that uses SQLAlchemy 2.0 style.
|
||||
"""
|
||||
if page < 1:
|
||||
flask.abort(404)
|
||||
|
||||
if max_page and page > max_page:
|
||||
flask.abort(404)
|
||||
bp = sqlalchemy.bindparam
|
||||
|
||||
ses = db.session()
|
||||
|
||||
# Count all items, use cache
|
||||
if app.config['COUNT_CACHE_DURATION']:
|
||||
query_key = (count_query._effective_key(ses), tuple(sorted(params.items())))
|
||||
if app.config.get('COUNT_CACHE_DURATION'):
|
||||
# Create a cache key based on the query and parameters
|
||||
# This is a simplified version compared to the bakery's _effective_key
|
||||
query_key = str(count_query)
|
||||
total_query_count = LRU_CACHE.get(query_key)
|
||||
if total_query_count is None:
|
||||
total_query_count = count_query(ses).params(**params).scalar()
|
||||
total_query_count = db.session.execute(count_query).scalar_one()
|
||||
LRU_CACHE.put(query_key, total_query_count, expiry=app.config['COUNT_CACHE_DURATION'])
|
||||
else:
|
||||
total_query_count = count_query(ses).params(**params).scalar()
|
||||
total_query_count = db.session.execute(count_query).scalar_one()
|
||||
|
||||
# Grab items on current page
|
||||
query += lambda q: q.limit(bp('limit')).offset(bp('offset'))
|
||||
params['limit'] = per_page
|
||||
params['offset'] = (page - 1) * per_page
|
||||
|
||||
res = query(ses).params(**params)
|
||||
items = res.all()
|
||||
# Apply pagination
|
||||
paginated_query = query.limit(per_page).offset((page - 1) * per_page)
|
||||
items = db.session.execute(paginated_query).scalars().all()
|
||||
|
||||
if max_page:
|
||||
total_query_count = min(total_query_count, max_page * per_page)
|
||||
|
@ -837,4 +668,8 @@ def baked_paginate(query, count_query, params, page=1, per_page=50, max_page=Non
|
|||
if not items and page != 1:
|
||||
flask.abort(404)
|
||||
|
||||
return Pagination(None, page, per_page, total_query_count, items)
|
||||
return CustomPagination(None, page, per_page, total_query_count, items)
|
||||
|
||||
|
||||
# Alias for backward compatibility
|
||||
baked_paginate = paginate_query
|
||||
|
|
|
@ -5,7 +5,8 @@ from datetime import datetime
|
|||
from email.utils import formatdate
|
||||
|
||||
import flask
|
||||
from werkzeug.urls import url_encode
|
||||
#from werkzeug.utils import url_encode
|
||||
from urllib.parse import urlencode # now using Python's built-in urlencode
|
||||
|
||||
from nyaa.backend import get_category_id_map
|
||||
from nyaa.torrents import create_magnet
|
||||
|
@ -81,7 +82,7 @@ def modify_query(**new_values):
|
|||
for key, value in new_values.items():
|
||||
args[key] = value
|
||||
|
||||
return '{}?{}'.format(flask.request.path, url_encode(args))
|
||||
return '{}?{}'.format(flask.request.path, urlencode(args))
|
||||
|
||||
|
||||
@bp.app_template_global()
|
||||
|
|
|
@ -108,8 +108,8 @@
|
|||
|
||||
{{ linkable_header("IRC Help Channel Policies", "irchelp") }}
|
||||
<div>
|
||||
<p>Our IRC help channel is at Rizon <a href="irc://irc.rizon.net/nyaa-help">#nyaa-help</a>. A webchat link
|
||||
pre-filled with our channel is available <a href="https://qchat.rizon.net/?channels=nyaa-help">right here</a>.</p>
|
||||
<p>Our IRC help channel is at Rizon <a>#nyaa-help</a>. A webchat link
|
||||
pre-filled with our channel is available <a>right here</a>.</p>
|
||||
|
||||
<b>Read this to avoid getting banned:</b>
|
||||
<ul>
|
||||
|
@ -199,7 +199,7 @@
|
|||
</li>
|
||||
</ul>
|
||||
<h1>IRC help channel</h1><a href="irc://irc-server:port/channel?key">
|
||||
<h1>NyaaV2 IRC</h1></a>
|
||||
<h1>NyaaV3 IRC</h1></a>
|
||||
<p>The IRC channel is only for site support.<br></p>
|
||||
<p><b>Read this to avoid getting banned:</b></p>
|
||||
<ul>
|
||||
|
|
|
@ -5,11 +5,11 @@ from urllib.parse import quote, urlencode
|
|||
import flask
|
||||
from flask import current_app as app
|
||||
|
||||
from orderedset import OrderedSet
|
||||
from orderly_set import OrderlySet
|
||||
|
||||
from nyaa import bencode
|
||||
|
||||
USED_TRACKERS = OrderedSet()
|
||||
USED_TRACKERS = OrderlySet()
|
||||
|
||||
|
||||
def read_trackers_from_file(file_object):
|
||||
|
@ -37,8 +37,8 @@ def default_trackers():
|
|||
|
||||
|
||||
def get_trackers_and_webseeds(torrent):
|
||||
trackers = OrderedSet()
|
||||
webseeds = OrderedSet()
|
||||
trackers = OrderlySet()
|
||||
webseeds = OrderlySet()
|
||||
|
||||
# Our main one first
|
||||
main_announce_url = app.config.get('MAIN_ANNOUNCE_URL')
|
||||
|
@ -63,7 +63,7 @@ def get_trackers_and_webseeds(torrent):
|
|||
|
||||
|
||||
def get_default_trackers():
|
||||
trackers = OrderedSet()
|
||||
trackers = OrderlySet()
|
||||
|
||||
# Our main one first
|
||||
main_announce_url = app.config.get('MAIN_ANNOUNCE_URL')
|
||||
|
@ -114,7 +114,7 @@ def create_default_metadata_base(torrent, trackers=None, webseeds=None):
|
|||
webseeds = db_webseeds if webseeds is None else webseeds
|
||||
|
||||
metadata_base = {
|
||||
'created by': 'NyaaV2',
|
||||
'created by': 'NyaaV3',
|
||||
'creation date': int(torrent.created_utc_timestamp),
|
||||
'comment': flask.url_for('torrents.view',
|
||||
torrent_id=torrent.id,
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import flask
|
||||
from markupsafe import Markup
|
||||
|
||||
from nyaa.views import ( # isort:skip
|
||||
account,
|
||||
|
@ -26,7 +27,7 @@ def _maintenance_mode_hook():
|
|||
return resp
|
||||
else:
|
||||
# Otherwise redirect to the target page and flash a message
|
||||
flask.flash(flask.Markup(message), 'danger')
|
||||
flask.flash(Markup(message), 'danger')
|
||||
try:
|
||||
target_url = flask.url_for(endpoint)
|
||||
except Exception:
|
||||
|
|
|
@ -2,6 +2,7 @@ import binascii
|
|||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from ipaddress import ip_address
|
||||
from markupsafe import Markup
|
||||
|
||||
import flask
|
||||
|
||||
|
@ -24,7 +25,7 @@ def login():
|
|||
form = forms.LoginForm(flask.request.form)
|
||||
if flask.request.method == 'POST' and form.validate():
|
||||
if app.config['MAINTENANCE_MODE'] and not app.config['MAINTENANCE_MODE_LOGINS']:
|
||||
flask.flash(flask.Markup('<strong>Logins are currently disabled.</strong>'), 'danger')
|
||||
flask.flash(Markup('<strong>Logins are currently disabled.</strong>'), 'danger')
|
||||
return flask.redirect(flask.url_for('account.login'))
|
||||
|
||||
username = form.username.data.strip()
|
||||
|
@ -38,20 +39,21 @@ def login():
|
|||
user = models.User.by_email(username)
|
||||
|
||||
if not user or password != user.password_hash:
|
||||
flask.flash(flask.Markup(
|
||||
flask.flash(Markup(
|
||||
'<strong>Login failed!</strong> Incorrect username or password.'), 'danger')
|
||||
return flask.redirect(flask.url_for('account.login'))
|
||||
|
||||
if user.is_banned:
|
||||
ban_reason = models.Ban.banned(user.id, None).first().reason
|
||||
ban_str = ('<strong>Login failed!</strong> You are banned with the '
|
||||
'reason "{0}" If you believe that this is a mistake, contact '
|
||||
ban = models.Ban.banned(user.id, None).first()
|
||||
ban_reason = ban.reason if ban else '[No reason provided]'
|
||||
ban_str = ('<strong>Login failed!</strong> You are banned. '
|
||||
'Reason: "{0}" If you believe this is a mistake, contact '
|
||||
'a moderator on IRC.'.format(ban_reason))
|
||||
flask.flash(flask.Markup(ban_str), 'danger')
|
||||
flask.flash(Markup(ban_str), 'danger')
|
||||
return flask.redirect(flask.url_for('account.login'))
|
||||
|
||||
if user.status != models.UserStatusType.ACTIVE:
|
||||
flask.flash(flask.Markup(
|
||||
flask.flash(Markup(
|
||||
'<strong>Login failed!</strong> Account is not activated.'), 'danger')
|
||||
return flask.redirect(flask.url_for('account.login'))
|
||||
|
||||
|
@ -78,7 +80,7 @@ def logout():
|
|||
flask.session.modified = False
|
||||
|
||||
response = flask.make_response(flask.redirect(redirect_url()))
|
||||
response.set_cookie(app.session_cookie_name, expires=0)
|
||||
response.set_cookie(app.config['SESSION_COOKIE_NAME'], expires=0)
|
||||
return response
|
||||
|
||||
|
||||
|
@ -113,7 +115,7 @@ def register():
|
|||
db.session.commit()
|
||||
|
||||
if app.config['RAID_MODE_LIMIT_REGISTER']:
|
||||
flask.flash(flask.Markup(app.config['RAID_MODE_REGISTER_MESSAGE'] + ' '
|
||||
flask.flash(Markup(app.config['RAID_MODE_REGISTER_MESSAGE'] + ' '
|
||||
'Please <a href="{}">ask a moderator</a> to manually '
|
||||
'activate your account <a href="{}">\'{}\'</a>.'
|
||||
.format(flask.url_for('site.help') + '#irchelp',
|
||||
|
@ -122,7 +124,7 @@ def register():
|
|||
user.username)), 'warning')
|
||||
|
||||
elif models.RangeBan.is_rangebanned(user.registration_ip):
|
||||
flask.flash(flask.Markup('Your IP is blocked from creating new accounts. '
|
||||
flask.flash(Markup('Your IP is blocked from creating new accounts. '
|
||||
'Please <a href="{}">ask a moderator</a> to manually '
|
||||
'activate your account <a href="{}">\'{}\'</a>.'
|
||||
.format(flask.url_for('site.help') + '#irchelp',
|
||||
|
@ -162,7 +164,7 @@ def password_reset(payload=None):
|
|||
if user:
|
||||
send_password_reset_request_email(user)
|
||||
|
||||
flask.flash(flask.Markup(
|
||||
flask.flash(Markup(
|
||||
'A password reset request was sent to the provided email, '
|
||||
'if a matching account was found.'), 'info')
|
||||
return flask.redirect(flask.url_for('main.home'))
|
||||
|
@ -196,7 +198,7 @@ def password_reset(payload=None):
|
|||
|
||||
send_password_reset_email(user)
|
||||
|
||||
flask.flash(flask.Markup('Your password was reset. Log in now.'), 'info')
|
||||
flask.flash(Markup('Your password was reset. Log in now.'), 'info')
|
||||
return flask.redirect(flask.url_for('account.login'))
|
||||
return flask.render_template('password_reset.html', form=form)
|
||||
|
||||
|
@ -212,25 +214,25 @@ def profile():
|
|||
if flask.request.method == 'POST':
|
||||
if form.authorized_submit and form.validate():
|
||||
user = flask.g.user
|
||||
new_email = form.email.data.strip()
|
||||
new_email = form.email.data.strip() if form.email.data else None
|
||||
new_password = form.new_password.data
|
||||
|
||||
if new_email:
|
||||
if new_email and new_email.strip():
|
||||
if form.current_password.data != user.password_hash:
|
||||
flask.flash(flask.Markup(
|
||||
flask.flash(Markup(
|
||||
'<strong>Email change failed!</strong> Incorrect password.'), 'danger')
|
||||
return flask.redirect('/profile')
|
||||
user.email = form.email.data
|
||||
flask.flash(flask.Markup(
|
||||
flask.flash(Markup(
|
||||
'<strong>Email successfully changed!</strong>'), 'success')
|
||||
|
||||
if new_password:
|
||||
if form.current_password.data != user.password_hash:
|
||||
flask.flash(flask.Markup(
|
||||
flask.flash(Markup(
|
||||
'<strong>Password change failed!</strong> Incorrect password.'), 'danger')
|
||||
return flask.redirect('/profile')
|
||||
user.password_hash = form.new_password.data
|
||||
flask.flash(flask.Markup(
|
||||
flask.flash(Markup(
|
||||
'<strong>Password successfully changed!</strong>'), 'success')
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
@ -244,7 +246,7 @@ def profile():
|
|||
db.session.add(preferences)
|
||||
db.session.commit()
|
||||
user.preferences.hide_comments = form.hide_comments.data
|
||||
flask.flash(flask.Markup(
|
||||
flask.flash(Markup(
|
||||
'<strong>Preferences successfully changed!</strong>'), 'success')
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
from datetime import datetime
|
||||
from ipaddress import ip_address
|
||||
from markupsafe import Markup
|
||||
|
||||
import flask
|
||||
|
||||
|
@ -162,10 +163,10 @@ def view_trusted_application(app_id):
|
|||
if decision_form.accept.data:
|
||||
app.status = models.TrustedApplicationStatus.ACCEPTED
|
||||
app.submitter.level = models.UserLevelType.TRUSTED
|
||||
flask.flash(flask.Markup('Application has been <b>accepted</b>.'), 'success')
|
||||
flask.flash(Markup('Application has been <b>accepted</b>.'), 'success')
|
||||
elif decision_form.reject.data:
|
||||
app.status = models.TrustedApplicationStatus.REJECTED
|
||||
flask.flash(flask.Markup('Application has been <b>rejected</b>.'), 'success')
|
||||
flask.flash(Markup('Application has been <b>rejected</b>.'), 'success')
|
||||
_send_trusted_decision_email(app.submitter, bool(decision_form.accept.data))
|
||||
db.session.commit()
|
||||
return flask.redirect(flask.url_for('admin.trusted_application', app_id=app_id))
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import base64
|
||||
import math
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from ipaddress import ip_address
|
||||
from markupsafe import Markup
|
||||
|
||||
import flask
|
||||
from flask_paginate import Pagination
|
||||
|
@ -37,8 +38,8 @@ def before_request():
|
|||
|
||||
flask.g.user = user
|
||||
|
||||
if 'timeout' not in flask.session or flask.session['timeout'] < datetime.now():
|
||||
flask.session['timeout'] = datetime.now() + timedelta(days=7)
|
||||
if 'timeout' not in flask.session or flask.session['timeout'] < datetime.now(timezone.utc):
|
||||
flask.session['timeout'] = datetime.now(timezone.utc) + timedelta(days=7)
|
||||
flask.session.permanent = True
|
||||
flask.session.modified = True
|
||||
|
||||
|
@ -140,7 +141,7 @@ def home(rss):
|
|||
infohash_torrent = special_results.get('infohash_torrent')
|
||||
if infohash_torrent:
|
||||
# infohash_torrent is only set if this is not RSS or userpage search
|
||||
flask.flash(flask.Markup('You were redirected here because '
|
||||
flask.flash(Markup('You were redirected here because '
|
||||
'the given hash matched this torrent.'), 'info')
|
||||
# Redirect user from search to the torrent if we found one with the specific info_hash
|
||||
return flask.redirect(flask.url_for('torrents.view', torrent_id=infohash_torrent.id))
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import json
|
||||
from ipaddress import ip_address
|
||||
from urllib.parse import quote
|
||||
from markupsafe import Markup
|
||||
|
||||
import flask
|
||||
from werkzeug.datastructures import CombinedMultiDict
|
||||
|
@ -21,7 +22,7 @@ def view_torrent(torrent_id):
|
|||
torrent = models.Torrent.by_id(torrent_id)
|
||||
else:
|
||||
torrent = models.Torrent.query \
|
||||
.options(joinedload('filelist')) \
|
||||
.options(joinedload(models.Torrent.filelist)) \
|
||||
.filter_by(id=torrent_id) \
|
||||
.first()
|
||||
|
||||
|
@ -135,7 +136,7 @@ def edit_torrent(torrent_id):
|
|||
|
||||
db.session.commit()
|
||||
|
||||
flask.flash(flask.Markup(
|
||||
flask.flash(Markup(
|
||||
'Torrent has been successfully edited! Changes might take a few minutes to show up.'),
|
||||
'success')
|
||||
|
||||
|
@ -227,7 +228,7 @@ def _delete_torrent(torrent, form, banform):
|
|||
db.session.add(torrent)
|
||||
|
||||
if not action and not ban_torrent:
|
||||
flask.flash(flask.Markup('What the fuck are you doing?'), 'danger')
|
||||
flask.flash(Markup('What the fuck are you doing?'), 'danger')
|
||||
return flask.redirect(flask.url_for('torrents.edit', torrent_id=torrent.id))
|
||||
|
||||
if action and editor.is_moderator:
|
||||
|
@ -239,7 +240,7 @@ def _delete_torrent(torrent, form, banform):
|
|||
|
||||
if action:
|
||||
db.session.commit()
|
||||
flask.flash(flask.Markup('Torrent has been successfully {0}.'.format(action)), 'success')
|
||||
flask.flash(Markup('Torrent has been successfully {0}.'.format(action)), 'success')
|
||||
|
||||
if not banform or not (banform.ban_user.data or banform.ban_userip.data):
|
||||
return flask.redirect(url)
|
||||
|
@ -253,7 +254,7 @@ def _delete_torrent(torrent, form, banform):
|
|||
|
||||
if (banform.ban_user.data and (not uploader or uploader.is_banned)) or \
|
||||
(banform.ban_userip.data and ipbanned):
|
||||
flask.flash(flask.Markup('What the fuck are you doing?'), 'danger')
|
||||
flask.flash(Markup('What the fuck are you doing?'), 'danger')
|
||||
return flask.redirect(flask.url_for('torrents.edit', torrent_id=torrent.id))
|
||||
|
||||
flavor = "Nyaa" if app.config['SITE_FLAVOR'] == 'nyaa' else "Sukebei"
|
||||
|
@ -298,7 +299,7 @@ def _delete_torrent(torrent, form, banform):
|
|||
|
||||
db.session.commit()
|
||||
|
||||
flask.flash(flask.Markup('Uploader has been successfully banned.'), 'success')
|
||||
flask.flash(Markup('Uploader has been successfully banned.'), 'success')
|
||||
|
||||
return flask.redirect(url)
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ import math
|
|||
import time
|
||||
from ipaddress import ip_address
|
||||
from itertools import chain
|
||||
from markupsafe import Markup
|
||||
|
||||
import flask
|
||||
from flask_paginate import Pagination
|
||||
|
@ -74,7 +75,7 @@ def view_user(user_name):
|
|||
if (ban_form.ban_user.data and user.is_banned) or \
|
||||
(ban_form.ban_userip.data and ipbanned) or \
|
||||
(ban_form.unban.data and not user.is_banned and not bans):
|
||||
flask.flash(flask.Markup('What the fuck are you doing?'), 'danger')
|
||||
flask.flash(Markup('What the fuck are you doing?'), 'danger')
|
||||
return flask.redirect(url)
|
||||
|
||||
user_str = "[{0}]({1})".format(user.username, url)
|
||||
|
@ -107,7 +108,7 @@ def view_user(user_name):
|
|||
|
||||
db.session.commit()
|
||||
|
||||
flask.flash(flask.Markup('User has been successfully {0}.'.format(action)), 'success')
|
||||
flask.flash(Markup('User has been successfully {0}.'.format(action)), 'success')
|
||||
return flask.redirect(url)
|
||||
|
||||
req_args = flask.request.args
|
||||
|
@ -233,7 +234,7 @@ def view_user_comments(user_name):
|
|||
@bp.route('/user/activate/<payload>')
|
||||
def activate_user(payload):
|
||||
if app.config['MAINTENANCE_MODE']:
|
||||
flask.flash(flask.Markup('<strong>Activations are currently disabled.</strong>'), 'danger')
|
||||
flask.flash(Markup('<strong>Activations are currently disabled.</strong>'), 'danger')
|
||||
return flask.redirect(flask.url_for('main.home'))
|
||||
|
||||
s = get_serializer()
|
||||
|
@ -259,7 +260,7 @@ def activate_user(payload):
|
|||
flask.session.permanent = True
|
||||
flask.session.modified = True
|
||||
|
||||
flask.flash(flask.Markup("You've successfully verified your account!"), 'success')
|
||||
flask.flash(Markup("You've successfully verified your account!"), 'success')
|
||||
return flask.redirect(flask.url_for('main.home'))
|
||||
|
||||
|
||||
|
|
110
requirements.txt
110
requirements.txt
|
@ -1,55 +1,59 @@
|
|||
alembic==1.0.11
|
||||
appdirs==1.4.3
|
||||
argon2-cffi==19.1.0
|
||||
autopep8==1.4.4
|
||||
blinker==1.4
|
||||
cffi==1.12.3
|
||||
click==7.0
|
||||
dnspython==1.16.0
|
||||
elasticsearch==7.0.2
|
||||
elasticsearch-dsl==7.0.0
|
||||
flake8==3.7.8
|
||||
flake8-isort==2.7.0
|
||||
Flask==1.1.1
|
||||
Flask-Assets==0.12
|
||||
Flask-DebugToolbar==0.10.1
|
||||
Flask-Migrate==2.5.2
|
||||
flask-paginate==0.5.3
|
||||
Flask-Script==2.0.6
|
||||
Flask-SQLAlchemy==2.4.0
|
||||
Flask-WTF==0.14.2
|
||||
gevent==1.4.0
|
||||
greenlet==0.4.15
|
||||
isort==4.3.21
|
||||
itsdangerous==1.1.0
|
||||
Jinja2==2.10.1
|
||||
Mako==1.1.0
|
||||
MarkupSafe==1.1.1
|
||||
mysql-replication==0.19
|
||||
mysqlclient==1.4.3
|
||||
orderedset==2.0.1
|
||||
packaging==19.1
|
||||
passlib==1.7.1
|
||||
alembic==1.14.1
|
||||
appdirs==1.4.4
|
||||
argon2-cffi==23.1.0
|
||||
autopep8==2.3.2
|
||||
blinker==1.9.0
|
||||
cffi==1.17.1
|
||||
click==8.1.8
|
||||
dnspython==2.7.0
|
||||
elasticsearch==8.17.1
|
||||
elasticsearch-dsl==8.17.1
|
||||
flake8==7.1.2
|
||||
flake8-isort==6.1.2
|
||||
Flask==3.1.0
|
||||
Flask-Assets==2.1.0
|
||||
Flask-DebugToolbar==0.16.0
|
||||
Flask-Migrate==4.1.0
|
||||
flask-paginate==2024.4.12
|
||||
# Flask-Script removed as it's deprecated and replaced with Flask CLI
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
Flask-WTF==1.2.2
|
||||
gevent==24.11.1
|
||||
greenlet==3.1.1
|
||||
isort==6.0.1
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.5
|
||||
Mako==1.3.9
|
||||
MarkupSafe==3.0.2
|
||||
mysql-replication==1.0.9
|
||||
mysqlclient==2.2.7
|
||||
# orderedset removed as it's deprecated and replaced with Flask CLI
|
||||
orderly-set==5.3.0
|
||||
packaging==24.2
|
||||
passlib==1.7.4
|
||||
progressbar33==2.4
|
||||
py==1.8.0
|
||||
pycodestyle==2.5.0
|
||||
pycparser==2.19
|
||||
PyMySQL==0.9.3
|
||||
pyparsing==2.4.2
|
||||
pytest==5.0.1
|
||||
python-dateutil==2.8.0
|
||||
py==1.11.0
|
||||
pycodestyle==2.12.1
|
||||
pycparser==2.22
|
||||
PyMySQL==1.1.1
|
||||
pyparsing==3.2.1
|
||||
pytest==8.3.4
|
||||
python-dateutil==2.9.0
|
||||
python-editor==1.0.4
|
||||
python-utils==2.3.0
|
||||
requests==2.22.0
|
||||
SQLAlchemy==1.3.6
|
||||
SQLAlchemy-FullText-Search==0.2.5
|
||||
SQLAlchemy-Utils==0.34.1
|
||||
statsd==3.3.0
|
||||
urllib3==1.25.3
|
||||
uWSGI==2.0.18
|
||||
redis==3.2.1
|
||||
webassets==0.12.1
|
||||
Werkzeug==0.15.5
|
||||
WTForms==2.2.1
|
||||
Flask-Caching==1.7.2
|
||||
Flask-Limiter==1.0.1
|
||||
python-utils==3.9.1
|
||||
requests==2.32.3
|
||||
SQLAlchemy==2.0.38
|
||||
SQLAlchemy-FullText-Search==0.3.0
|
||||
SQLAlchemy-Utils==0.41.2
|
||||
statsd==4.0.1
|
||||
urllib3==2.3.0
|
||||
uWSGI==2.0.28
|
||||
redis==5.2.1
|
||||
webassets==2.0
|
||||
Werkzeug==3.1.3
|
||||
WTForms==3.2.1
|
||||
Flask-Caching==2.3.1
|
||||
Flask-Limiter==3.10.1
|
||||
mypy==1.15.0
|
||||
typing-extensions==4.12.2
|
||||
email-validator==2.2.0
|
8
run.py
8
run.py
|
@ -1,5 +1,11 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Main entry point for running the Nyaa application.
|
||||
Compatible with Python 3.13.
|
||||
"""
|
||||
from nyaa import create_app
|
||||
|
||||
app = create_app('config')
|
||||
app.run(host='0.0.0.0', port=5500, debug=True)
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5500, debug=True)
|
||||
|
|
|
@ -18,7 +18,7 @@ class NyaaTestCase(unittest.TestCase):
|
|||
|
||||
# Use a separate database for testing
|
||||
# if USE_MYSQL:
|
||||
# cls.db_name = 'nyaav2_tests'
|
||||
# cls.db_name = 'nyaav3_tests'
|
||||
# db_uri = 'mysql://root:@localhost/{}?charset=utf8mb4'.format(cls.db_name)
|
||||
# else:
|
||||
# cls.db_name = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'test.db')
|
||||
|
|
|
@ -5,8 +5,8 @@ import re
|
|||
|
||||
import requests
|
||||
|
||||
NYAA_HOST = 'https://nyaa.si'
|
||||
SUKEBEI_HOST = 'https://sukebei.nyaa.si'
|
||||
NYAA_HOST = 'https://your.nyaa.instance'
|
||||
SUKEBEI_HOST = 'https://your.sukebei.instance'
|
||||
|
||||
API_BASE = '/api'
|
||||
API_INFO = API_BASE + '/info'
|
||||
|
|
|
@ -5,8 +5,8 @@ import os
|
|||
|
||||
import requests
|
||||
|
||||
NYAA_HOST = 'https://nyaa.si'
|
||||
SUKEBEI_HOST = 'https://sukebei.nyaa.si'
|
||||
NYAA_HOST = 'https://your.nyaa.instance'
|
||||
SUKEBEI_HOST = 'https://your.sukebei.instance'
|
||||
|
||||
API_BASE = '/api'
|
||||
API_UPLOAD = API_BASE + '/upload'
|
||||
|
|
|
@ -18,7 +18,7 @@ if not os.path.exists(outdir):
|
|||
db = MySQLdb.connect(host='localhost',
|
||||
user='test',
|
||||
passwd='test123',
|
||||
db='nyaav2',
|
||||
db='nyaav3',
|
||||
cursorclass=MySQLdb.cursors.SSCursor)
|
||||
cur = db.cursor()
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue