This commit is contained in:
Mateusz Gruszczyński committed 2026-10-09 09:41:42 +02:00
1 parent 46d986ce80
commit ad620c976a
5 files changed
+438 -158

No files matched your search

+130 -157
View File
@@ -25,6 +25,12 @@ from flask import (
Response,
)
from flask_compress import Compress
from source_conversion import (
InvalidSourceError,
SourceTooLargeError,
convert_source,
iter_completed_bytes,
)
from flask_limiter import Limiter
import config
@@ -381,31 +387,18 @@ def index():
@app.route("/convert")
@limiter.limit(config.RATE_LIMIT_CONVERT)
def convert():
def is_private_client_ip() -> bool:
ip = get_client_ip()
try:
return ipaddress.ip_address(ip).is_private
except Exception:
return False
# Flask automatically maps HEAD to a GET route as well.
if request.method == "HEAD":
return convert_head()
requested_debug = request.args.get("debug", "").lower() in (
"1",
"true",
"t",
"yes",
"y",
"on",
"1", "true", "t", "yes", "y", "on",
)
debug_allowed = False
if config.DEBUG_ENABLE:
header_key = request.headers.get("X-Debug-Key", "")
if (
config.DEBUG_KEY
and header_key
and hmac.compare_digest(header_key, config.DEBUG_KEY)
):
if (config.DEBUG_KEY and header_key
and hmac.compare_digest(header_key, config.DEBUG_KEY)):
debug_allowed = True
elif is_private_client_ip():
debug_allowed = True
@@ -417,182 +410,160 @@ def convert():
debug_lines = []
def d(msg):
ts = datetime.now().isoformat()
line = f"# [DEBUG {ts}] {msg}"
line = f"# [DEBUG {datetime.now().isoformat()}] {msg}"
debug_lines.append(line)
app.logger.debug(line)
def debug_response(status=200):
body = "\n".join(debug_lines) + ("\n" if debug_lines else "")
resp = Response(body, mimetype="text/plain; charset=utf-8", status=status)
resp = Response(body, content_type="text/plain; charset=utf-8", status=status)
resp.headers["X-Debug-Mode"] = "1"
resp.headers["Cache-Control"] = "no-store"
return resp
def reject(status, description):
redis_client.incr(f"stats:errors_{status}")
if debug_mode:
d(description)
return debug_response(status=status)
abort(status, description=description)
add_recent_convert()
redis_client.incr("stats:convert_requests")
if debug_mode:
d("Start /convert w trybie debug")
# request.args already URL-decodes the outer query parameter. Calling
# unquote() again corrupts percent escapes within the nested source URL.
source_url = request.args.get("url")
if not source_url:
return reject(400, "Brak parametru url z adresem listy.")
try:
add_recent_convert()
redis_client.incr("stats:convert_requests")
if debug_mode:
d("Start /convert w trybie debug")
normalized_url = validate_and_normalize_url(source_url)
except ValueError:
return reject(400, "Nieprawid\u0142owy adres URL listy.")
encoded_url = request.args.get("url")
if not encoded_url:
if debug_mode:
d("Brak parametru ?url")
return debug_response(status=400)
redis_client.incr("stats:errors_400")
abort(400, description="Missing URL parameter")
try:
target_ip = validate_ip(request.args.get("ip", "127.0.0.1"))
except ValueError:
return reject(400, "Nieprawid\u0142owy adres IP.")
decoded_url = unquote(encoded_url)
try:
normalized_url = validate_and_normalize_url(decoded_url)
except ValueError as e:
if debug_mode:
d(f"Błąd walidacji URL: {e}")
return debug_response(status=400)
redis_client.incr("stats:errors_400")
abort(400)
if debug_mode:
d(f"URL: {normalized_url}")
d(f"target_ip: {target_ip}")
try:
target_ip = validate_ip(request.args.get("ip", "127.0.0.1"))
except ValueError:
if debug_mode:
d("Bad parametr ?ip")
return debug_response(status=400)
redis_client.incr("stats:errors_400")
abort(400, description="Invalid IP")
track_url_request(normalized_url)
redis_client.incr(f"stats:target_ips:{target_ip}")
if debug_mode:
d(f"URL (encoded): {encoded_url}")
d(f"URL (decoded): {decoded_url}")
d(f"URL (norm): {normalized_url}")
d(f"target_ip: {target_ip}")
req_headers = {}
for client_header in ("If-None-Match", "If-Modified-Since"):
value = request.headers.get(client_header)
if value:
req_headers[client_header] = value
track_url_request(normalized_url)
redis_client.incr(f"stats:target_ips:{target_ip}")
# nagłówki If-*
req_headers = {}
inm = request.headers.get("If-None-Match")
ims = request.headers.get("If-Modified-Since")
if inm:
req_headers["If-None-Match"] = inm
if ims:
req_headers["If-Modified-Since"] = ims
if debug_mode:
d("Wysyłam GET do upstreamu")
d(f"Nagłówki: {req_headers or '{}'}")
r = requests.get(
try:
upstream = requests.get(
normalized_url, headers=req_headers, stream=True, timeout=(10, 60)
)
except requests.exceptions.RequestException as exc:
app.logger.warning("Cannot fetch source list: %s", exc)
return reject(502, "Nie mo\u017cna pobra\u0107 listy z podanego adresu.")
ct = r.headers.get("Content-Type", "")
result = None
try:
if debug_mode:
d(f"Upstream status: {r.status_code}")
d(f"Content-Type: {ct or '(brak)'}")
d(f"ETag: {r.headers.get('ETag')}")
d(f"Last-Modified: {r.headers.get('Last-Modified')}")
d(f"Upstream HTTP: {upstream.status_code}")
d(f"Content-Type: {upstream.headers.get('Content-Type', '(brak)')}")
if "text" not in ct and "octet-stream" not in ct and ct != "":
if debug_mode:
d("Unsupported Media Type -> 415")
r.close()
return debug_response(status=415)
r.close()
abort(415, description="Unsupported Media Type")
if r.status_code == 304:
if upstream.status_code == 304:
etag = build_etag(
r.headers.get("ETag"), r.headers.get("Last-Modified"), target_ip
upstream.headers.get("ETag"),
upstream.headers.get("Last-Modified"),
target_ip,
)
if debug_mode:
d("Upstream 304 – zwracam 304")
r.close()
add_recent_link(normalized_url, target_ip)
d("Upstream HTTP 304")
return debug_response(status=304)
resp = Response(status=304)
resp.headers.update(cache_headers(etag, r.headers.get("Last-Modified")))
resp.direct_passthrough = True
r.close()
resp.headers.update(cache_headers(etag, upstream.headers.get("Last-Modified")))
add_recent_link(normalized_url, target_ip)
return resp
up_etag = r.headers.get("ETag")
up_lm = r.headers.get("Last-Modified")
etag = build_etag(up_etag, up_lm, target_ip)
if debug_mode:
d(f"Etag dla klienta: {etag}")
if upstream.status_code != 200:
return reject(
502,
f"Nie mo\u017cna pobra\u0107 listy. Serwer \u017ar\u00f3d\u0142owy zwr\u00f3ci\u0142 HTTP {upstream.status_code}.",
)
@stream_with_context
def body_gen():
lines_read = 0
lines_emitted = 0
try:
if debug_mode:
yield "\n".join(debug_lines) + "\n"
debug_lines.clear()
for line in r.iter_lines(
decode_unicode=True, chunk_size=config.READ_CHUNK
):
if line is None:
continue
lines_read += 1
if len(line) > config.STREAM_LINE_LIMIT:
if debug_mode and lines_read <= 5:
yield f"# [DEBUG] pominięto długi wiersz ({len(line)} bajtów)\n"
continue
out = convert_host_line(line, target_ip)
if out:
lines_emitted += 1
yield out + "\n"
if debug_mode and lines_read <= 5:
preview = line[:200].replace("\r", "\\r").replace("\n", "\\n")
yield f"# [DEBUG] podgląd linii {lines_read}: {preview}\n"
if debug_mode:
yield f"# [DEBUG] podsumowanie: przeczytano={lines_read}, wyemitowano={lines_emitted}\n"
if lines_emitted == 0:
yield "# [DEBUG] Uwaga: 0 linii wynikowych – czy format listy pasuje?\n"
redis_client.incrby("stats:content_size_total", 0)
redis_client.incr("stats:content_size_count")
finally:
r.close()
content_type = upstream.headers.get("Content-Type", "")
media_type = content_type.split(";", 1)[0].strip().lower()
if (media_type and (
media_type in {"text/html", "application/xhtml+xml"}
or not (media_type.startswith("text/") or media_type == "application/octet-stream")
)):
return reject(415, "Podany adres nie zwraca obs\u0142ugiwanej listy tekstowej.")
resp = Response(body_gen(), mimetype="text/plain; charset=utf-8")
resp.headers.update(cache_headers(etag, up_lm))
resp.direct_passthrough = True
redis_client.incr("stats:conversions_success")
add_recent_link(normalized_url, target_ip)
return resp
try:
result = convert_source(
upstream,
target_ip,
convert_host_line,
read_chunk=config.READ_CHUNK,
line_limit=config.STREAM_LINE_LIMIT,
source_limit=config.MAX_SOURCE_BYTES,
output_limit=config.MAX_CONVERTED_BYTES,
debug_lines=debug_lines if debug_mode else None,
)
except SourceTooLargeError as exc:
app.logger.info("Source or output size limit exceeded: %s", exc)
return reject(413, "Plik listy jest zbyt du\u017cy do przetworzenia.")
except InvalidSourceError as exc:
app.logger.info("Unsupported or corrupted source list: %s", exc)
return reject(
422,
"Nie mo\u017cna zdekodowa\u0107 ani obs\u0142u\u017cy\u0107 tej listy. "
"Sprawd\u017a adres URL oraz format pliku.",
)
except requests.exceptions.RequestException as exc:
app.logger.warning("Error while reading source body: %s", exc)
return reject(502, "Przerwano pobieranie listy z serwera \u017ar\u00f3d\u0142owego.")
finally:
upstream.close()
except requests.exceptions.RequestException as e:
app.logger.error(f"Request error: {str(e)}")
redis_client.incr("stats:errors_500")
if debug_mode:
d(f"Wyjątek requests: {e}")
return debug_response(status=502)
abort(500)
except ValueError as e:
app.logger.error(f"URL validation error: {str(e)}")
redis_client.incr("stats:errors_400")
if debug_mode:
d(f"Wyjątek ValueError: {e}")
return debug_response(status=400)
abort(400)
up_etag = upstream.headers.get("ETag")
up_lm = upstream.headers.get("Last-Modified")
etag = build_etag(up_etag, up_lm, target_ip)
# The entire response was decoded, validated and spooled successfully.
# In particular, this iterator produces BYTES, as required by WSGI/ASGI.
resp = Response(
iter_completed_bytes(result.output, config.READ_CHUNK),
content_type="text/plain; charset=utf-8",
)
resp.headers.update(cache_headers(etag, up_lm))
resp.headers["Content-Length"] = str(result.output_size)
resp.direct_passthrough = True
resp.call_on_close(result.output.close)
redis_client.incr("stats:conversions_success")
redis_client.incrby("stats:content_size_total", result.output_size)
redis_client.incr("stats:content_size_count")
add_recent_link(normalized_url, target_ip)
return resp
@app.route("/convert", methods=["HEAD"])
def convert_head():
encoded_url = request.args.get("url", config.DEFAULT_SOURCE_URL)
if not encoded_url:
abort(400)
decoded_url = unquote(encoded_url)
validate_and_normalize_url(decoded_url)
target_ip = validate_ip(request.args.get("ip", "127.0.0.1"))
source_url = request.args.get("url", config.DEFAULT_SOURCE_URL)
if not source_url:
abort(400, description="Brak parametru url z adresem listy.")
try:
validate_and_normalize_url(source_url)
target_ip = validate_ip(request.args.get("ip", "127.0.0.1"))
except ValueError:
abort(400, description="Nieprawid\u0142owy adres URL lub IP.")
etag = build_etag(None, None, target_ip)
resp = Response(status=200)
resp.headers.update(cache_headers(etag, None))
resp.direct_passthrough = True
return resp
@@ -770,6 +741,8 @@ def stats_json():
@app.errorhandler(404)
@app.errorhandler(413)
@app.errorhandler(415)
@app.errorhandler(422)
@app.errorhandler(502)
@app.errorhandler(500)
def handle_errors(e):
try:
+2
View File
@@ -53,6 +53,8 @@ AIOHTTP_SOCK_READ_TIMEOUT = getenv_float("AIOHTTP_SOCK_READ_TIMEOUT", 60.0)
READ_CHUNK = getenv_int("READ_CHUNK", 64 * 1024) # 64 KiB
STREAM_LINE_LIMIT= getenv_int("STREAM_LINE_LIMIT", 4096)
MAX_SOURCE_BYTES= getenv_int("MAX_SOURCE_BYTES", 100 * 1024 * 1024)
MAX_CONVERTED_BYTES= getenv_int("MAX_CONVERTED_BYTES", 100 * 1024 * 1024)
# Serwer
BIND_HOST = os.getenv("BIND_HOST", "127.0.0.1")
+181
View File
@@ -0,0 +1,181 @@
"""Validate and convert complete upstream lists before returning HTTP 200.
The output is spooled to disk when it grows, avoiding a large in-memory
response. Validation and download errors happen before response headers are
sent to the browser or reverse proxy.
"""
import codecs
import re
import tempfile
from dataclasses import dataclass
from typing import BinaryIO, Callable
class InvalidSourceError(Exception):
"""An upstream body is not a supported, decodable hosts list."""
class SourceTooLargeError(InvalidSourceError):
"""The source or converted result exceeds a configured limit."""
@dataclass
class ConversionResult:
output: BinaryIO
lines_read: int
lines_emitted: int
output_size: int
def _get_encoding(content_type: str) -> str:
match = re.search(
r'(?:^|;)\s*charset\s*=\s*(?:"([^"]+)"|([^;\s]+))',
content_type or "", re.IGNORECASE,
)
encoding = (match.group(1) or match.group(2)) if match else "utf-8-sig"
try:
encoding = codecs.lookup(encoding).name
except (LookupError, ValueError) as exc:
raise InvalidSourceError("Nieznane kodowanie tekstu z serwera zrodlowego.") from exc
# The reader splits on ASCII newline octets. Multi-byte encodings need a
# different framing protocol and must not be interpreted as hosts text.
if encoding in {"utf-16", "utf-16-le", "utf-16-be", "utf-32", "utf-32-le", "utf-32-be"}:
raise InvalidSourceError("Nieobslugiwane kodowanie pliku listy.")
return encoding
def _bounded_lines(response, *, chunk_size: int, line_limit: int, source_limit: int):
"""Return raw lines, or None for overlong lines; never buffer a huge line."""
pending = bytearray()
skipping = False
total = 0
for chunk in response.iter_content(chunk_size=chunk_size):
if not chunk:
continue
if not isinstance(chunk, bytes):
raise InvalidSourceError("Zrodlo nie zwrocilo poprawnych danych binarnych.")
total += len(chunk)
if total > source_limit:
raise SourceTooLargeError("Plik zrodlowy przekracza dopuszczalny rozmiar.")
pieces = chunk.split(b"\n")
for part in pieces[:-1]:
if skipping or len(pending) + len(part) > line_limit:
yield None
else:
pending.extend(part)
yield bytes(pending).rstrip(b"\r")
pending.clear()
skipping = False
last = pieces[-1]
if not skipping:
if len(pending) + len(last) > line_limit:
pending.clear()
skipping = True
else:
pending.extend(last)
if skipping:
yield None
elif pending:
yield bytes(pending).rstrip(b"\r")
def convert_source(
response,
target_ip: str,
convert_line: Callable[[str, str], str | None],
*,
read_chunk: int,
line_limit: int,
source_limit: int,
output_limit: int,
spool_memory_limit: int = 2 * 1024 * 1024,
debug_lines: list[str] | None = None,
) -> ConversionResult:
"""Consume, decode and check the whole source before returning output.
The caller owns and must close result.output. On failure, the spool is
closed here, so no partial result can be published or cached as valid.
"""
encoding = _get_encoding(response.headers.get("Content-Type", ""))
length = response.headers.get("Content-Length", "")
if length:
try:
if int(length) > source_limit:
raise SourceTooLargeError("Plik zrodlowy przekracza dopuszczalny rozmiar.")
except ValueError:
pass
output = tempfile.SpooledTemporaryFile(max_size=spool_memory_limit, mode="w+b")
lines_read = 0
lines_emitted = 0
written = 0
def append(data: bytes):
nonlocal written
if written + len(data) > output_limit:
raise SourceTooLargeError("Wynik konwersji przekracza dopuszczalny rozmiar.")
output.write(data)
written += len(data)
try:
if debug_lines is not None and debug_lines:
append(("\n".join(debug_lines) + "\n").encode("utf-8"))
for raw in _bounded_lines(
response,
chunk_size=read_chunk,
line_limit=line_limit,
source_limit=source_limit,
):
lines_read += 1
if raw is None:
if debug_lines is not None and lines_read <= 5:
append(b"# [DEBUG] pominieto zbyt dlugi wiersz\n")
continue
if b"\x00" in raw:
raise InvalidSourceError("Zrodlo zawiera dane binarne zamiast listy tekstowej.")
try:
line = raw.decode(encoding, errors="strict")
if lines_read == 1:
line = line.lstrip("\ufeff")
except UnicodeError as exc:
raise InvalidSourceError("Nie mozna zdekodowac tekstu listy (kodowanie).") from exc
try:
converted = convert_line(line, target_ip)
except (TypeError, ValueError) as exc:
raise InvalidSourceError("Nie mozna przetworzyc formatu listy.") from exc
if converted:
lines_emitted += 1
append((converted + "\n").encode("utf-8"))
if debug_lines is not None and lines_read <= 5:
preview = line[:200].replace("\r", "\\r").replace("\n", "\\n")
append(f"# [DEBUG] podglad {lines_read}: {preview}\n".encode("utf-8"))
if lines_emitted == 0:
raise InvalidSourceError(
"Nie znaleziono domen w obslugiwanym formacie. Sprawdz adres i zawartosc listy."
)
if debug_lines is not None:
append(
f"# [DEBUG] podsumowanie: przeczytano={lines_read}, wyemitowano={lines_emitted}\n".encode("utf-8")
)
output.seek(0)
return ConversionResult(output, lines_read, lines_emitted, written)
except BaseException:
output.close()
raise
def iter_completed_bytes(output: BinaryIO, chunk_size: int):
"""The WSGI iterable must produce bytes, especially for WsgiToAsgi."""
try:
while True:
data = output.read(chunk_size)
if not data:
break
yield data
finally:
output.close()
+3 -1
View File
@@ -36,7 +36,9 @@
{% elif (error.code or 500) == 403 %}Forbidden
{% elif (error.code or 500) == 404 %}Not found
{% elif (error.code or 500) == 413 %}Payload too large
{% elif (error.code or 500) == 415 %}Unsupported media type
{% elif (error.code or 500) == 415 %}Nieobs&#322;ugiwany format listy
{% elif (error.code or 500) == 422 %}Nie mo&#380;na przetworzy&#263; listy
{% elif (error.code or 500) == 502 %}Nie uda&#322;o si&#281; pobra&#263; listy
{% elif (error.code or 500) == 500 %}Internal server error
{% else %}Something went wrong
{% endif %}
+122
View File
@@ -0,0 +1,122 @@
import unittest
from source_conversion import (
InvalidSourceError,
SourceTooLargeError,
convert_source,
iter_completed_bytes,
)
class FakeUpstream:
def __init__(self, body, headers=None, fail_after=None):
self.body = body
self.headers = headers or {"Content-Type": "text/plain; charset=utf-8"}
self.fail_after = fail_after
def iter_content(self, chunk_size):
for i in range(0, len(self.body), chunk_size):
yield self.body[i:i + chunk_size]
if self.fail_after is not None and i >= self.fail_after:
raise IOError("upstream disconnected")
def convert_test_line(line, ip):
line = line.strip()
if not line or line.startswith('#'):
return None
if line.startswith('||') and line.endswith('^'):
return f'{ip} {line[2:-1]}'
if line.startswith('0.0.0.0 '):
return f'{ip} {line.split()[1]}'
if line.endswith('.test'):
return f'{ip} {line}'
return None
def run_conversion(body, *, headers=None, chunk=7, source_limit=1024, output_limit=1024,
line_limit=128, debug=None, fail_after=None):
return convert_source(
FakeUpstream(body, headers=headers, fail_after=fail_after),
"0.0.0.0",
convert_test_line,
read_chunk=chunk,
line_limit=line_limit,
source_limit=source_limit,
output_limit=output_limit,
spool_memory_limit=16,
debug_lines=debug,
)
class ConvertSourceTests(unittest.TestCase):
def test_valid_host_list_is_bytes_and_completed(self):
src = b'# comment\n0.0.0.0 first.test\n||second.test^\n'
result = run_conversion(src)
body_chunks = list(iter_completed_bytes(result.output, 5))
self.assertTrue(all(isinstance(v, bytes) for v in body_chunks))
self.assertEqual(b''.join(body_chunks), b'0.0.0.0 first.test\n0.0.0.0 second.test\n')
self.assertTrue(result.output.closed)
self.assertEqual(result.lines_emitted, 2)
def test_octet_stream_without_charset(self):
result = run_conversion(
b'example.test\n', headers={"Content-Type": "application/octet-stream"}
)
self.assertEqual(result.output.read(), b'0.0.0.0 example.test\n')
result.output.close()
def test_utf8_bom(self):
result = run_conversion(b'\xef\xbb\xbfexample.test\n')
self.assertEqual(result.output.read(), b'0.0.0.0 example.test\n')
result.output.close()
def test_no_supported_domain_returns_error(self):
with self.assertRaises(InvalidSourceError):
run_conversion(b'<!doctype html>\n<h1>Not found</h1>\n')
def test_invalid_encoding_returns_error(self):
with self.assertRaises(InvalidSourceError):
run_conversion(b'first.test\n\xff\xfe\n')
def test_binary_null_is_rejected(self):
with self.assertRaises(InvalidSourceError):
run_conversion(b'first.test\n\x00\x00\n')
def test_source_limit(self):
with self.assertRaises(SourceTooLargeError):
run_conversion(b'first.test\n', source_limit=5)
def test_response_content_length_limit(self):
with self.assertRaises(SourceTooLargeError):
run_conversion(b'first.test\n', headers={
"Content-Type": "application/octet-stream", "Content-Length": "2048"
}, source_limit=100)
def test_output_limit(self):
with self.assertRaises(SourceTooLargeError):
run_conversion(b'first.test\n', output_limit=8)
def test_overlong_line_is_skipped_without_buffering_it(self):
result = run_conversion(b'A' * 1000 + b'\nfirst.test\n', line_limit=32, chunk=4)
self.assertEqual(result.output.read(), b'0.0.0.0 first.test\n')
result.output.close()
def test_exception_from_upstream_happens_before_success_result(self):
with self.assertRaisesRegex(IOError, 'disconnected'):
run_conversion(b'first.test\nsecond.test\n', fail_after=7)
def test_debug_is_bytes(self):
result = run_conversion(b'first.test\n', debug=['# [DEBUG] start'])
output = result.output.read()
self.assertIn(b'# [DEBUG] start\n', output)
self.assertIn(b'0.0.0.0 first.test\n', output)
result.output.close()
def test_unrecognized_charset_rejected(self):
with self.assertRaises(InvalidSourceError):
run_conversion(b'first.test', headers={"Content-Type": "text/plain; charset=weird-charset-zzz"})
if __name__ == "__main__":
unittest.main()