Skip to content

Commit

Permalink
Add webserver (#2)
Browse files Browse the repository at this point in the history
* Updated TODO list

* Added tests for the webserver

* refactored webserver to use infohashes

* tests

* Added cli flags for server mode

* Removed unused import

* Added port env var

* Added 404 response

* Updated README
  • Loading branch information
moleculekayak authored Jul 30, 2024
1 parent 32cfddd commit 07b7153
Show file tree
Hide file tree
Showing 15 changed files with 248 additions and 34 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,4 @@ cython_debug/

.env
tests/support/torrents/example.torrent
scratchpad.md
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ RUN apt-get update \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

EXPOSE 9713

ENTRYPOINT ["./docker_start"]
13 changes: 0 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +0,0 @@
## TODO:

- reintroduce `PTH` as an alternate source for RED
- Update docker file
- add `ignore-ops` and `ignore-red` flags (might need clearer name)
- Add ability to call with single torrent files (ie: not a dirscan)
- Add webserver to allow for calls to be sent to the container
- Add direct injection perhaps?
- Optionally read secrets from ENV vars OR Specify secrets file location
- See if I can store metadata in the torrent file to indicate the torrent was generated by this app (and therefor can be ignored)
- Alternately, see if I can screen out torrenets that have already been generated via infohash
- Add a linter
- add build artifacts to release
2 changes: 2 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ services:
dockerfile: ./Dockerfile
volumes:
- '.:/app'
ports:
- '9713:9713'
entrypoint: /bin/bash
command:
- -c
Expand Down
7 changes: 6 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
from colorama import Fore

from src.api import RedAPI, OpsAPI
Expand All @@ -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()
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
bencoder
colorama
requests
flask
pytest
requests-mock
ruff
16 changes: 15 additions & 1 deletion src/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand All @@ -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
11 changes: 10 additions & 1 deletion src/parser.py
Original file line number Diff line number Diff line change
@@ -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"]
Expand Down
66 changes: 66 additions & 0 deletions src/webserver.py
Original file line number Diff line number Diff line change
@@ -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)
12 changes: 11 additions & 1 deletion tests/support.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
import os
import shutil


def get_torrent_path(name):
return f"tests/support/torrents/{name}.torrent"


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)
10 changes: 10 additions & 0 deletions tests/test_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
12 changes: 12 additions & 0 deletions tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"
Expand Down
12 changes: 0 additions & 12 deletions tests/test_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 0 additions & 5 deletions tests/test_torrent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading

0 comments on commit 07b7153

Please sign in to comment.