fix
This commit is contained in:
1 parent
46d986ce80
commit
ad620c976a
5 files changed
+431
-151
No files matched your search
@@ -25,6 +25,12 @@ from flask import (
|
|||||||
Response,
|
Response,
|
||||||
)
|
)
|
||||||
from flask_compress import Compress
|
from flask_compress import Compress
|
||||||
|
from source_conversion import (
|
||||||
|
InvalidSourceError,
|
||||||
|
SourceTooLargeError,
|
||||||
|
convert_source,
|
||||||
|
iter_completed_bytes,
|
||||||
|
)
|
||||||
from flask_limiter import Limiter
|
from flask_limiter import Limiter
|
||||||
import config
|
import config
|
||||||
|
|
||||||
@@ -381,31 +387,18 @@ def index():
|
|||||||
@app.route("/convert")
|
@app.route("/convert")
|
||||||
@limiter.limit(config.RATE_LIMIT_CONVERT)
|
@limiter.limit(config.RATE_LIMIT_CONVERT)
|
||||||
def convert():
|
def convert():
|
||||||
|
# Flask automatically maps HEAD to a GET route as well.
|
||||||
|
if request.method == "HEAD":
|
||||||
def is_private_client_ip() -> bool:
|
return convert_head()
|
||||||
ip = get_client_ip()
|
|
||||||
try:
|
|
||||||
return ipaddress.ip_address(ip).is_private
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
requested_debug = request.args.get("debug", "").lower() in (
|
requested_debug = request.args.get("debug", "").lower() in (
|
||||||
"1",
|
"1", "true", "t", "yes", "y", "on",
|
||||||
"true",
|
|
||||||
"t",
|
|
||||||
"yes",
|
|
||||||
"y",
|
|
||||||
"on",
|
|
||||||
)
|
)
|
||||||
debug_allowed = False
|
debug_allowed = False
|
||||||
if config.DEBUG_ENABLE:
|
if config.DEBUG_ENABLE:
|
||||||
header_key = request.headers.get("X-Debug-Key", "")
|
header_key = request.headers.get("X-Debug-Key", "")
|
||||||
if (
|
if (config.DEBUG_KEY and header_key
|
||||||
config.DEBUG_KEY
|
and hmac.compare_digest(header_key, config.DEBUG_KEY)):
|
||||||
and header_key
|
|
||||||
and hmac.compare_digest(header_key, config.DEBUG_KEY)
|
|
||||||
):
|
|
||||||
debug_allowed = True
|
debug_allowed = True
|
||||||
elif is_private_client_ip():
|
elif is_private_client_ip():
|
||||||
debug_allowed = True
|
debug_allowed = True
|
||||||
@@ -417,182 +410,160 @@ def convert():
|
|||||||
debug_lines = []
|
debug_lines = []
|
||||||
|
|
||||||
def d(msg):
|
def d(msg):
|
||||||
ts = datetime.now().isoformat()
|
line = f"# [DEBUG {datetime.now().isoformat()}] {msg}"
|
||||||
line = f"# [DEBUG {ts}] {msg}"
|
|
||||||
debug_lines.append(line)
|
debug_lines.append(line)
|
||||||
app.logger.debug(line)
|
app.logger.debug(line)
|
||||||
|
|
||||||
def debug_response(status=200):
|
def debug_response(status=200):
|
||||||
body = "\n".join(debug_lines) + ("\n" if debug_lines else "")
|
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["X-Debug-Mode"] = "1"
|
||||||
resp.headers["Cache-Control"] = "no-store"
|
resp.headers["Cache-Control"] = "no-store"
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
try:
|
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()
|
add_recent_convert()
|
||||||
redis_client.incr("stats:convert_requests")
|
redis_client.incr("stats:convert_requests")
|
||||||
if debug_mode:
|
if debug_mode:
|
||||||
d("Start /convert w trybie debug")
|
d("Start /convert w trybie debug")
|
||||||
|
|
||||||
encoded_url = request.args.get("url")
|
# request.args already URL-decodes the outer query parameter. Calling
|
||||||
if not encoded_url:
|
# unquote() again corrupts percent escapes within the nested source URL.
|
||||||
if debug_mode:
|
source_url = request.args.get("url")
|
||||||
d("Brak parametru ?url")
|
if not source_url:
|
||||||
return debug_response(status=400)
|
return reject(400, "Brak parametru url z adresem listy.")
|
||||||
redis_client.incr("stats:errors_400")
|
|
||||||
abort(400, description="Missing URL parameter")
|
|
||||||
|
|
||||||
decoded_url = unquote(encoded_url)
|
|
||||||
try:
|
try:
|
||||||
normalized_url = validate_and_normalize_url(decoded_url)
|
normalized_url = validate_and_normalize_url(source_url)
|
||||||
except ValueError as e:
|
except ValueError:
|
||||||
if debug_mode:
|
return reject(400, "Nieprawid\u0142owy adres URL listy.")
|
||||||
d(f"Błąd walidacji URL: {e}")
|
|
||||||
return debug_response(status=400)
|
|
||||||
redis_client.incr("stats:errors_400")
|
|
||||||
abort(400)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
target_ip = validate_ip(request.args.get("ip", "127.0.0.1"))
|
target_ip = validate_ip(request.args.get("ip", "127.0.0.1"))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
if debug_mode:
|
return reject(400, "Nieprawid\u0142owy adres IP.")
|
||||||
d("Bad parametr ?ip")
|
|
||||||
return debug_response(status=400)
|
|
||||||
redis_client.incr("stats:errors_400")
|
|
||||||
abort(400, description="Invalid IP")
|
|
||||||
|
|
||||||
if debug_mode:
|
if debug_mode:
|
||||||
d(f"URL (encoded): {encoded_url}")
|
d(f"URL: {normalized_url}")
|
||||||
d(f"URL (decoded): {decoded_url}")
|
|
||||||
d(f"URL (norm): {normalized_url}")
|
|
||||||
d(f"target_ip: {target_ip}")
|
d(f"target_ip: {target_ip}")
|
||||||
|
|
||||||
track_url_request(normalized_url)
|
track_url_request(normalized_url)
|
||||||
redis_client.incr(f"stats:target_ips:{target_ip}")
|
redis_client.incr(f"stats:target_ips:{target_ip}")
|
||||||
|
|
||||||
# nagłówki If-*
|
|
||||||
req_headers = {}
|
req_headers = {}
|
||||||
inm = request.headers.get("If-None-Match")
|
for client_header in ("If-None-Match", "If-Modified-Since"):
|
||||||
ims = request.headers.get("If-Modified-Since")
|
value = request.headers.get(client_header)
|
||||||
if inm:
|
if value:
|
||||||
req_headers["If-None-Match"] = inm
|
req_headers[client_header] = value
|
||||||
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)
|
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
|
||||||
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')}")
|
|
||||||
|
|
||||||
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:
|
|
||||||
etag = build_etag(
|
|
||||||
r.headers.get("ETag"), r.headers.get("Last-Modified"), target_ip
|
|
||||||
)
|
|
||||||
if debug_mode:
|
|
||||||
d("Upstream 304 – zwracam 304")
|
|
||||||
r.close()
|
|
||||||
add_recent_link(normalized_url, target_ip)
|
|
||||||
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()
|
|
||||||
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}")
|
|
||||||
|
|
||||||
@stream_with_context
|
|
||||||
def body_gen():
|
|
||||||
lines_read = 0
|
|
||||||
lines_emitted = 0
|
|
||||||
try:
|
try:
|
||||||
if debug_mode:
|
if debug_mode:
|
||||||
yield "\n".join(debug_lines) + "\n"
|
d(f"Upstream HTTP: {upstream.status_code}")
|
||||||
debug_lines.clear()
|
d(f"Content-Type: {upstream.headers.get('Content-Type', '(brak)')}")
|
||||||
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()
|
|
||||||
|
|
||||||
resp = Response(body_gen(), mimetype="text/plain; charset=utf-8")
|
if upstream.status_code == 304:
|
||||||
resp.headers.update(cache_headers(etag, up_lm))
|
etag = build_etag(
|
||||||
resp.direct_passthrough = True
|
upstream.headers.get("ETag"),
|
||||||
redis_client.incr("stats:conversions_success")
|
upstream.headers.get("Last-Modified"),
|
||||||
|
target_ip,
|
||||||
|
)
|
||||||
|
if debug_mode:
|
||||||
|
d("Upstream HTTP 304")
|
||||||
|
return debug_response(status=304)
|
||||||
|
resp = Response(status=304)
|
||||||
|
resp.headers.update(cache_headers(etag, upstream.headers.get("Last-Modified")))
|
||||||
add_recent_link(normalized_url, target_ip)
|
add_recent_link(normalized_url, target_ip)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
if upstream.status_code != 200:
|
||||||
app.logger.error(f"Request error: {str(e)}")
|
return reject(
|
||||||
redis_client.incr("stats:errors_500")
|
502,
|
||||||
if debug_mode:
|
f"Nie mo\u017cna pobra\u0107 listy. Serwer \u017ar\u00f3d\u0142owy zwr\u00f3ci\u0142 HTTP {upstream.status_code}.",
|
||||||
d(f"Wyjątek requests: {e}")
|
)
|
||||||
return debug_response(status=502)
|
|
||||||
abort(500)
|
content_type = upstream.headers.get("Content-Type", "")
|
||||||
except ValueError as e:
|
media_type = content_type.split(";", 1)[0].strip().lower()
|
||||||
app.logger.error(f"URL validation error: {str(e)}")
|
if (media_type and (
|
||||||
redis_client.incr("stats:errors_400")
|
media_type in {"text/html", "application/xhtml+xml"}
|
||||||
if debug_mode:
|
or not (media_type.startswith("text/") or media_type == "application/octet-stream")
|
||||||
d(f"Wyjątek ValueError: {e}")
|
)):
|
||||||
return debug_response(status=400)
|
return reject(415, "Podany adres nie zwraca obs\u0142ugiwanej listy tekstowej.")
|
||||||
abort(400)
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
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"])
|
@app.route("/convert", methods=["HEAD"])
|
||||||
def convert_head():
|
def convert_head():
|
||||||
encoded_url = request.args.get("url", config.DEFAULT_SOURCE_URL)
|
source_url = request.args.get("url", config.DEFAULT_SOURCE_URL)
|
||||||
if not encoded_url:
|
if not source_url:
|
||||||
abort(400)
|
abort(400, description="Brak parametru url z adresem listy.")
|
||||||
decoded_url = unquote(encoded_url)
|
try:
|
||||||
validate_and_normalize_url(decoded_url)
|
validate_and_normalize_url(source_url)
|
||||||
target_ip = validate_ip(request.args.get("ip", "127.0.0.1"))
|
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)
|
etag = build_etag(None, None, target_ip)
|
||||||
resp = Response(status=200)
|
resp = Response(status=200)
|
||||||
resp.headers.update(cache_headers(etag, None))
|
resp.headers.update(cache_headers(etag, None))
|
||||||
resp.direct_passthrough = True
|
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@@ -770,6 +741,8 @@ def stats_json():
|
|||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
@app.errorhandler(413)
|
@app.errorhandler(413)
|
||||||
@app.errorhandler(415)
|
@app.errorhandler(415)
|
||||||
|
@app.errorhandler(422)
|
||||||
|
@app.errorhandler(502)
|
||||||
@app.errorhandler(500)
|
@app.errorhandler(500)
|
||||||
def handle_errors(e):
|
def handle_errors(e):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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
|
READ_CHUNK = getenv_int("READ_CHUNK", 64 * 1024) # 64 KiB
|
||||||
STREAM_LINE_LIMIT= getenv_int("STREAM_LINE_LIMIT", 4096)
|
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
|
# Serwer
|
||||||
BIND_HOST = os.getenv("BIND_HOST", "127.0.0.1")
|
BIND_HOST = os.getenv("BIND_HOST", "127.0.0.1")
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -36,7 +36,9 @@
|
|||||||
{% elif (error.code or 500) == 403 %}Forbidden
|
{% elif (error.code or 500) == 403 %}Forbidden
|
||||||
{% elif (error.code or 500) == 404 %}Not found
|
{% elif (error.code or 500) == 404 %}Not found
|
||||||
{% elif (error.code or 500) == 413 %}Payload too large
|
{% 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ługiwany format listy
|
||||||
|
{% elif (error.code or 500) == 422 %}Nie można przetworzyć listy
|
||||||
|
{% elif (error.code or 500) == 502 %}Nie udało się pobrać listy
|
||||||
{% elif (error.code or 500) == 500 %}Internal server error
|
{% elif (error.code or 500) == 500 %}Internal server error
|
||||||
{% else %}Something went wrong
|
{% else %}Something went wrong
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in new issue
Block a user