diff --git a/.gitignore b/.gitignore index 403e83f..aeff59b 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,4 @@ cython_debug/ .env tests/support/torrents/example.torrent +scratchpad.md diff --git a/Dockerfile b/Dockerfile index 1fff910..79d0448 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,4 +17,6 @@ RUN apt-get update \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* +EXPOSE 9713 + ENTRYPOINT ["./docker_start"] diff --git a/docker-compose.yaml b/docker-compose.yaml index fac172f..cfd8c3d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -5,6 +5,8 @@ services: dockerfile: ./Dockerfile volumes: - '.:/app' + ports: + - '9713:9713' entrypoint: /bin/bash command: - -c diff --git a/main.py b/main.py index e721922..237ab29 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,4 @@ +import os from colorama import Fore from src.api import RedAPI, OpsAPI @@ -6,6 +7,8 @@ from src.torrent import generate_new_torrent_from_file from src.scanner import scan_torrent_directory +from src.webserver import run_webserver + def cli_entrypoint(): args = parse_args() @@ -16,7 +19,9 @@ def cli_entrypoint(): ops_api = OpsAPI(config.ops_key) try: - if args.input_file: + if args.server: + run_webserver(args.input_directory, args.output_directory, red_api, ops_api, port=os.environ.get("PORT", 9713)) + elif args.input_file: _, torrent_path = generate_new_torrent_from_file(args.input_file, args.output_directory, red_api, ops_api) print(torrent_path) elif args.input_directory: diff --git a/requirements.txt b/requirements.txt index 41a774c..154795d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ bencoder colorama requests +flask pytest requests-mock ruff diff --git a/src/args.py b/src/args.py index 1db4c79..673d0ac 100644 --- a/src/args.py +++ b/src/args.py @@ -16,6 +16,7 @@ def parse_args(args=None): support = parser.add_argument_group(title="support") directories = parser.add_argument_group(title="directories") inputs = directories.add_mutually_exclusive_group(required=True) + options = parser.add_argument_group(title="options") config = parser.add_argument_group(title="config") support.add_argument( @@ -46,6 +47,14 @@ def parse_args(args=None): help="directory where cross-seedable .torrent files will be saved", ) + options.add_argument( + "-s", + "--server", + action="store_true", + help="starts fertizer in server mode. Requires -i/--input-directory", + default=False, + ) + config.add_argument( "-c", "--config-file", @@ -54,4 +63,9 @@ def parse_args(args=None): default="src/settings.json", ) - return parser.parse_args(args) + parsed = parser.parse_args(args) + + if parsed.server and not parsed.input_directory: + parser.error("--server requires --input-directory") + + return parsed diff --git a/src/parser.py b/src/parser.py index 83962f3..03cb63b 100644 --- a/src/parser.py +++ b/src/parser.py @@ -1,10 +1,19 @@ -import bencoder import copy +import bencoder from hashlib import sha1 from .trackers import RedTracker, OpsTracker +def is_valid_infohash(infohash: str) -> bool: + if not isinstance(infohash, str) or len(infohash) != 40: + return False + try: + return bool(int(infohash, 16)) + except ValueError: + return False + + def get_source(torrent_data: dict) -> bytes: try: return torrent_data[b"info"][b"source"] diff --git a/src/webserver.py b/src/webserver.py new file mode 100644 index 0000000..a9a63ce --- /dev/null +++ b/src/webserver.py @@ -0,0 +1,66 @@ +import os +from flask import Flask, request + +from src.parser import is_valid_infohash +from src.torrent import generate_new_torrent_from_file +from src.errors import TorrentAlreadyExistsError, TorrentNotFoundError + +app = Flask(__name__) + + +@app.route("/api/webhook", methods=["POST"]) +def webhook(): + config = app.config + request_form = request.form.to_dict() + infohash = request_form.get("infohash") + # NOTE: always ensure safety checks are done before this filepath is ever used + filepath = f"{config['input_dir']}/{infohash}.torrent" + + if infohash is None: + return http_error("Request must include an 'infohash' parameter", 400) + if not is_valid_infohash(infohash): + return http_error("Invalid infohash", 400) + if not os.path.exists(filepath): + return http_error(f"No torrent found at {filepath}", 404) + + try: + _, new_filepath = generate_new_torrent_from_file( + filepath, + config["output_dir"], + config["red_api"], + config["ops_api"], + ) + + return http_success(new_filepath, 201) + except TorrentAlreadyExistsError as e: + return http_error(str(e), 409) + except TorrentNotFoundError as e: + return http_error(str(e), 404) + except Exception as e: + return http_error(str(e), 500) + + +@app.errorhandler(404) +def page_not_found(_e): + return http_error("Not found", 404) + + +def http_success(message, code): + return {"status": "success", "message": message}, code + + +def http_error(message, code): + return {"status": "error", "message": message}, code + + +def run_webserver(input_dir, output_dir, red_api, ops_api, host="0.0.0.0", port=9713): + app.config.update( + { + "input_dir": input_dir, + "output_dir": output_dir, + "red_api": red_api, + "ops_api": ops_api, + } + ) + + app.run(debug=False, host=host, port=port) diff --git a/tests/support.py b/tests/support.py index 700caf3..86ced5d 100644 --- a/tests/support.py +++ b/tests/support.py @@ -1,4 +1,5 @@ import os +import shutil def get_torrent_path(name): @@ -6,10 +7,19 @@ def get_torrent_path(name): class SetupTeardown: + TORRENT_SUCCESS_RESPONSE = {"status": "success", "response": {"torrent": {"filePath": "foo", "id": 123}}} + TORRENT_KNOWN_BAD_RESPONSE = {"status": "failure", "error": "bad hash parameter"} + TORRENT_UNKNOWN_BAD_RESPONSE = {"status": "failure", "error": "unknown error"} + ANNOUNCE_SUCCESS_RESPONSE = {"status": "success", "response": {"passkey": "bar"}} + def setup_method(self): for f in os.listdir("/tmp"): if f.endswith(".torrent"): os.remove(os.path.join("/tmp", f)) + os.makedirs("/tmp/input", exist_ok=True) + os.makedirs("/tmp/output", exist_ok=True) + def teardown_method(self): - pass + shutil.rmtree("/tmp/input", ignore_errors=True) + shutil.rmtree("/tmp/output", ignore_errors=True) diff --git a/tests/test_args.py b/tests/test_args.py index c28116d..b312090 100644 --- a/tests/test_args.py +++ b/tests/test_args.py @@ -41,6 +41,16 @@ def test_does_not_allow_both_input_types(self, capsys): assert excinfo.value.code == 2 assert "argument -f/--input-file: not allowed with argument -i/--input-directory" in captured.err + def test_server_requires_input_directory(self, capsys): + with pytest.raises(SystemExit) as excinfo: + parse_args(["-s", "-o", "foo", "-f", "bar"]) + + captured = capsys.readouterr() + + assert excinfo.value.code == 2 + print(captured.err) + assert "--server requires --input-directory" in captured.err + def test_requires_output_directory(self, capsys): with pytest.raises(SystemExit) as excinfo: parse_args(["-i", "foo"]) diff --git a/tests/test_parser.py b/tests/test_parser.py index 8a1181b..0cbff3c 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -4,6 +4,7 @@ from src.trackers import RedTracker, OpsTracker from src.parser import ( + is_valid_infohash, get_source, get_torrent_data, get_announce_url, @@ -13,6 +14,17 @@ ) +class TestParserIsValidInfohash(SetupTeardown): + def test_returns_true_for_valid_infohash(self): + assert is_valid_infohash("0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33") + + def test_returns_false_for_invalid_infohash(self): + assert not is_valid_infohash("abc") + assert not is_valid_infohash("mnopqrstuvwx") + assert not is_valid_infohash("Ubeec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33") + assert not is_valid_infohash(123) + + class TestParserGetSource(SetupTeardown): def test_returns_source_if_present(self): assert get_source({b"info": {b"source": b"FOO"}}) == b"FOO" diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 3f7474d..d06b377 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -11,18 +11,6 @@ class TestScanTorrentDirectory(SetupTeardown): - TORRENT_SUCCESS_RESPONSE = {"status": "success", "response": {"torrent": {"filePath": "foo", "id": 123}}} - TORRENT_KNOWN_BAD_RESPONSE = {"status": "failure", "error": "bad hash parameter"} - TORRENT_UNKNOWN_BAD_RESPONSE = {"status": "failure", "error": "unknown error"} - ANNOUNCE_SUCCESS_RESPONSE = {"status": "success", "response": {"passkey": "bar"}} - - def setup_method(self): - super().setup_method() - shutil.rmtree("/tmp/input", ignore_errors=True) - shutil.rmtree("/tmp/output", ignore_errors=True) - os.makedirs("/tmp/input") - os.makedirs("/tmp/output") - def test_gets_mad_if_input_directory_does_not_exist(self, red_api, ops_api): with pytest.raises(FileNotFoundError): scan_torrent_directory("/tmp/nonexistent", "/tmp/output", red_api, ops_api) diff --git a/tests/test_torrent.py b/tests/test_torrent.py index 9661636..d9e1a2b 100644 --- a/tests/test_torrent.py +++ b/tests/test_torrent.py @@ -17,11 +17,6 @@ class TestGenerateNewTorrentFromFile(SetupTeardown): - TORRENT_SUCCESS_RESPONSE = {"status": "success", "response": {"torrent": {"filePath": "foo", "id": 123}}} - TORRENT_KNOWN_BAD_RESPONSE = {"status": "failure", "error": "bad hash parameter"} - TORRENT_UNKNOWN_BAD_RESPONSE = {"status": "failure", "error": "unknown error"} - ANNOUNCE_SUCCESS_RESPONSE = {"status": "success", "response": {"passkey": "bar"}} - def test_saves_new_torrent_from_red_to_ops(self, red_api, ops_api): with requests_mock.Mocker() as m: m.get(re.compile("action=torrent"), json=self.TORRENT_SUCCESS_RESPONSE) diff --git a/tests/test_webserver.py b/tests/test_webserver.py new file mode 100644 index 0000000..54c04db --- /dev/null +++ b/tests/test_webserver.py @@ -0,0 +1,112 @@ +import re +import os +import pytest +import shutil +import requests_mock + +from .support import SetupTeardown, get_torrent_path + +from src.webserver import app as webserver_app + + +@pytest.fixture() +def app(red_api, ops_api): + webserver_app.config.update( + { + "input_dir": "/tmp/input", + "output_dir": "/tmp/output", + "red_api": red_api, + "ops_api": ops_api, + } + ) + + yield webserver_app + + +@pytest.fixture() +def client(app): + return app.test_client() + + +@pytest.fixture() +def infohash(): + return "0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33" + + +class TestWebserverNotFound(SetupTeardown): + def test_returns_not_found(self, client): + response = client.get("/api/does-not-exist") + assert response.status_code == 404 + assert response.json == {"status": "error", "message": "Not found"} + + +class TestWebserverWebhook(SetupTeardown): + def test_requires_infohash_parameter(self, client): + response = client.post("/api/webhook", data={}) + assert response.status_code == 400 + assert response.json == {"status": "error", "message": "Request must include an 'infohash' parameter"} + + def test_rejects_invalid_infohash(self, client): + responses = [ + client.post("/api/webhook", data={"infohash": "abc"}), + client.post("/api/webhook", data={"infohash": "mnopqrstuvwx"}), + client.post("/api/webhook", data={"infohash": 123}), + ] + + for response in responses: + assert response.status_code == 400 + assert response.json == {"status": "error", "message": "Invalid infohash"} + + def test_requires_existing_torrent_file(self, client, infohash): + response = client.post("/api/webhook", data={"infohash": infohash}) + assert response.status_code == 404 + assert response.json == {"status": "error", "message": f"No torrent found at /tmp/input/{infohash}.torrent"} + + def test_generates_new_torrent_file(self, client, infohash): + shutil.copy(get_torrent_path("red_source"), f"/tmp/input/{infohash}.torrent") + + with requests_mock.Mocker() as m: + m.get(re.compile("action=torrent"), json=self.TORRENT_SUCCESS_RESPONSE) + m.get(re.compile("action=index"), json=self.ANNOUNCE_SUCCESS_RESPONSE) + + response = client.post("/api/webhook", data={"infohash": infohash}) + assert response.status_code == 201 + assert response.json == {"status": "success", "message": "/tmp/output/foo [OPS].torrent"} + assert os.path.exists("/tmp/output/foo [OPS].torrent") + + def test_returns_error_if_torrent_already_exists(self, client, infohash): + shutil.copy(get_torrent_path("red_source"), f"/tmp/input/{infohash}.torrent") + shutil.copy(get_torrent_path("red_source"), "/tmp/output/foo [OPS].torrent") + + with requests_mock.Mocker() as m: + m.get(re.compile("action=torrent"), json=self.TORRENT_SUCCESS_RESPONSE) + m.get(re.compile("action=index"), json=self.ANNOUNCE_SUCCESS_RESPONSE) + + response = client.post("/api/webhook", data={"infohash": infohash}) + assert response.status_code == 409 + assert response.json == { + "status": "error", + "message": "Torrent file already exists at /tmp/output/foo [OPS].torrent", + } + + def test_returns_error_if_torrent_not_found(self, client, infohash): + shutil.copy(get_torrent_path("red_source"), f"/tmp/input/{infohash}.torrent") + + with requests_mock.Mocker() as m: + m.get(re.compile("action=torrent"), json=self.TORRENT_KNOWN_BAD_RESPONSE) + m.get(re.compile("action=index"), json=self.ANNOUNCE_SUCCESS_RESPONSE) + + response = client.post("/api/webhook", data={"infohash": infohash}) + assert response.status_code == 404 + assert response.json == {"status": "error", "message": "Torrent could not be found on OPS"} + + def test_returns_error_if_unknown_error(self, client, infohash): + shutil.copy(get_torrent_path("red_source"), f"/tmp/input/{infohash}.torrent") + + with requests_mock.Mocker() as m: + m.get(re.compile("action=torrent"), json=self.TORRENT_UNKNOWN_BAD_RESPONSE) + m.get(re.compile("action=index"), json=self.ANNOUNCE_SUCCESS_RESPONSE) + + response = client.post("/api/webhook", data={"infohash": infohash}) + assert response.status_code == 500 + assert response.json == {"status": "error", "message": "An unknown error occurred in the API response from OPS"}