This commit is contained in:
Mateusz Gruszczyński committed 2026-10-09 10:53:10 +02:00
1 parent ad620c976a
commit 0973129c17
26 files changed
+2255 -2245

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
# `/convert` shared cache — RustProxy 0.29.3 and Varnish
Only fully downloaded, decoded and converted lists return a cacheable HTTP 200.
All errors (400, 413, 415, 422, 429, 502, etc.) return `Cache-Control: no-store`.
Authorized debug responses (`?debug=1`) are never shared or cached.
## Example successful response
```http
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 12345
ETag: "<sha256 of converted response bytes>"
Cache-Control: public, max-age=3600, s-maxage=604800, stale-while-revalidate=86400, stale-if-error=604800
X-Content-Type-Options: nosniff
```
- `max-age`: cache freshness for browsers/clients (default 1 hour).
- `s-maxage`: freshness for shared caches such as RustProxy/Varnish (default 7 days).
- `stale-while-revalidate`: permits serving a stored stale object while the proxy refreshes it (1 additional day by default; cache support required).
- `stale-if-error`: permits serving an already-stored stale object if refresh fails (7 additional days by default; cache support required).
- `ETag`: SHA-256 of the **converted bytes**, not the upstream file ETag. Identical converted bodies have matching ETags. `If-None-Match` can produce an HTTP 304, but only after the application checks the complete source on a backend request. On a normal proxy HIT, no application work is performed.
- The upstream source is always fetched as an unconditional GET. Client validators must not be sent to the **source list** because they refer to the **converted** resource.
- GET and HEAD use the same validated body, ETag and length; HEAD does not send response bytes.
Environment variables in `/var/www/adlist_mikrotik/.env`:
```dotenv
CACHE_ENABLED=true
CACHE_MAX_AGE=3600
CACHE_S_MAXAGE=604800
CACHE_STALE_WHILE_REVALIDATE=86400
CACHE_STALE_IF_ERROR=604800
```
Existing `.env` files override `config.py` defaults, so update the actual deployed `.env` after upgrading. Restart the `listapp` service to apply changes.
## RustProxy (important: origin headers alone cannot override BYPASS)
1. Enable the global cache and caching on the `listapp.linuxiarz.pl` vhost.
2. Set `max_ttl_seconds=604800` globally and/or on that vhost. RustProxy 0.29.3 has a **default cap of 86400 seconds**, and an allowed maximum of **604800 seconds**. Longer origin `s-maxage` values cannot override the cap.
3. Set `bypass_query=false`: `/convert` **requires** a query string (`url=...&ip=...`). Check `bypass_prefixes` to make sure `/convert` is not excluded.
4. Allow sufficient `max_object_bytes` for the **converted result**, not the source. The RustProxy default is 16 MiB; objects larger than the limit will not be stored. If necessary, increase the total HOT/transient budgets too.
5. Optionally enable `cold_enabled=true` and size COLD appropriately, so cached objects can survive RAM eviction and regular restarts. COLD is still bounded and not permanent storage.
6. Requests with `Cookie`, `Authorization`, `Cache-Control: no-store`, or various forced revalidation directives may BYPASS / revalidate. Normal unauthenticated periodic scans without these request headers are best for high cache hit rate. If you configure an exception, scope it only to public `/convert` requests and consider the security implications.
7. Enable cache diagnostics `expose_status`, `expose_cache_status`, and optionally `expose_resident_age` to inspect MISS/HIT/BYPASS and reason codes.
A long TTL does not guarantee physical retention: eviction, explicit purge, size limits and settings may remove entries. Source updates are not visible to normal cache users until freshness expires or the cache is purged; choose TTL accordingly.
## Varnish
Varnish can use `Cache-Control: public, s-maxage=...` (subject to VCL policy, cookies, Authorization, cache size and eviction). It must be configured to pass/cache the `/convert` route appropriately. The application does not use an internal Redis response-body cache; the shared proxy stores successful responses.
## Verification
Use the *same full URL* repeatedly, with GET (not HEAD), without cookies or request `Cache-Control: no-cache`. For example:
```bash
BASE='https://listapp.linuxiarz.pl/convert'
for n in 1 2 3; do
echo "Request $n"
curl -sS --get \
--data-urlencode 'url=https://v.firebog.net/hosts/RPiList-Malware.txt' \
--data-urlencode 'ip=0.0.0.0' \
-D - -o /dev/null "$BASE" | \
grep -Ei '^(HTTP/|cache-control:|etag:|age:|x-cache:|cache-status:|content-length:|content-type:)'
sleep 2
done
```
Expect `HTTP 200` and the `public, s-maxage` header from the application. If RustProxy caching is eligible, a typical sequence is `MISS`, `HIT`, `HIT`, while `Age` increases. `BYPASS` means the proxy did not even attempt a cache fill (check request and proxy configuration); `MISS` followed by repeated `MISS` can mean oversize objects, excluded responses or aborted body transfer. These labels require corresponding diagnostics to be enabled on the proxy.
Run local code checks:
```bash
python3 -m unittest discover -s tests -p 'test_*.py' -v
python3 tests/smoke_cache_routes.py
```
+56
View File
@@ -0,0 +1,56 @@
# Wdrożenie na host.linuxiarz.pl
Paczka jest przygotowana jako aktualizacja wersji `adlist_mikrotik-cache-7d-english`.
**Nie nadpisuj** istniejącego `.env`, katalogu `venv` ani własnych danych Redis.
## 1. Kopia bezpieczeństwa
```bash
sudo systemctl stop listapp
sudo cp -a /var/www/adlist_mikrotik "/var/www/adlist_mikrotik.backup.$(date +%Y%m%d-%H%M%S)"
```
## 2. Instalacja ZIP
Rozpakuj plik w osobnym katalogu. Po rozpakowaniu katalog `adlist_mikrotik/`
z paczki można skopiować bez naruszania środowiska uruchomieniowego:
```bash
sudo rsync -a --exclude='.env' --exclude='venv/' --exclude='.git/' \
./adlist_mikrotik/ /var/www/adlist_mikrotik/
```
Alternatywnie użyj PATCH, ale **wyłącznie jeśli instalacja odpowiada ostatniej wersji
`adlist_mikrotik-cache-7d-english`**. Wtedy:
```bash
cd /var/www/adlist_mikrotik
sudo git apply --check /tmp/adlist_mikrotik-rustproxy-ui-from-cache7d.patch
sudo git apply /tmp/adlist_mikrotik-rustproxy-ui-from-cache7d.patch
```
Dla PATCH potrzebne jest `git`, ale katalog nie musi być repozytorium.
## 3. Test i restart
```bash
cd /var/www/adlist_mikrotik
/var/www/adlist_mikrotik/venv/bin/python -m compileall -q app.py adlist source_conversion.py
sudo systemctl start listapp
sudo systemctl status listapp --no-pager
sudo journalctl -u listapp -n 60 --no-pager
```
Sprawdź stronę `/`, zabezpieczone `/stats`, a potem `/convert` na poprawnej liście.
W przypadku niepowodzenia odtwórz kopię z kroku 1 i ponownie uruchom usługę.
## Cache
Nagłówki aplikacji nie wymuszają przechowywania obiektów przez proxy.
RustProxy musi mieć włączony cache, `bypass_query=false`, `max_ttl_seconds=604800`
oraz odpowiedni `max_object_bytes`. Błędne odpowiedzi nie są cache'owane.
## Ustawienia produkcyjne
W `.env` ustaw `FLASK_DEBUG=false`, silny `SECRET_KEY` i nieużywane nigdzie
indziej hasło `STATS_BASIC_AUTH_PASS`. Zachowaj własne wartości w istniejącym pliku.
+49
View File
@@ -0,0 +1,49 @@
# Adlist Converter — RustProxy-style UI
A modular Flask/WSGI application that converts a complete, validated hosts/adblock source into a MikroTik-compatible text response. The design uses RustProxy 0.29.3's visual language: dark/light color tokens, dashboard sidebar, typography, panels, controls and compact data tables.
## Deployment compatibility
- Existing entrypoints: `app:app` (WSGI), `app:asgi_app` (Gunicorn/Uvicorn via `WsgiToAsgi`).
- Existing paths: `/`, `/convert`, `/stats`, `/stats.json`, `/favicon.ico`.
- Existing Redis keys and `.env` settings are preserved.
- Successful `/convert` GET/HEAD responses retain ETag, 7-day shared cache TTL and conditional 304 handling. Invalid lists return an English error page and `Cache-Control: no-store`.
- UI, `/stats` and `/stats.json` are private/no-store. CSS/JS and favicon use content-hashed `v` query strings so immutable assets refresh on upgrades.
- The generator only constructs links locally; it **does not** fetch or convert the source as a side effect of typing.
## Project structure
```text
app.py Flask/ASGI entrypoint and URL registration
config.py Environment configuration
source_conversion.py Safe decoding, validation and disk-backed output
adlist/
converter.py /convert HTTP route
cache.py Cache-Control, ETag response metadata
validation.py URL, IP and host-list line validation
services.py Redis, request metrics and recent links
metrics.py /stats and /stats.json
pages.py Dashboard and error rendering
templates/
base.html Common sidebar, topbar and theme
form.html Generator and recent links
stats.html Statistics dashboard
error.html English error page
static/
css/main.css RustProxy-style dark/light, responsive CSS
js/main.js UI behavior (vanilla JS)
favicon.svg New local brand icon
tests/ Regression checks
```
## Quick checks
```bash
python3 -m compileall -q app.py config.py source_conversion.py adlist
python3 -m unittest discover -s tests -p 'test_*.py' -v
python3 tests/smoke_routes.py
```
The standalone smoke test uses module stubs when Flask/Redis are unavailable; it is **not** an integrated production test. Run a real Gunicorn/Uvicorn smoke check after deployment.
See `DEPLOY.md` for backups, upgrade steps and rollback. See `CACHE_SETUP.md` for RustProxy/Varnish cache configuration.
View File
Whitespace-only changes.
+34
View File
@@ -0,0 +1,34 @@
"""HTTP cache policy of the completed converted representation."""
import config
def cache_headers(content_sha256: str, *, debug_mode: bool = False) -> dict[str, str]:
"""Cache headers for a fully validated, byte-identical converted representation.
The ETag must describe the actual converted bytes, never the source ETag:
different sources, target IPs and debug output can have different bodies.
"""
headers = {
"Content-Type": "text/plain; charset=utf-8",
"X-Content-Type-Options": "nosniff",
}
if debug_mode:
# Debug output contains request-specific information and timestamps.
headers["Cache-Control"] = "private, no-store"
headers["X-Debug-Mode"] = "1"
elif not config.CACHE_ENABLED or config.CACHE_S_MAXAGE == 0:
headers["Cache-Control"] = "no-store"
else:
headers["ETag"] = f'"{content_sha256}"'
directives = [
"public",
f"max-age={config.CACHE_MAX_AGE}",
f"s-maxage={config.CACHE_S_MAXAGE}",
]
if config.CACHE_STALE_WHILE_REVALIDATE:
directives.append(
f"stale-while-revalidate={config.CACHE_STALE_WHILE_REVALIDATE}"
)
if config.CACHE_STALE_IF_ERROR:
directives.append(f"stale-if-error={config.CACHE_STALE_IF_ERROR}")
headers["Cache-Control"] = ", ".join(directives)
return headers
+171
View File
@@ -0,0 +1,171 @@
"""The /convert endpoint: validate, download, spool, then return cacheable bytes."""
import hmac
from datetime import datetime
import ipaddress
import requests
from flask import request, abort, Response, current_app
from source_conversion import InvalidSourceError, SourceTooLargeError, convert_source, iter_completed_bytes
from adlist.cache import cache_headers
from adlist.validation import validate_and_normalize_url, validate_ip, convert_host_line
from adlist.services import (
redis_client, get_client_ip, track_url_request, add_recent_convert, add_recent_link
)
import config
def is_private_client_ip() -> bool:
try:
return ipaddress.ip_address(get_client_ip()).is_private
except (ValueError, TypeError):
return False
def convert():
# Flask maps HEAD to this GET handler; the same validated representation
# and ETag are generated for both methods (no synthetic 200 for HEAD).
requested_debug = request.args.get("debug", "").lower() in (
"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)):
debug_allowed = True
elif is_private_client_ip():
debug_allowed = True
if requested_debug and not debug_allowed:
abort(403)
debug_mode = requested_debug and debug_allowed
debug_lines = []
def d(msg):
line = f"# [DEBUG {datetime.now().isoformat()}] {msg}"
debug_lines.append(line)
current_app.logger.debug(line)
def debug_response(status=200):
body = "\n".join(debug_lines) + ("\n" if debug_lines else "")
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("Starting /convert in debug mode")
# 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, "Missing required 'url' parameter for the source list.")
try:
normalized_url = validate_and_normalize_url(source_url)
except ValueError:
return reject(400, "Invalid source list URL.")
try:
target_ip = validate_ip(request.args.get("ip", "127.0.0.1"))
except ValueError:
return reject(400, "Invalid target IP address.")
if debug_mode:
d(f"URL: {normalized_url}")
d(f"target_ip: {target_ip}")
track_url_request(normalized_url)
redis_client.incr(f"stats:target_ips:{target_ip}")
# Client validators refer to the CONVERTED body, not to the upstream list.
# Passing If-None-Match/If-Modified-Since upstream could cause an upstream
# 304 without any body to convert, or incorrectly validate another URL/IP.
try:
upstream = requests.get(normalized_url, stream=True, timeout=(10, 60))
except requests.exceptions.RequestException as exc:
current_app.logger.warning("Cannot fetch source list: %s", exc)
return reject(502, "Unable to fetch the source list from the provided URL.")
result = None
try:
if debug_mode:
d(f"Upstream HTTP: {upstream.status_code}")
d(f"Content-Type: {upstream.headers.get('Content-Type', '(missing)')}")
if upstream.status_code != 200:
return reject(
502,
f"Unable to fetch the source list. The source server returned HTTP {upstream.status_code}.",
)
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, "The URL does not return a supported plain-text host list.")
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:
current_app.logger.info("Source or output size limit exceeded: %s", exc)
return reject(413, "The source list or converted output exceeds the size limit.")
except InvalidSourceError as exc:
current_app.logger.info("Unsupported or corrupted source list: %s", exc)
return reject(
422,
"Unable to decode or process this list. "
"Check the source URL and file format.",
)
except requests.exceptions.RequestException as exc:
current_app.logger.warning("Error while reading source body: %s", exc)
return reject(502, "The source server interrupted the list download.")
finally:
upstream.close()
# Only a completely decoded and validated byte stream is cacheable.
# Its strong ETag is SHA-256 of the converted bytes, not the origin ETag.
if request.method == "HEAD":
result.output.close()
resp = Response(status=200, content_type="text/plain; charset=utf-8")
else:
resp = Response(
iter_completed_bytes(result.output, config.READ_CHUNK),
content_type="text/plain; charset=utf-8",
)
resp.direct_passthrough = True
resp.call_on_close(result.output.close)
resp.headers.update(cache_headers(result.content_sha256, debug_mode=debug_mode))
resp.headers["Content-Length"] = str(result.output_size)
if not debug_mode and "ETag" in resp.headers:
# Flask/Werkzeug implements HTTP conditional GET/HEAD correctly for
# the final representation. It may return 304 only after validating
# the complete content hash. Never use the source's validators.
resp.make_conditional(request)
if resp.status_code == 304:
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
+76
View File
@@ -0,0 +1,76 @@
"""Read-only statistics endpoints backed by Redis."""
import json
from urllib.parse import unquote, urlsplit, parse_qs
from flask import render_template, jsonify
from adlist.services import redis_client
def collect_stats():
stats_data, target_ips, url_requests, user_agents, client_ips = {}, {}, {}, {}, {}
for key in redis_client.scan_iter("stats:*"):
key_str = key.decode()
value = (redis_client.get(key) or b"0").decode()
if key_str.startswith("stats:target_ips:"):
target_ips[key_str.split(":", 2)[2]] = value
elif key_str.startswith("stats:url_requests:"):
url_requests[unquote(key_str.split(":", 2)[2])] = value
elif key_str.startswith("stats:user_agents:"):
user_agents[unquote(key_str.split(":", 2)[2])] = value
elif key_str.startswith("stats:client_ips:"):
client_ips[key_str.split(":", 2)[2]] = value
else:
stats_data[key_str] = value
recent = []
for item in redis_client.lrange("recent_converts", 0, 99):
try:
row = json.loads(item.decode())
params = parse_qs(urlsplit(row.get("url", "")).query)
row["source_url"] = params.get("url", [""])[0]
row["target_ip"] = params.get("ip", ["127.0.0.1"])[0]
recent.append(row)
except (ValueError, TypeError, AttributeError):
continue
total_time = float(redis_client.get("stats:processing_time_total") or 0)
count_time = int(redis_client.get("stats:processing_time_count") or 0)
total_size = int(redis_client.get("stats:content_size_total") or 0)
count_size = int(redis_client.get("stats:content_size_count") or 0)
detailed = {
"processing_time_total_sec": total_time,
"processing_time_count": count_time,
"processing_time_avg_sec": total_time / count_time if count_time else 0,
"processing_time_min_sec": float(redis_client.get("stats:processing_time_min") or 0),
"processing_time_max_sec": float(redis_client.get("stats:processing_time_max") or 0),
"content_size_total_bytes": total_size,
"content_size_count": count_size,
"content_size_avg_bytes": total_size / count_size if count_size else 0,
}
return {
**stats_data,
"target_ips": target_ips,
"url_requests": url_requests,
"user_agents": user_agents,
"client_ips": client_ips,
"recent_converts": recent,
"detailed_stats": detailed,
}
def stats():
data = collect_stats()
return render_template(
"stats.html",
stats=data,
target_ips=data["target_ips"],
url_requests=data["url_requests"],
user_agents=data["user_agents"],
client_ips=data["client_ips"],
recent=data["recent_converts"],
detailed=data["detailed_stats"],
raw_json=json.dumps(data, indent=2),
)
def stats_json():
return jsonify(collect_stats())
+27
View File
@@ -0,0 +1,27 @@
"""Public page and consistent HTTP error rendering."""
from datetime import datetime
from flask import request, render_template, Response, jsonify, current_app
from adlist import services
import config
def favicon():
return current_app.send_static_file("favicon.svg")
def index():
recent_links = services.get_recent_links()
return render_template(
"form.html",
recent_links=recent_links,
client_ip=services.get_client_ip(),
cache_days=config.CACHE_S_MAXAGE // 86400,
)
def handle_errors(error):
status = getattr(error, "code", 500)
try:
return render_template("error.html", error=error, code=status), status
except Exception:
return jsonify({"error": getattr(error, "description", "Unexpected error"), "code": status}), status
+174
View File
@@ -0,0 +1,174 @@
"""Redis-backed counters, recent links and request lifecycle hooks."""
import redis
import socket
import time
import json
from datetime import datetime
from functools import wraps
from urllib.parse import quote
from flask import request, Response, g
import config
def build_redis():
if config.REDIS_URL:
return redis.Redis.from_url(config.REDIS_URL)
return redis.Redis(
host=config.REDIS_HOST, port=config.REDIS_PORT, db=config.REDIS_DB
)
redis_client = build_redis()
def get_client_ip():
xff = request.headers.get("X-Forwarded-For", "").split(",")
if xff and xff[0].strip():
return xff[0].strip()
return request.remote_addr
def track_request_data():
g.start_time = time.perf_counter()
redis_client.incr(
f"stats:user_agents:{quote(request.headers.get('User-Agent', 'Unknown'), safe='')}"
)
redis_client.incr(f"stats:client_ips:{get_client_ip()}")
redis_client.incr(f"stats:methods:{request.method}")
def finalize_response(response):
elapsed = time.perf_counter() - getattr(g, "start_time", time.perf_counter())
redis_client.incrbyfloat("stats:processing_time_total", elapsed)
redis_client.incr("stats:processing_time_count")
try:
current_min = float(redis_client.get("stats:processing_time_min") or elapsed)
if elapsed < current_min:
redis_client.set("stats:processing_time_min", elapsed)
except Exception:
redis_client.set("stats:processing_time_min", elapsed)
try:
current_max = float(redis_client.get("stats:processing_time_max") or elapsed)
if elapsed > current_max:
redis_client.set("stats:processing_time_max", elapsed)
except Exception:
redis_client.set("stats:processing_time_max", elapsed)
path = request.path or "/"
if response.status_code >= 400:
response.headers["Cache-Control"] = "no-store"
return response
if path.startswith("/static/"):
response.headers.pop("Content-Disposition", None)
# Only versioned assets are immutable. Unversioned files must not stay stale after upgrades.
if request.args.get("v"):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
else:
response.headers["Cache-Control"] = "public, max-age=3600"
return response
if path in {"/", "/stats", "/stats.json"}:
# Only validated /convert outputs are public cache objects.
response.headers["Cache-Control"] = "private, no-store"
return response
return response
def datetimeformat_filter(value, format="%Y-%m-%d %H:%M"):
try:
dt = datetime.fromisoformat(value)
return dt.strftime(format)
except (ValueError, AttributeError):
return value
def basic_auth_required(realm: str, user: str, password: str):
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
if not config.STATS_BASIC_AUTH_ENABLED:
return f(*args, **kwargs)
auth = request.authorization
if (
auth
and auth.type == "basic"
and auth.username == user
and auth.password == password
):
return f(*args, **kwargs)
resp = Response(status=401)
resp.headers["WWW-Authenticate"] = f'Basic realm="{realm}"'
return resp
return wrapper
return decorator
def track_url_request(url):
redis_client.incr(f"stats:url_requests:{quote(url, safe='')}")
def add_recent_link(url, target_ip):
ts = datetime.now().isoformat()
new_item = f"{ts}|{url}|{target_ip}"
key = "recent_links"
current = redis_client.lrange(key, 0, -1)
filtered = []
for raw in current:
try:
s = raw.decode()
parts = s.split("|")
if len(parts) >= 3 and parts[1] == url and parts[2] == target_ip:
continue
except Exception:
pass
filtered.append(raw)
with redis_client.pipeline() as pipe:
pipe.delete(key)
pipe.lpush(key, new_item)
if filtered:
pipe.rpush(key, *filtered[:99])
pipe.ltrim(key, 0, 99)
pipe.execute()
redis_client.incr("stats:recent_links_added")
def get_recent_links():
links = redis_client.lrange("recent_links", 0, 99)
out = []
for link in links:
parts = link.decode().split("|")
if len(parts) >= 3:
out.append((parts[0], parts[1], parts[2]))
elif len(parts) == 2:
out.append((parts[0], parts[1], "127.0.0.1"))
return out
def get_hostname(ip):
key = f"reverse_dns:{ip}"
cached = redis_client.get(key)
if cached:
return cached.decode()
try:
hostname = socket.gethostbyaddr(ip)[0]
except Exception:
hostname = ip
redis_client.setex(key, 3600, hostname)
return hostname
def add_recent_convert():
ip = get_client_ip()
hostname = get_hostname(ip)
ua = request.headers.get("User-Agent", "Unknown")
time_str = datetime.now().astimezone().isoformat()
url = request.full_path
data = {
"url": url,
"ip": ip,
"hostname": hostname,
"time": time_str,
"user_agent": ua,
}
redis_client.lpush("recent_converts", json.dumps(data))
redis_client.ltrim("recent_converts", 0, 99)
+90
View File
@@ -0,0 +1,90 @@
"""Pure input validation and supported host-list line formats."""
import ipaddress
import re
from urllib.parse import urlparse
def should_ignore_domain(domain):
return domain.startswith(".") or any(
ch in domain for ch in ["~", "=", "$", "'", "^", "_", ">", "<", ":"]
)
def should_ignore_line(line):
return any(sym in line for sym in ["<", ">", "##", "###", "div", "span"])
def is_valid_domain(domain):
return bool(re.compile(r"^(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$").match(domain))
def convert_host_line(line: str, target_ip: str):
# szybkie odrzucenia
if not line:
return None
line = line.strip()
if not line or line.startswith(("!", "#", "/", ";")):
return None
for sep in (" #", " ;"):
idx = line.find(sep)
if idx != -1:
line = line[:idx].rstrip()
if not line:
return None
m = re.match(r"^\|\|([a-z0-9.-]+)\^", line, re.IGNORECASE)
if m:
domain = m.group(1).strip(".")
if not should_ignore_domain(domain) and is_valid_domain(domain):
return f"{target_ip} {domain}"
return None
parts = line.split()
if len(parts) >= 2 and (
re.match(r"^\d{1,3}(?:\.\d{1,3}){3}$", parts[0]) or ":" in parts[0]
):
domain = parts[1].strip().split("#", 1)[0].strip().strip(".")
if not should_ignore_domain(domain) and is_valid_domain(domain):
return f"{target_ip} {domain}"
return None
m = re.match(r"^(?:address|server)=/([a-z0-9.-]+)/", line, re.IGNORECASE)
if m:
domain = m.group(1).strip(".")
if not should_ignore_domain(domain) and is_valid_domain(domain):
return f"{target_ip} {domain}"
return None
token = parts[0].split("#", 1)[0].strip().strip(".")
if token and not should_ignore_domain(token) and is_valid_domain(token):
return f"{target_ip} {token}"
return None
def validate_and_normalize_url(url):
url = (url or "").strip()
# prosta sanity-check: usuń CR/LF
if any(c in url for c in ("\r", "\n")):
raise ValueError("Invalid characters in URL")
parsed = urlparse(url)
if not parsed.scheme:
url = f"https://{url}"
parsed = urlparse(url)
# akceptuj tylko http/https
if parsed.scheme not in {"http", "https"}:
raise ValueError(f"Unsupported scheme: {parsed.scheme}")
if not parsed.netloc:
raise ValueError("Missing host in URL")
return parsed.geturl()
def validate_ip(value: str) -> str:
v = (value or "").strip()
try:
return str(ipaddress.ip_address(v))
except Exception:
raise ValueError("Invalid IP address")
+51 -751
View File
@@ -1,768 +1,68 @@
import re
import redis
import requests
import socket
import time
import json
import hashlib
import ipaddress
import hmac, ipaddress
from datetime import datetime
from urllib.parse import urlparse, quote, unquote, urljoin
from functools import wraps
from typing import Optional
from datetime import timezone
import json as _json
"""WSGI / ASGI application entrypoint. Gunicorn target stays app:asgi_app.
from flask import (
Flask,
request,
render_template,
abort,
jsonify,
stream_with_context,
g,
Response,
)
Routes, caching, validation, and Redis statistics live in the adlist package.
"""
import hashlib
from pathlib import Path
from flask import Flask
from flask_compress import Compress
from source_conversion import (
InvalidSourceError,
SourceTooLargeError,
convert_source,
iter_completed_bytes,
)
from flask_limiter import Limiter
from asgiref.wsgi import WsgiToAsgi
import config
from adlist import services, pages, converter, metrics
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = config.MAX_CONTENT_LENGTH
app.config["SECRET_KEY"] = config.SECRET_KEY
app.config.update(MAX_CONTENT_LENGTH=config.MAX_CONTENT_LENGTH, SECRET_KEY=config.SECRET_KEY)
app.debug = config.FLASK_DEBUG
def build_redis():
if config.REDIS_URL:
return redis.Redis.from_url(config.REDIS_URL)
return redis.Redis(
host=config.REDIS_HOST, port=config.REDIS_PORT, db=config.REDIS_DB
)
redis_client = build_redis()
def get_client_ip():
xff = request.headers.get("X-Forwarded-For", "").split(",")
if xff and xff[0].strip():
return xff[0].strip()
return request.remote_addr
limiter = Limiter(
key_func=get_client_ip,
key_func=services.get_client_ip,
app=app,
default_limits=[config.RATE_LIMIT_DEFAULT],
storage_uri=config.REDIS_URL,
)
Compress(app)
@app.before_request
def track_request_data():
g.start_time = time.perf_counter()
redis_client.incr(
f"stats:user_agents:{quote(request.headers.get('User-Agent', 'Unknown'), safe='')}"
)
redis_client.incr(f"stats:client_ips:{get_client_ip()}")
redis_client.incr(f"stats:methods:{request.method}")
@app.after_request
def finalize_response(response):
elapsed = time.perf_counter() - g.start_time
redis_client.incrbyfloat("stats:processing_time_total", elapsed)
redis_client.incr("stats:processing_time_count")
try:
current_min = float(redis_client.get("stats:processing_time_min") or elapsed)
if elapsed < current_min:
redis_client.set("stats:processing_time_min", elapsed)
except Exception:
redis_client.set("stats:processing_time_min", elapsed)
try:
current_max = float(redis_client.get("stats:processing_time_max") or elapsed)
if elapsed > current_max:
redis_client.set("stats:processing_time_max", elapsed)
except Exception:
redis_client.set("stats:processing_time_max", elapsed)
path = request.path or "/"
if response.status_code >= 400:
response.headers["Cache-Control"] = "no-store"
return response
if path.startswith("/static/"):
response.headers.pop("Content-Disposition", None)
if path.endswith((".css", ".js")):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
else:
response.headers["Cache-Control"] = "public, max-age=86400"
return response
if path == "/":
response.headers["Cache-Control"] = "private, no-store"
return response
return response
@app.template_filter("datetimeformat")
def datetimeformat_filter(value, format="%Y-%m-%d %H:%M"):
try:
dt = datetime.fromisoformat(value)
return dt.strftime(format)
except (ValueError, AttributeError):
return value
def basic_auth_required(realm: str, user: str, password: str):
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
if not config.STATS_BASIC_AUTH_ENABLED:
return f(*args, **kwargs)
auth = request.authorization
if (
auth
and auth.type == "basic"
and auth.username == user
and auth.password == password
):
return f(*args, **kwargs)
resp = Response(status=401)
resp.headers["WWW-Authenticate"] = f'Basic realm="{realm}"'
return resp
return wrapper
return decorator
def cache_key(source_url, ip):
return f"cache:{source_url}:{ip}"
def should_ignore_domain(domain):
return domain.startswith(".") or any(
ch in domain for ch in ["~", "=", "$", "'", "^", "_", ">", "<", ":"]
)
def should_ignore_line(line):
return any(sym in line for sym in ["<", ">", "##", "###", "div", "span"])
def is_valid_domain(domain):
return bool(re.compile(r"^(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$").match(domain))
def is_private_client_ip() -> bool:
ip = get_client_ip()
try:
return ipaddress.ip_address(ip).is_private
except Exception:
return False
def convert_host_line(line: str, target_ip: str):
# szybkie odrzucenia
if not line:
return None
line = line.strip()
if not line or line.startswith(("!", "#", "/", ";")):
return None
for sep in (" #", " ;"):
idx = line.find(sep)
if idx != -1:
line = line[:idx].rstrip()
if not line:
return None
m = re.match(r"^\|\|([a-z0-9.-]+)\^", line, re.IGNORECASE)
if m:
domain = m.group(1).strip(".")
if not should_ignore_domain(domain) and is_valid_domain(domain):
return f"{target_ip} {domain}"
return None
parts = line.split()
if len(parts) >= 2 and (
re.match(r"^\d{1,3}(?:\.\d{1,3}){3}$", parts[0]) or ":" in parts[0]
):
domain = parts[1].strip().split("#", 1)[0].strip().strip(".")
if not should_ignore_domain(domain) and is_valid_domain(domain):
return f"{target_ip} {domain}"
return None
m = re.match(r"^(?:address|server)=/([a-z0-9.-]+)/", line, re.IGNORECASE)
if m:
domain = m.group(1).strip(".")
if not should_ignore_domain(domain) and is_valid_domain(domain):
return f"{target_ip} {domain}"
return None
token = parts[0].split("#", 1)[0].strip().strip(".")
if token and not should_ignore_domain(token) and is_valid_domain(token):
return f"{target_ip} {token}"
return None
def build_etag(
up_etag: Optional[str], up_lastmod: Optional[str], target_ip: str
) -> str:
base = (up_etag or up_lastmod or "no-upstream") + f"::{target_ip}::v1"
return 'W/"' + hashlib.sha1(base.encode("utf-8")).hexdigest() + '"'
def cache_headers(etag: str, up_lm: Optional[str]):
headers = {
"ETag": etag,
"Vary": "Accept-Encoding",
"Content-Type": "text/plain; charset=utf-8",
"X-Content-Type-Options": "nosniff",
# "Content-Disposition": "inline; filename=converted_hosts.txt",
}
if config.CACHE_ENABLED:
headers["Cache-Control"] = (
f"public, s-maxage={config.CACHE_S_MAXAGE}, max-age={config.CACHE_MAX_AGE}"
)
else:
headers["Cache-Control"] = "no-store"
if up_lm:
headers["Last-Modified"] = up_lm
return headers
def validate_and_normalize_url(url):
url = (url or "").strip()
# prosta sanity-check: usuń CR/LF
if any(c in url for c in ("\r", "\n")):
raise ValueError("Invalid characters in URL")
parsed = urlparse(url)
if not parsed.scheme:
url = f"https://{url}"
parsed = urlparse(url)
# akceptuj tylko http/https
if parsed.scheme not in {"http", "https"}:
raise ValueError(f"Unsupported scheme: {parsed.scheme}")
if not parsed.netloc:
raise ValueError("Missing host in URL")
return parsed.geturl()
def track_url_request(url):
redis_client.incr(f"stats:url_requests:{quote(url, safe='')}")
def add_recent_link(url, target_ip):
ts = datetime.now().isoformat()
new_item = f"{ts}|{url}|{target_ip}"
key = "recent_links"
current = redis_client.lrange(key, 0, -1)
filtered = []
for raw in current:
try:
s = raw.decode()
parts = s.split("|")
if len(parts) >= 3 and parts[1] == url and parts[2] == target_ip:
continue
except Exception:
pass
filtered.append(raw)
with redis_client.pipeline() as pipe:
pipe.delete(key)
pipe.lpush(key, new_item)
if filtered:
pipe.rpush(key, *filtered[:99])
pipe.ltrim(key, 0, 99)
pipe.execute()
redis_client.incr("stats:recent_links_added")
def get_recent_links():
links = redis_client.lrange("recent_links", 0, 99)
out = []
for link in links:
parts = link.decode().split("|")
if len(parts) >= 3:
out.append((parts[0], parts[1], parts[2]))
elif len(parts) == 2:
out.append((parts[0], parts[1], "127.0.0.1"))
return out
def get_hostname(ip):
key = f"reverse_dns:{ip}"
cached = redis_client.get(key)
if cached:
return cached.decode()
try:
hostname = socket.gethostbyaddr(ip)[0]
except Exception:
hostname = ip
redis_client.setex(key, 3600, hostname)
return hostname
def add_recent_convert():
ip = get_client_ip()
hostname = get_hostname(ip)
ua = request.headers.get("User-Agent", "Unknown")
time_str = datetime.now().astimezone().isoformat()
url = request.full_path
data = {
"url": url,
"ip": ip,
"hostname": hostname,
"time": time_str,
"user_agent": ua,
}
redis_client.lpush("recent_converts", json.dumps(data))
redis_client.ltrim("recent_converts", 0, 99)
def validate_ip(value: str) -> str:
v = (value or "").strip()
try:
return str(ipaddress.ip_address(v))
except Exception:
raise ValueError("Invalid IP address")
@app.route("/favicon.ico", methods=["GET"])
def favicon():
return Response(status=204)
@app.route("/", methods=["GET"])
def index():
recent_links = get_recent_links()
try:
return render_template(
"form.html",
recent_links=recent_links,
client_ip=get_client_ip(),
user_agent=request.headers.get("User-Agent", "Unknown"),
)
except Exception:
return jsonify(
{
"recent_links": recent_links,
"client_ip": get_client_ip(),
"user_agent": request.headers.get("User-Agent", "Unknown"),
}
)
@app.route("/convert")
@limiter.limit(config.RATE_LIMIT_CONVERT)
def convert():
# 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",
)
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)):
debug_allowed = True
elif is_private_client_ip():
debug_allowed = True
if requested_debug and not debug_allowed:
abort(403)
debug_mode = requested_debug and debug_allowed
debug_lines = []
def d(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, 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:
normalized_url = validate_and_normalize_url(source_url)
except ValueError:
return reject(400, "Nieprawid\u0142owy adres URL listy.")
try:
target_ip = validate_ip(request.args.get("ip", "127.0.0.1"))
except ValueError:
return reject(400, "Nieprawid\u0142owy adres IP.")
if debug_mode:
d(f"URL: {normalized_url}")
d(f"target_ip: {target_ip}")
track_url_request(normalized_url)
redis_client.incr(f"stats:target_ips:{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
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.")
result = None
try:
if debug_mode:
d(f"Upstream HTTP: {upstream.status_code}")
d(f"Content-Type: {upstream.headers.get('Content-Type', '(brak)')}")
if upstream.status_code == 304:
etag = build_etag(
upstream.headers.get("ETag"),
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)
return resp
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}.",
)
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.")
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"])
def convert_head():
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))
return resp
@app.route("/stats")
@basic_auth_required(
realm=config.STATS_BASIC_AUTH_REALM,
user=config.STATS_BASIC_AUTH_USER,
password=config.STATS_BASIC_AUTH_PASS,
)
def stats():
stats_data, target_ips, url_requests, user_agents, client_ips = {}, {}, {}, {}, {}
# Zbierz klucze stats:*
for key in redis_client.scan_iter("stats:*"):
key_str = key.decode()
value = (redis_client.get(key) or b"0").decode()
if key_str.startswith("stats:target_ips:"):
ip = key_str.split(":", 2)[2]
target_ips[ip] = value
elif key_str.startswith("stats:url_requests:"):
url = unquote(key_str.split(":", 2)[2])
url_requests[url] = value
elif key_str.startswith("stats:user_agents:"):
ua = unquote(key_str.split(":", 2)[2])
user_agents[ua] = value
elif key_str.startswith("stats:client_ips:"):
ip = key_str.split(":", 2)[2]
client_ips[ip] = value
else:
stats_data[key_str] = value
recent_converts = []
for entry in redis_client.lrange("recent_converts", 0, 99):
try:
recent_converts.append(json.loads(entry.decode()))
except Exception:
pass
# Agregaty szczegółowe
processing_time_total = float(redis_client.get("stats:processing_time_total") or 0)
processing_time_count = int(redis_client.get("stats:processing_time_count") or 0)
avg_processing_time = (
processing_time_total / processing_time_count
if processing_time_count > 0
else 0
)
content_size_total = int(redis_client.get("stats:content_size_total") or 0)
content_size_count = int(redis_client.get("stats:content_size_count") or 0)
avg_content_size = (
content_size_total / content_size_count if content_size_count > 0 else 0
)
detailed_stats = {
"processing_time_total_sec": processing_time_total,
"processing_time_count": processing_time_count,
"processing_time_avg_sec": avg_processing_time,
"processing_time_min_sec": float(
redis_client.get("stats:processing_time_min") or 0
),
"processing_time_max_sec": float(
redis_client.get("stats:processing_time_max") or 0
),
"content_size_total_bytes": content_size_total,
"content_size_count": content_size_count,
"content_size_avg_bytes": avg_content_size,
}
# Surowe JSON do sekcji "Raw JSON" na stronie
raw_json = _json.dumps(
{
**stats_data,
"target_ips": target_ips,
"url_requests": url_requests,
"user_agents": user_agents,
"client_ips": client_ips,
"recent_converts": recent_converts,
"detailed_stats": detailed_stats,
},
indent=2,
)
return render_template(
"stats.html",
stats=stats_data,
target_ips=target_ips,
url_requests=url_requests,
user_agents=user_agents,
client_ips=client_ips,
recent=recent_converts,
detailed=detailed_stats,
raw_json=raw_json,
)
@app.route("/stats.json")
@basic_auth_required(
realm=config.STATS_BASIC_AUTH_REALM,
user=config.STATS_BASIC_AUTH_USER,
password=config.STATS_BASIC_AUTH_PASS,
)
def stats_json():
stats_data, target_ips, url_requests, user_agents, client_ips = {}, {}, {}, {}, {}
for key in redis_client.scan_iter("stats:*"):
key_str = key.decode()
value = (redis_client.get(key) or b"0").decode()
if key_str.startswith("stats:target_ips:"):
ip = key_str.split(":", 2)[2]
target_ips[ip] = value
elif key_str.startswith("stats:url_requests:"):
url = unquote(key_str.split(":", 2)[2])
url_requests[url] = value
elif key_str.startswith("stats:user_agents:"):
ua = unquote(key_str.split(":", 2)[2])
user_agents[ua] = value
elif key_str.startswith("stats:client_ips:"):
ip = key_str.split(":", 2)[2]
client_ips[ip] = value
else:
stats_data[key_str] = value
recent_converts = []
for entry in redis_client.lrange("recent_converts", 0, 99):
try:
recent_converts.append(json.loads(entry.decode()))
except Exception:
pass
processing_time_total = float(redis_client.get("stats:processing_time_total") or 0)
processing_time_count = int(redis_client.get("stats:processing_time_count") or 0)
avg_processing_time = (
processing_time_total / processing_time_count
if processing_time_count > 0
else 0
)
content_size_total = int(redis_client.get("stats:content_size_total") or 0)
content_size_count = int(redis_client.get("stats:content_size_count") or 0)
avg_content_size = (
content_size_total / content_size_count if content_size_count > 0 else 0
)
detailed_stats = {
"processing_time_total_sec": processing_time_total,
"processing_time_count": processing_time_count,
"processing_time_avg_sec": avg_processing_time,
"processing_time_min_sec": float(
redis_client.get("stats:processing_time_min") or 0
),
"processing_time_max_sec": float(
redis_client.get("stats:processing_time_max") or 0
),
"content_size_total_bytes": content_size_total,
"content_size_count": content_size_count,
"content_size_avg_bytes": avg_content_size,
}
return jsonify(
{
**stats_data,
"target_ips": target_ips,
"url_requests": url_requests,
"user_agents": user_agents,
"client_ips": client_ips,
"recent_converts": recent_converts,
"detailed_stats": detailed_stats,
}
)
@app.errorhandler(400)
@app.errorhandler(403)
@app.errorhandler(404)
@app.errorhandler(413)
@app.errorhandler(415)
@app.errorhandler(422)
@app.errorhandler(502)
@app.errorhandler(500)
def handle_errors(e):
try:
now_iso = datetime.now().astimezone().isoformat()
return render_template(
"error.html", error=e, code=getattr(e, "code", 500), now_iso=now_iso
), getattr(e, "code", 500)
except Exception:
return jsonify(
{
"error": getattr(e, "description", str(e)),
"code": getattr(e, "code", 500),
}
), getattr(e, "code", 500)
app.before_request(services.track_request_data)
app.after_request(services.finalize_response)
app.add_template_filter(services.datetimeformat_filter, "datetimeformat")
# Fingerprinting keeps long-lived cache safe for upgraded CSS / JS assets.
def _asset_revision():
root = Path(__file__).resolve().parent / "static"
digest = hashlib.sha256()
for asset in sorted(root.rglob("*.css")) + sorted(root.rglob("*.js")):
digest.update(asset.relative_to(root).as_posix().encode("utf-8"))
digest.update(asset.read_bytes())
return digest.hexdigest()[:12]
ASSET_REVISION = _asset_revision()
@app.context_processor
def inject_common():
return {"asset_revision": ASSET_REVISION}
app.add_url_rule("/", "index", pages.index, methods=["GET"])
app.add_url_rule("/favicon.ico", "favicon", pages.favicon, methods=["GET"])
app.add_url_rule("/convert", "convert", limiter.limit(config.RATE_LIMIT_CONVERT)(converter.convert), methods=["GET", "HEAD"])
for name, func in (("stats", metrics.stats), ("stats_json", metrics.stats_json)):
wrapped = services.basic_auth_required(
realm=config.STATS_BASIC_AUTH_REALM,
user=config.STATS_BASIC_AUTH_USER,
password=config.STATS_BASIC_AUTH_PASS,
)(func)
app.add_url_rule("/" + ("stats" if name == "stats" else "stats.json"), name, wrapped, methods=["GET"])
for code in (400, 403, 404, 413, 415, 422, 429, 500, 502):
app.register_error_handler(code, pages.handle_errors)
# Compatibility: integrations refer to app:app or app:asgi_app.
asgi_app = WsgiToAsgi(app)
convert = converter.convert
stats = metrics.stats
stats_json = metrics.stats_json
redis_client = services.redis_client
cache_headers = converter.cache_headers
if __name__ == "__main__":
app.run(host=config.BIND_HOST, port=config.BIND_PORT)
else:
from asgiref.wsgi import WsgiToAsgi
asgi_app = WsgiToAsgi(app)
+9 -6
View File
@@ -19,7 +19,7 @@ def getenv_float(key: str, default: float) -> float:
return default
# Podstawowe
FLASK_DEBUG = getenv_bool("FLASK_DEBUG", True)
FLASK_DEBUG = getenv_bool("FLASK_DEBUG", False)
SECRET_KEY = os.getenv("SECRET_KEY", "secretkey")
# Rozmiary/limity
@@ -39,11 +39,14 @@ STATS_BASIC_AUTH_REALM = os.getenv("STATS_BASIC_AUTH_REALM", "Stats")
STATS_BASIC_AUTH_USER = os.getenv("STATS_BASIC_AUTH_USER", "admin").strip()
STATS_BASIC_AUTH_PASS = os.getenv("STATS_BASIC_AUTH_PASS", "admin").strip()
# Cache/ETag dla Varnisha
CACHE_ENABLED = getenv_bool("CACHE_ENABLED", True)
CACHE_S_MAXAGE = getenv_int("CACHE_S_MAXAGE", 43200) # 12h
CACHE_MAX_AGE = getenv_int("CACHE_MAX_AGE", 3600) # 1h
USE_REDIS_BODY_CACHE = getenv_bool("USE_REDIS_BODY_CACHE", False)
# Shared proxy cache (Cache-Control); RustProxy's own max_ttl_seconds cap
# must also be configured to at least CACHE_S_MAXAGE (maximum 604800 seconds).
CACHE_ENABLED = getenv_bool("CACHE_ENABLED", True)
CACHE_S_MAXAGE = max(0, getenv_int("CACHE_S_MAXAGE", 604800)) # 7 days at proxy
CACHE_MAX_AGE = max(0, getenv_int("CACHE_MAX_AGE", 3600)) # 1 hour in browsers
CACHE_STALE_WHILE_REVALIDATE = max(0, getenv_int("CACHE_STALE_WHILE_REVALIDATE", 86400)) # 1 day
CACHE_STALE_IF_ERROR = max(0, getenv_int("CACHE_STALE_IF_ERROR", 604800)) # 7 days
USE_REDIS_BODY_CACHE = getenv_bool("USE_REDIS_BODY_CACHE", False) # legacy/unused
# AIOHTTP/stream
AIOHTTP_TOTAL_TIMEOUT = getenv_float("AIOHTTP_TOTAL_TIMEOUT", 70.0)
+1 -2
View File
@@ -5,6 +5,5 @@ redis
requests
aiohttp
asgiref
unicorn
gunicorn
uvicorn
uvicorn
+18 -14
View File
@@ -6,6 +6,7 @@ sent to the browser or reverse proxy.
"""
import codecs
import hashlib
import re
import tempfile
from dataclasses import dataclass
@@ -26,6 +27,7 @@ class ConversionResult:
lines_read: int
lines_emitted: int
output_size: int
content_sha256: str
def _get_encoding(content_type: str) -> str:
@@ -37,11 +39,11 @@ def _get_encoding(content_type: str) -> str:
try:
encoding = codecs.lookup(encoding).name
except (LookupError, ValueError) as exc:
raise InvalidSourceError("Nieznane kodowanie tekstu z serwera zrodlowego.") from exc
raise InvalidSourceError("Unsupported source text encoding.") 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.")
raise InvalidSourceError("Unsupported source list encoding.")
return encoding
@@ -55,10 +57,10 @@ def _bounded_lines(response, *, chunk_size: int, line_limit: int, source_limit:
if not chunk:
continue
if not isinstance(chunk, bytes):
raise InvalidSourceError("Zrodlo nie zwrocilo poprawnych danych binarnych.")
raise InvalidSourceError("Source returned invalid binary data.")
total += len(chunk)
if total > source_limit:
raise SourceTooLargeError("Plik zrodlowy przekracza dopuszczalny rozmiar.")
raise SourceTooLargeError("Source list exceeds the maximum allowed size.")
pieces = chunk.split(b"\n")
for part in pieces[:-1]:
if skipping or len(pending) + len(part) > line_limit:
@@ -104,7 +106,7 @@ def convert_source(
if length:
try:
if int(length) > source_limit:
raise SourceTooLargeError("Plik zrodlowy przekracza dopuszczalny rozmiar.")
raise SourceTooLargeError("Source list exceeds the maximum allowed size.")
except ValueError:
pass
@@ -112,13 +114,15 @@ def convert_source(
lines_read = 0
lines_emitted = 0
written = 0
content_hasher = hashlib.sha256()
def append(data: bytes):
nonlocal written
if written + len(data) > output_limit:
raise SourceTooLargeError("Wynik konwersji przekracza dopuszczalny rozmiar.")
raise SourceTooLargeError("Converted output exceeds the maximum allowed size.")
output.write(data)
written += len(data)
content_hasher.update(data)
try:
if debug_lines is not None and debug_lines:
@@ -133,37 +137,37 @@ def convert_source(
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")
append(b"# [DEBUG] skipped an overlong line\n")
continue
if b"\x00" in raw:
raise InvalidSourceError("Zrodlo zawiera dane binarne zamiast listy tekstowej.")
raise InvalidSourceError("Source contains binary data instead of a text list.")
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
raise InvalidSourceError("Unable to decode source list text (character encoding).") from exc
try:
converted = convert_line(line, target_ip)
except (TypeError, ValueError) as exc:
raise InvalidSourceError("Nie mozna przetworzyc formatu listy.") from exc
raise InvalidSourceError("Unable to process this list format.") 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"))
append(f"# [DEBUG] preview {lines_read}: {preview}\n".encode("utf-8"))
if lines_emitted == 0:
raise InvalidSourceError(
"Nie znaleziono domen w obslugiwanym formacie. Sprawdz adres i zawartosc listy."
"No domains found in a supported format. Check the URL and list contents."
)
if debug_lines is not None:
append(
f"# [DEBUG] podsumowanie: przeczytano={lines_read}, wyemitowano={lines_emitted}\n".encode("utf-8")
f"# [DEBUG] summary: read={lines_read}, emitted={lines_emitted}\n".encode("utf-8")
)
output.seek(0)
return ConversionResult(output, lines_read, lines_emitted, written)
return ConversionResult(output, lines_read, lines_emitted, written, content_hasher.hexdigest())
except BaseException:
output.close()
raise
+2 -2
View File
@@ -1,3 +1,3 @@
#/bin/bash
#!/bin/bash
venv/bin/gunicorn -k uvicorn.workers.UvicornWorker --workers 1 --bind 127.0.0.1:8283 --keep-alive 30 --timeout 60 app:asgi_app
venv/bin/gunicorn -k uvicorn.workers.UvicornWorker --workers 1 --bind 127.0.0.1:8283 --keep-alive 30 --timeout 60 app:asgi_app
+299 -742
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="8" fill="#131615"/><rect x="6" y="6" width="52" height="52" fill="none" stroke="#f69b74" stroke-width="2"/><text x="32" y="42" text-anchor="middle" fill="#f69b74" font-family="monospace" font-size="30" font-weight="700">a/l</text></svg>

After

Width:  |  Height:  |  Size: 330 B

+210 -273
View File
@@ -1,288 +1,225 @@
(function () {
/* Progressive enhancement for Adlist Converter. No framework and no network
calls while composing a link: only /convert retrieves source data. */
(() => {
'use strict';
const $ = (selector, parent = document) => parent.querySelector(selector);
const $$ = (selector, parent = document) => Array.from(parent.querySelectorAll(selector));
const $ = (q, c = document) => c.querySelector(q);
const $$ = (q, c = document) => Array.from(c.querySelectorAll(q));
const toastEl = $('#toast');
let toastTimer;
function toast(message, isError = false) {
if (!toastEl) return;
toastEl.textContent = message;
toastEl.classList.toggle('error', isError);
toastEl.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 2600);
}
function setTheme(theme) {
const value = theme === 'light' ? 'light' : 'dark';
document.documentElement.dataset.theme = value;
const themeColor = $('meta[name="theme-color"]');
if (themeColor) themeColor.content = value === 'light' ? '#f4f6f2' : '#131615';
try { localStorage.setItem('adlist-theme', value); } catch (_) {}
}
setTheme(document.documentElement.dataset.theme || 'dark');
// --- theme ---
const setTheme = (t) => {
document.documentElement.setAttribute('data-theme', t);
try { localStorage.setItem('theme', t) } catch { }
};
window.setTheme = setTheme;
const toast = (msg) => {
const el = $('#toast'); if (!el) return;
el.textContent = msg; el.classList.add('show');
clearTimeout(el._t); el._t = setTimeout(() => el.classList.remove('show'), 2000);
};
const host = () => `${location.protocol}//${location.host}`;
// --- IP validators ---
function isValidIPv4(ip) {
if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(ip)) return false;
return ip.split('.').every(oct => {
if (oct.length > 1 && oct[0] === '0') return oct === '0';
const n = Number(oct);
return n >= 0 && n <= 255;
});
function isValidIPv4(ip) {
const octets = ip.split('.');
return octets.length === 4 && octets.every(octet => {
if (!/^(0|[1-9]\d{0,2})$/.test(octet)) return false;
return Number(octet) <= 255;
});
}
function isValidIPv6(ip) {
if (!ip.includes(':') || ip.includes('%') || ip.split('::').length > 2) return false;
let raw = ip;
let ipv4Count = 0;
if (raw.includes('.')) {
const cut = raw.lastIndexOf(':');
if (cut < 0 || !isValidIPv4(raw.slice(cut + 1))) return false;
raw = raw.slice(0, cut) + ':0:0';
ipv4Count = 1;
}
const parts = raw.split(':').filter(Boolean);
if (!parts.every(part => /^[a-f\d]{1,4}$/i.test(part))) return false;
if (raw.includes('::')) return parts.length < 8;
return parts.length === 8 && !raw.startsWith(':') && !raw.endsWith(':');
}
function isValidIP(ip) { return isValidIPv4(ip) || isValidIPv6(ip); }
function isValidIPv6(ip) {
let work = ip;
const idx = work.lastIndexOf(':');
if (idx !== -1) {
const tail = work.slice(idx + 1);
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(tail)) {
if (!isValidIPv4(tail)) return false;
work = work.slice(0, idx) + ':0:0';
}
}
if (work.split('::').length > 2) return false;
const hasCompress = work.includes('::');
const parts = work.split(':').filter(Boolean);
if ((!hasCompress && parts.length !== 8) || (hasCompress && parts.length > 7)) return false;
return parts.every(g => /^[0-9a-fA-F]{1,4}$/.test(g));
function normalizeSource(input) {
const value = (input || '').trim();
if (!value || /[\r\n]/.test(value)) return null;
try {
const candidate = /^[a-z][a-z\d+.-]*:\/\//i.test(value) ? value : `https://${value}`;
const parsed = new URL(candidate);
if (!['https:', 'http:'].includes(parsed.protocol) || !parsed.hostname) return null;
return parsed.toString();
} catch (_) { return null; }
}
function conversionUrl(source, target) {
const origin = /^https?:$/.test(window.location.protocol) ? window.location.origin : 'https://listapp.linuxiarz.pl';
const url = new URL('/convert', origin);
url.searchParams.set('url', source);
url.searchParams.set('ip', target);
return url.href;
}
function setError(input, message) {
if (!input) return;
input.setAttribute('aria-invalid', message ? 'true' : 'false');
const error = $(`#${input.id === 'url-input' ? 'url-error' : 'ip-error'}`);
if (error) { error.textContent = message; error.hidden = !message; }
}
const urlInput = $('#url-input');
const ipInput = $('#ip-input');
const result = $('#generated-link');
const openLink = $('#open-link');
const copyBtn = $('#copy-btn');
const linkState = $('#link-state');
const validationStatus = $('#validation-status');
function updateOutput(showErrors = false) {
if (!urlInput || !ipInput || !result) return;
const rawSource = urlInput.value.trim();
const ip = ipInput.value.trim();
const source = normalizeSource(rawSource);
const ipValid = isValidIP(ip);
if (showErrors || rawSource && !source) setError(urlInput, !source && rawSource ? 'Enter a valid HTTP or HTTPS address.' : '');
if (showErrors || ip && !ipValid) setError(ipInput, !ipValid && ip ? 'Enter a valid IPv4 or IPv6 address.' : '');
$$('.preset').forEach(button => {
const selected = ip === button.dataset.ip;
button.classList.toggle('active', selected);
button.setAttribute('aria-pressed', String(selected));
});
const ready = Boolean(source && ipValid);
const href = ready ? conversionUrl(source, ip) : '';
result.value = href;
if (copyBtn) copyBtn.disabled = !ready;
if (openLink) {
openLink.href = href || '#';
openLink.setAttribute('aria-disabled', String(!ready));
openLink.tabIndex = ready ? 0 : -1;
}
function isValidIP(ip) {
const v = (ip || '').trim();
return isValidIPv4(v) || isValidIPv6(v);
if (linkState) {
linkState.textContent = ready ? 'LINK READY' : 'AWAITING INPUT';
linkState.classList.toggle('live', ready);
}
// --- URL helpers ---
function normalizeUrlMaybe(v) {
const raw = (v || '').trim();
if (!raw) return '';
try {
const test = raw.includes('://') ? raw : `https://${raw}`;
const u = new URL(test);
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('scheme');
return u.toString();
} catch {
return '';
}
const status = validationStatus?.parentElement;
if (status && validationStatus) {
status.classList.toggle('ready', ready);
status.classList.toggle('invalid', Boolean(rawSource && !source || ip && !ipValid));
validationStatus.textContent = ready ? 'Endpoint ready to copy' : !rawSource ? 'Waiting for a source URL' : !source ? 'Invalid source URL' : !ipValid ? 'Invalid target address' : 'Waiting for valid input';
}
}
[urlInput, ipInput].forEach(input => {
if (!input) return;
input.addEventListener('input', () => { setError(input, ''); updateOutput(); });
input.addEventListener('blur', () => updateOutput(true));
});
$('#converter-form')?.addEventListener('submit', event => event.preventDefault());
$$('.preset').forEach(button => button.addEventListener('click', () => {
if (!ipInput) return;
ipInput.value = button.dataset.ip || '';
setError(ipInput, '');
updateOutput();
}));
updateOutput();
function buildLink(url, ip) {
if (!url || !ip) return '';
try {
const enc = encodeURIComponent(url);
const ipClean = (ip || '').trim();
return `${host()}/convert?url=${enc}&ip=${encodeURIComponent(ipClean)}`;
} catch { return ''; }
async function copy(text) {
if (!text) { toast('Nothing to copy', true); return; }
try {
if (!navigator.clipboard?.writeText) throw new Error('Clipboard API unavailable');
await navigator.clipboard.writeText(text);
toast('Copied to clipboard');
} catch (_) {
const temporary = document.createElement('textarea');
temporary.value = text;
temporary.setAttribute('readonly', '');
temporary.style.cssText = 'position:fixed;left:-9999px;top:0';
document.body.append(temporary);
temporary.select();
try {
if (!document.execCommand('copy')) throw new Error('Copy rejected');
toast('Copied to clipboard');
} catch (_) {
toast('Copy failed — select the text manually', true);
} finally { temporary.remove(); }
}
}
document.addEventListener('click', (e) => {
const t = e.target.closest('[data-action="toggle-theme"]');
if (t) {
e.preventDefault();
const cur = document.documentElement.getAttribute('data-theme') || 'dark';
setTheme(cur === 'dark' ? 'light' : 'dark');
}
});
const urlInput = $('#url-input');
const ipInput = $('#ip-input');
const ipPreset = $('#ip-preset');
const out = $('#generated-link');
const openBtn = $('#open-link');
const copyBtn = $('#copy-btn');
function showError(input, msg) {
const id = input.getAttribute('id');
const box = document.querySelector(`.error[data-error-for="${id}"]`);
if (box) box.textContent = msg || '';
input.setAttribute('aria-invalid', msg ? 'true' : 'false');
document.addEventListener('click', event => {
const trigger = event.target.closest('[data-action]');
if (!trigger) return;
const action = trigger.dataset.action;
if (action === 'toggle-theme') {
setTheme(document.documentElement.dataset.theme === 'light' ? 'dark' : 'light');
} else if (action === 'menu') {
const open = document.body.classList.toggle('menu-open');
trigger.setAttribute('aria-expanded', String(open));
} else if (action === 'clear') {
if (!urlInput || !ipInput) return;
urlInput.value = '';
ipInput.value = '195.187.6.34';
setError(urlInput, ''); setError(ipInput, '');
updateOutput();
urlInput.focus();
} else if (action === 'copy') {
const node = $(trigger.dataset.target || '');
void copy(node?.value ?? node?.textContent ?? '');
} else if (action === 'copy-text') {
const value = trigger.dataset.text || '';
const absolute = value.startsWith('/') ? window.location.origin + value : value;
void copy(absolute);
} else if (action === 'use-source') {
if (!urlInput || !ipInput) return;
urlInput.value = trigger.dataset.url || '';
ipInput.value = trigger.dataset.ip || '195.187.6.34';
setError(urlInput, ''); setError(ipInput, '');
updateOutput();
$('#generator')?.scrollIntoView({behavior: 'smooth', block: 'start'});
toast('Source loaded into generator');
} else if (action === 'try-again') {
window.location.reload();
} else if (action === 'show-all') {
const table = $(trigger.dataset.target || '');
if (!table) return;
$$('[data-extra-row]', table).forEach(row => { row.hidden = false; row.removeAttribute('data-extra-row'); });
trigger.closest('.table-footer')?.remove();
}
function updatePreview() {
const rawUrl = (urlInput?.value || '').trim();
const ip = (ipInput?.value || '').trim();
if (!ip || !isValidIP(ip)) {
if (out) out.value = '';
if (openBtn) {
openBtn.setAttribute('href', '#');
openBtn.setAttribute('aria-disabled', 'true');
openBtn.setAttribute('disabled', 'true');
}
if (copyBtn) copyBtn.setAttribute('disabled', 'true');
$('.result-box')?.setAttribute('data-state', 'empty');
return;
}
const normalized = normalizeUrlMaybe(rawUrl);
const guessed = rawUrl ? (rawUrl.includes('://') ? rawUrl : `https://${rawUrl}`) : '';
const previewUrl = normalized || guessed;
if (!previewUrl) {
if (out) out.value = '';
if (openBtn) {
openBtn.setAttribute('href', '#');
openBtn.setAttribute('aria-disabled', 'true');
openBtn.setAttribute('disabled', 'true');
}
if (copyBtn) copyBtn.setAttribute('disabled', 'true');
$('.result-box')?.setAttribute('data-state', 'empty');
return;
}
const link = buildLink(previewUrl, ip);
if (out) out.value = link;
const ok = !!normalized;
if (openBtn) {
if (ok) {
openBtn.setAttribute('href', link);
openBtn.setAttribute('aria-disabled', 'false');
openBtn.removeAttribute('disabled');
} else {
openBtn.setAttribute('href', '#');
openBtn.setAttribute('aria-disabled', 'true');
openBtn.setAttribute('disabled', 'true');
}
}
if (copyBtn) copyBtn.toggleAttribute('disabled', !ok);
$('.result-box')?.setAttribute('data-state', ok ? 'ready' : 'empty');
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && document.body.classList.contains('menu-open')) {
document.body.classList.remove('menu-open');
$('[data-action="menu"]')?.setAttribute('aria-expanded', 'false');
}
});
document.addEventListener('click', event => {
if (!document.body.classList.contains('menu-open')) return;
if (event.target.closest('.sidebar,.menu-toggle')) {
if (event.target.closest('.sidebar a')) {
document.body.classList.remove('menu-open');
$('[data-action="menu"]')?.setAttribute('aria-expanded', 'false');
}
return;
}
document.body.classList.remove('menu-open');
$('[data-action="menu"]')?.setAttribute('aria-expanded', 'false');
});
// live update
['input', 'change', 'blur'].forEach(evt => {
urlInput?.addEventListener(evt, updatePreview);
ipInput?.addEventListener(evt, updatePreview);
$$('[data-filter]').forEach(filter => filter.addEventListener('input', () => {
const table = $(filter.dataset.filter || '');
if (!table) return;
const query = filter.value.trim().toLowerCase();
let visible = 0;
$$('tbody tr:not(.empty-row)', table).forEach(row => {
const match = row.textContent.toLowerCase().includes(query);
const limited = !query && row.hasAttribute('data-extra-row');
row.hidden = !match || limited;
if (!row.hidden) visible++;
});
// presets
ipPreset?.addEventListener('change', () => {
const v = ipPreset.value;
if (!v) return;
if (v !== 'custom') ipInput.value = v;
ipInput.focus();
const ok = isValidIP(ipInput.value.trim());
showError(ipInput, ok ? '' : 'Invalid IP address');
updatePreview();
});
// event delegation
document.addEventListener('click', (e) => {
let t = e.target;
if (t.closest('[data-action="copy"]')) {
e.preventDefault();
const btn = t.closest('[data-action="copy"]');
const sel = btn.getAttribute('data-target') || '#generated-link';
const el = $(sel);
if (!el) return;
const text = el.value || el.textContent || '';
navigator.clipboard?.writeText(text).then(() => {
btn.classList.add('copied'); setTimeout(() => btn.classList.remove('copied'), 1200);
toast('Link copied');
}).catch(() => {
const range = document.createRange(); range.selectNodeContents(el);
const selObj = getSelection(); selObj.removeAllRanges(); selObj.addRange(range);
try { document.execCommand('copy'); toast('Link copied'); } catch { }
selObj.removeAllRanges();
});
}
if (t.closest('[data-action="copy-text"]')) {
e.preventDefault();
const btn = t.closest('[data-action="copy-text"]');
let text = btn.getAttribute('data-text') || '';
if (!text) return;
if (text.startsWith('/')) text = host() + text;
navigator.clipboard?.writeText(text).then(() => toast('Copied'));
}
if (t.closest('[data-action="clear"]')) {
e.preventDefault();
urlInput.value = '';
if (ipInput) ipInput.value = '';
if (ipPreset) ipPreset.value = '';
showError(urlInput, '');
showError(ipInput, '');
updatePreview();
urlInput.focus();
}
if (t.closest('[data-action="collapse"]')) {
e.preventDefault();
const btn = t.closest('[data-action="collapse"]');
const panel = $('#' + (btn.getAttribute('aria-controls') || ''));
if (!panel) return;
const expanded = btn.getAttribute('aria-expanded') === 'true';
const next = !expanded;
btn.setAttribute('aria-expanded', next ? 'true' : 'false');
panel.hidden = !next;
panel.style.display = next ? '' : 'none';
const newLabel = next ? 'Collapse' : 'Expand';
btn.textContent = newLabel;
btn.setAttribute('aria-label', newLabel);
}
});
// field-level validation
urlInput?.addEventListener('blur', () => {
const raw = urlInput.value.trim();
if (!raw) return showError(urlInput, '');
const normalized = normalizeUrlMaybe(raw);
showError(urlInput, normalized ? '' : 'Invalid URL');
});
ipInput?.addEventListener('blur', () => {
const v = ipInput.value.trim();
if (!v) return showError(ipInput, '');
const ok = isValidIP(v);
showError(ipInput, ok ? '' : 'Invalid IP address');
if (!ok) $('.result-box')?.setAttribute('data-state', 'empty');
});
// init (preview + recent-list sync)
(function init() {
const serverLink = out?.value?.trim();
if (serverLink) {
$('.result-box')?.setAttribute('data-state', 'ready');
openBtn?.setAttribute('aria-disabled', 'false');
} else {
updatePreview();
}
})();
document.addEventListener('DOMContentLoaded', () => {
const btn = document.querySelector('[data-action="collapse"]');
const panelId = btn?.getAttribute('aria-controls') || '';
const panel = panelId ? document.getElementById(panelId) : null;
if (!btn || !panel) return;
const expanded = btn.getAttribute('aria-expanded') === 'true';
panel.hidden = !expanded;
panel.style.display = expanded ? '' : 'none';
const label = expanded ? 'Collapse' : 'Expand';
btn.textContent = label;
btn.setAttribute('aria-label', label);
});
form?.addEventListener('submit', (e) => e.preventDefault());
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
const text = out?.value?.trim(); if (!text) return;
navigator.clipboard?.writeText(text).then(() => toast('Link copied'));
}
});
const empty = $(table.id === 'recent-table' ? '#recent-empty' : `[data-empty-for="#${table.id}"]`);
if (empty) empty.hidden = !query || visible > 0;
}));
})();
// --- theme color sync (poza IIFE) ---
function updateThemeColor() {
const meta = document.querySelector('meta[name="theme-color"]');
if (!meta) return;
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
meta.setAttribute('content', isLight ? '#f6f8fb' : '#0f1115');
}
const _setTheme = setTheme;
setTheme = function (t) { _setTheme(t); updateThemeColor(); };
document.addEventListener('DOMContentLoaded', updateThemeColor);
+75
View File
@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#131615">
<meta name="description" content="Convert public hosts and adblocking lists into MikroTik-compatible host entries.">
<title>{% block title %}Adlist Converter{% endblock %} / Control room</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg', v=asset_revision) }}">
<script>
try { const theme = localStorage.getItem('adlist-theme');
if (theme === 'dark' || theme === 'light') document.documentElement.dataset.theme = theme;
} catch (_) {}
</script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css', v=asset_revision) }}">
{% block head %}{% endblock %}
</head>
<body>
<div class="shell">
<aside class="sidebar" id="sidebar">
<a class="brand" href="{{ url_for('index') }}" aria-label="Adlist Converter home">
<span class="brand-mark" aria-hidden="true">a<span>/</span>l</span>
<span class="brand-name">Adlist<span class="brand-suffix"> Converter</span></span>
</a>
<div class="edition">HOSTS CONVERSION ENGINE</div>
<nav class="main-navigation" aria-label="Main navigation" id="main-navigation">
<a class="nav {% if page == 'converter' %}active{% endif %}" href="{{ url_for('index') }}" {% if page == 'converter' %}aria-current="page"{% endif %}>
<span class="nav-index">01</span><b>Converter</b><span class="nav-arrow" aria-hidden="true">↗</span>
</a>
<a class="nav" href="{{ url_for('index') }}#recent-links">
<span class="nav-index">02</span><b>Recent links</b><span class="nav-arrow" aria-hidden="true">↗</span>
</a>
<a class="nav {% if page == 'stats' %}active{% endif %}" href="{{ url_for('stats') }}" {% if page == 'stats' %}aria-current="page"{% endif %}>
<span class="nav-index">03</span><b>Statistics</b><span class="nav-arrow" aria-hidden="true">↗</span>
</a>
<a class="nav" href="{{ url_for('stats_json') }}" target="_blank" rel="noopener">
<span class="nav-index">04</span><b>Stats API</b><span class="nav-arrow" aria-hidden="true">↗</span>
</a>
</nav>
<div class="sidebar-bottom">
<span class="small-label">RUNTIME</span>
<p>Python / Flask / Redis<br>WSGI / ASGI / HTTP cache</p>
<div class="local-tag"><i aria-hidden="true"></i> PROXY CACHE READY</div>
<div class="sidebar-credit"><a href="https://www.linuxiarz.pl" target="_blank" rel="noopener noreferrer">linuxiarz.pl <span aria-hidden="true">↗</span></a></div>
</div>
</aside>
<div class="workarea">
<header class="topbar">
<div class="topbar-left">
<button class="menu-toggle quiet" type="button" data-action="menu" aria-controls="main-navigation" aria-expanded="false" aria-label="Toggle menu">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
</button>
<div class="breadcrumb">CONTROL ROOM <span>/</span> <b>{% if page == 'stats' %}Statistics{% elif page == 'error' %}Error{% else %}Converter{% endif %}</b></div>
</div>
<div class="top-actions">
<span class="top-annotation">HOSTS / MIKROTIK</span>
<button type="button" class="quiet theme-toggle" data-action="toggle-theme" aria-label="Switch color theme" title="Switch color theme">
<svg class="theme-icon theme-icon-sun" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32 1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
<svg class="theme-icon theme-icon-moon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M20.5 14.1A8.5 8.5 0 0 1 9.9 3.5 8.5 8.5 0 1 0 20.5 14.1Z"/></svg>
<span>Theme</span>
</button>
<a class="quiet top-external" href="https://www.linuxiarz.pl" target="_blank" rel="noopener noreferrer">linuxiarz.pl <span aria-hidden="true">↗</span></a>
</div>
</header>
<main id="main-content">
{% block content %}{% endblock %}
</main>
<footer class="app-footer"><span>ADLIST CONVERTER <span class="footer-divide">/</span> INDEPENDENT UTILITY</span><span>Powered by Flask · Built for MikroTik</span></footer>
</div>
</div>
<div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
<script defer src="{{ url_for('static', filename='js/main.js', v=asset_revision) }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+13 -88
View File
@@ -1,88 +1,13 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Error {{ error.code or 500 }}</title>
<meta name="theme-color" content="#0f1115" />
<link rel="preload" href="{{ url_for('static', filename='css/main.css') }}" as="style">
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
</head>
<body>
<header class="site-header">
<div class="brand">
<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
<path d="M4 4h16v4H4zM4 10h10v4H4zM4 16h16v4H4z" fill="currentColor" />
</svg>
<span>Hosts Converter</span>
</div>
<nav class="actions">
<button class="btn ghost" type="button" data-action="toggle-theme" aria-label="Toggle theme">🌓</button>
<a class="btn primary" href="/" rel="nofollow">Home</a>
</nav>
</header>
<main class="container">
<section class="card error-card">
<div class="error-hero">
<div class="error-illustration" aria-hidden="true">⚠️</div>
<div class="error-main">
<div class="status-badge">Error {{ error.code or 500 }}</div>
<h1 class="error-title">
{% if (error.code or 500) == 400 %}Bad request
{% 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 %}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 %}
</h1>
<p class="muted">{{ (error.description|string)|e }}</p>
<div class="error-actions">
<button class="btn" type="button" data-action="try-again">Try again</button>
<a class="btn outline" href="/" rel="nofollow">Go home</a>
</div>
</div>
</div>
<details class="error-details">
<summary>
<span class="summary-title">Error details</span>
<span class="summary-hint">click to expand</span>
</summary>
<div class="details-body">
<pre id="error-dump" class="mono">
code: {{ error.code or 500 }}
message: {{ (error.description|string) }}
path: {{ request.path if request else '/' }}
method: {{ request.method if request else 'GET' }}
user_ip: {{ request.remote_addr if request else '' }}
user_agent: {{ request.headers.get('User-Agent') if request else '' }}
</pre>
<div class="details-actions">
</div>
</div>
</details>
</section>
</main>
<footer class="site-footer">
<div>&copy; 2025 <a href="https://www.linuxiarz.pl" target="_blank" rel="noopener">linuxiarz.pl</a></div>
<div class="meta">Your IP: <strong>{{ request.remote_addr if request else '' }}</strong></div>
</footer>
<div id="toast" role="status" aria-live="polite" aria-atomic="true"></div>
<script defer src="{{ url_for('static', filename='js/main.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/error.js') }}"></script>
</body>
</html>
{% extends 'base.html' %}
{% set page = 'error' %}
{% block title %}Error {{ code }}{% endblock %}
{% block content %}
<section class="page-intro"><div><div class="eyebrow error-eyebrow">REQUEST / ERROR {{ code }}</div><h1>We couldn't <em>complete that.</em></h1><p class="lead">Your request could not be processed. Review the details below and try a different source if needed.</p></div></section>
<section class="card error-card">
<div class="error-head"><div class="error-icon" aria-hidden="true">!</div><div><span class="small-label">HTTP {{ code }} / REQUEST FAILED</span><h2>{% if code == 400 %}Invalid request{% elif code == 403 %}Access denied{% elif code == 404 %}Page not found{% elif code == 413 %}Source file too large{% elif code == 415 %}Unsupported file format{% elif code == 422 %}Unable to decode this list{% elif code == 429 %}Rate limit exceeded{% elif code == 502 %}Source unavailable{% elif code == 500 %}Internal server error{% else %}Unable to process request{% endif %}</h2></div></div>
<p class="error-description">{{ error.description }}</p>
<div class="error-actions"><a href="{{ url_for('index') }}" class="primary">Back to converter</a><button type="button" class="outline" data-action="try-again">Retry request</button></div>
<details class="error-details"><summary>Technical details <span aria-hidden="true">↓</span></summary><dl><div><dt>HTTP STATUS</dt><dd>{{ code }}</dd></div><div><dt>METHOD</dt><dd>{{ request.method }}</dd></div><div><dt>PATH</dt><dd>{{ request.path }}</dd></div></dl></details>
</section>
<div class="error-tip"><i class="signal" aria-hidden="true"></i><span>Invalid or failed conversion responses are not cached by the reverse proxy.</span></div>
{% endblock %}
+106 -151
View File
@@ -1,156 +1,111 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
{% extends 'base.html' %}
{% set page = 'converter' %}
{% block title %}Converter{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<div class="eyebrow"><i class="signal" aria-hidden="true"></i> THE CONVERSION WORKSPACE</div>
<h1>Blocklists, <em>made compatible.</em></h1>
<p class="lead">Turn public hosts, DNS and adblock sources into MikroTik-compatible lists. One source URL. One output link. Ready for your DNS setup.</p>
</div>
<div class="hero-identity" aria-hidden="true"><span>DNS</span><span class="hero-line"></span><strong>LIST</strong><small>/CONVERT</small></div>
</section>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hosts Converter</title>
<meta name="theme-color" content="#0f1115" />
<link rel="preload" href="{{ url_for('static', filename='css/main.css') }}" as="style">
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
</head>
<body>
<header class="site-header">
<div class="brand">
<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
<path d="M4 4h16v4H4zM4 10h10v4H4zM4 16h16v4H4z" fill="currentColor" />
</svg>
<span>Hosts Converter</span>
</div>
<nav class="actions">
<button class="btn ghost" type="button" data-action="toggle-theme" aria-label="Toggle theme">🌓</button>
<a class="btn primary" href="https://www.linuxiarz.pl" target="_blank" rel="noopener">linuxiarz.pl</a>
</nav>
<section class="workspace-grid" aria-label="Conversion tools">
<article class="card generator-card" id="generator">
<header class="card-heading">
<div><span class="small-label">01 / CONFIGURATION</span><h2>Link generator</h2></div>
<span class="pill live"><i class="signal" aria-hidden="true"></i> READY</span>
</header>
<form id="converter-form" novalidate>
<div class="field">
<label for="url-input">Source URL <span class="required">*</span></label>
<div class="input-wrap url-field">
<span class="input-prefix" aria-hidden="true">↗</span>
<input type="url" id="url-input" name="url" placeholder="https://example.com/blocklist.txt" inputmode="url" autocomplete="url" spellcheck="false" aria-describedby="url-help url-error" required>
</div>
<div class="field-hint" id="url-help">Paste a direct link to a plain-text hosts or adblock list.</div>
<p class="field-error" id="url-error" role="alert" hidden></p>
</div>
<div class="field">
<div class="label-row"><label for="ip-input">Target IP <span class="required">*</span></label><span class="fine">IPv4 / IPv6 supported</span></div>
<div class="preset-row" role="group" aria-label="Target IP presets">
<button type="button" class="preset" data-ip="0.0.0.0">0.0.0.0 <small>BLACKHOLE</small></button>
<button type="button" class="preset" data-ip="127.0.0.1">127.0.0.1 <small>LOOPBACK</small></button>
<button type="button" class="preset" data-ip="195.187.6.34">195.187.6.34 <small>DEFAULT</small></button>
</div>
<input id="ip-input" name="ip" type="text" value="195.187.6.34" inputmode="text" autocomplete="off" spellcheck="false" aria-describedby="ip-error" required>
<p class="field-error" id="ip-error" role="alert" hidden></p>
</div>
<div class="form-controls">
<div class="form-status"><i class="signal" aria-hidden="true"></i><span id="validation-status">Waiting for a source URL</span></div>
<button type="button" class="quiet" data-action="clear">Reset fields</button>
</div>
</form>
</article>
<main class="container">
<section class="card hero">
<div class="hero-text">
<h1>Convert adblock lists to MikroTik / hosts</h1>
<p class="muted">Paste a list URL (AdGuard/uBlock/hosts/dnsmasq), pick the target IP, and generate a
<kbd>/convert</kbd> link.
</p>
</div>
<div class="hero-cta">
<a class="btn primary large" href="#form">Start</a>
</div>
</section>
<aside class="side-column">
<section class="card guide-card" id="guide">
<div class="card-heading"><div><span class="small-label">02 / WORKFLOW</span><h2>How it works</h2></div></div>
<div class="workflow-step"><span class="step-num">01</span><div><strong>Choose a source</strong><p>Add a public URL to a supported list.</p></div></div>
<div class="workflow-step"><span class="step-num">02</span><div><strong>Set your target</strong><p>Select the IP used for blocked domains.</p></div></div>
<div class="workflow-step"><span class="step-num">03</span><div><strong>Copy and deploy</strong><p>Use the generated address in IP → DNS → Adlist.</p></div></div>
</section>
<section class="card support-card">
<header class="card-heading"><div><span class="small-label">INPUT / COMPATIBILITY</span><h2>Supported formats</h2></div><span class="pill">TEXT</span></header>
<div class="format-list"><span>Hosts</span><span>AdGuard / uBlock</span><span>dnsmasq</span><span>Plain domains</span></div>
<p class="fine">The source is checked before the response is delivered. Invalid lists return an English error page.</p>
</section>
</aside>
</section>
<section id="form" class="card form-card" aria-labelledby="form-title">
<h2 id="form-title">Link generator</h2>
<section class="card result-card" id="result" aria-labelledby="result-heading">
<header class="card-heading">
<div><span class="small-label">03 / GENERATED OUTPUT</span><h2 id="result-heading">Your conversion endpoint</h2></div>
<span class="pill" id="link-state">AWAITING INPUT</span>
</header>
<p class="fine result-intro">The link updates as you type. Generating the address does not download the source; conversion runs when the endpoint is requested.</p>
<div class="output-row">
<div class="output-field"><span class="output-mark">GET</span><input id="generated-link" type="text" readonly aria-label="Generated conversion link" placeholder="Enter a valid source URL to generate your link"></div>
<div class="output-actions">
<button type="button" class="primary" data-action="copy" data-target="#generated-link" id="copy-btn" disabled>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="1"/><path d="M16 8V4H4v12h4"/></svg> Copy link
</button>
<a class="outline" id="open-link" href="#" target="_blank" rel="noopener noreferrer" aria-disabled="true" tabindex="-1">Open <span aria-hidden="true">↗</span></a>
</div>
</div>
<div class="result-footer"><span class="success-note"><i class="signal" aria-hidden="true"></i> VALID RESULTS ARE CACHEABLE</span><span>Shared cache: up to {{ cache_days }} days (proxy configuration required)</span></div>
</section>
<form method="GET" action="/" novalidate>
<div class="grid">
<div class="form-group col-12">
<label for="url-input">URL to hosts/adblock list</label>
<input id="url-input" type="url" name="url" required placeholder="https://example.com/list.txt"
inputmode="url" autocomplete="url" aria-describedby="url-help" autofocus>
<small id="url-help" class="hint"> Supported formats: <code>||domain^</code>,
<code>address=/domain/</code>, hosts files, plain domains.</small>
<div class="error" data-error-for="url-input"></div>
</div>
<div class="form-group col-6">
<label for="ip-input">Target IP</label>
<input id="ip-input" type="text" name="ip" value="195.187.6.34" required inputmode="text"
autocomplete="off" aria-describedby="ip-help" spellcheck="false">
<small id="ip-help" class="hint">Common choices: <code>0.0.0.0</code>, <code>127.0.0.1</code>,
your device IP, supports IPv4 and IPv6.</small>
<div class="error" data-error-for="ip-input"></div>
</div>
<div class="form-group col-6">
<label for="ip-preset">Presets</label>
<select id="ip-preset" class="select">
<option value="">— choose preset —</option>
<option value="0.0.0.0">0.0.0.0 (blackhole)</option>
<option value="127.0.0.1">127.0.0.1 (localhost)</option>
<option value="195.187.6.34">195.187.6.34 (current)</option>
<option value="custom">Custom…</option>
</select>
</div>
<div class="form-actions col-12">
<button class="btn ghost" type="button" data-action="clear">Clear</button>
</div>
</div>
</form>
<div class="result-box" data-state="empty" aria-live="polite" aria-atomic="true">
<div class="result-row">
<input id="generated-link" type="text" value="{{ generated_link or '' }}" readonly
placeholder="Link will appear here…">
<div class="result-buttons">
<button class="btn" type="button" id="copy-btn" data-action="copy"
data-target="#generated-link">Copy</button>
<a class="btn outline" id="open-link" href="{{ generated_link or '#' }}" target="_blank"
rel="noopener" aria-disabled="{{ 'false' if generated_link else 'true' }}">Open</a>
</div>
</div>
<small class="hint">Paste this link in your Mikrotik (IP -> DNS -> Adlist) or other DNS server /
ad blocking tool</small>
</div>
</section>
<section class="card recent-card">
<div class="section-head">
<h2>Recent converts</h2>
<div class="head-actions">
<button class="btn ghost" type="button" data-action="collapse" aria-expanded="false"
aria-controls="recent-list">Expand</button>
</div>
</div>
<div id="recent-list" class="recent-list" style="display:none">
{% if recent_links %}
{% for link_data in recent_links %}
<article class="link-item">
<div class="link-main">
<a class="mono ellipsis" title="{{ link_data[1] }}"
href="/convert?url={{ link_data[1]|urlencode }}&ip={{ link_data[2] }}" target="_blank"
rel="noopener">
{{ link_data[1] }}
</a>
<span class="arrow">→</span>
<span class="mono ellipsis" title="{{ link_data[2] }}">{{ link_data[2] }}</span>
</div>
<div class="link-meta">
<span class="timestamp">{{ link_data[0]|datetimeformat }}</span>
<div class="link-actions">
<button class="btn tiny" type="button" data-action="copy-text"
data-text="{{ url_for('convert', _external=True) }}?url={{ link_data[1]|urlencode }}&ip={{ link_data[2] }}">Copy</button>
<a class="btn tiny outline"
href="/convert?url={{ link_data[1]|urlencode }}&ip={{ link_data[2] }}" target="_blank"
rel="noopener">Open</a>
</div>
</div>
</article>
{% endfor %}
{% else %}
<p class="muted">Empty..</p>
{% endif %}
</div>
</section>
</main>
<footer class="site-footer">
<div>&copy; 2025 <a href="https://www.linuxiarz.pl" target="_blank" rel="noopener">linuxiarz.pl</a> · All rights
reserved</div>
<div class="meta">Your IP: <strong>{{ client_ip }}</strong> · UA: <strong>{{ user_agent }}</strong></div>
</footer>
<div id="toast" role="status" aria-live="polite" aria-atomic="true"></div>
<script defer src="{{ url_for('static', filename='js/main.js') }}"></script>
<script>
// no-flash theme bootstrap
(function () { const t = localStorage.getItem('theme') || 'dark'; document.documentElement.setAttribute('data-theme', t); })();
</script>
</body>
</html>
<section class="card recent-card" id="recent-links" aria-labelledby="recent-heading">
<header class="card-heading card-heading-responsive">
<div><span class="small-label">04 / RECENT ACTIVITY</span><h2 id="recent-heading">Recently converted sources <span class="count">{{ recent_links|length }}</span></h2></div>
<div class="head-actions"><label class="sr-only" for="recent-filter">Filter recent sources</label><input id="recent-filter" type="search" placeholder="Search sources or IPs…" data-filter="#recent-table" autocomplete="off"></div>
</header>
<div class="table-scroll">
<table class="data-table" id="recent-table">
<thead><tr><th>Source</th><th>Target IP</th><th>Last used</th><th class="actions-cell">Actions</th></tr></thead>
<tbody>
{% for item in recent_links %}
<tr {% if loop.index > 8 %}hidden data-extra-row="true"{% endif %}>
<td class="source-cell"><span class="source-glyph" aria-hidden="true">↗</span><a href="{{ url_for('convert',url=item[1],ip=item[2]) }}" target="_blank" rel="noopener noreferrer" title="{{ item[1] }}">{{ item[1] }}</a></td>
<td class="mono"><span class="ip-label">{{ item[2] }}</span></td>
<td class="mono subdued nowrap">{{ item[0]|datetimeformat }}</td>
<td class="row-actions">
<button type="button" class="quiet tiny" data-action="use-source" data-url="{{ item[1] }}" data-ip="{{ item[2] }}">Use</button>
<button type="button" class="quiet tiny" data-action="copy-text" data-text="{{ url_for('convert', url=item[1], ip=item[2]) }}">Copy</button>
<a class="quiet tiny" href="{{ url_for('convert',url=item[1],ip=item[2]) }}" target="_blank" rel="noopener noreferrer">Open ↗</a>
</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="4" class="empty-table"><span class="empty-title">No recent links yet</span><span>Successful conversions will appear here.</span></td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if recent_links|length > 8 %}
<div class="table-footer"><span class="fine">Showing 8 of {{ recent_links|length }} entries</span><button class="quiet tiny" type="button" data-action="show-all" data-target="#recent-table">Show all entries ↓</button></div>
{% endif %}
<p class="fine no-results" id="recent-empty" hidden>No entries match this search.</p>
</section>
{% endblock %}
+81 -216
View File
@@ -1,216 +1,81 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stats</title>
<meta name="theme-color" content="#0f1115">
<link rel="preload" href="{{ url_for('static', filename='css/main.css') }}" as="style">
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
</head>
<body>
<header class="site-header">
<div class="brand">
<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
<path d="M4 4h16v4H4zM4 10h10v4H4zM4 16h16v4H4z" fill="currentColor" />
</svg>
<span>Hosts Converter</span>
</div>
<nav class="actions">
<button class="btn ghost" type="button" data-action="toggle-theme" aria-label="Toggle theme">🌓</button>
<a class="btn ghost" href="/" rel="nofollow">Home</a>
</nav>
</header>
<main class="container container--wide">
<!-- KPIs -->
<section class="card kpi-card">
<h2 class="section-title">Overview</h2>
<div class="kpi-grid">
<div class="kpi">
<div class="kpi-label">Convert requests</div>
<div class="kpi-value">{{ stats.get('stats:convert_requests', 0) }}</div>
</div>
<div class="kpi">
<div class="kpi-label">Successful conversions</div>
<div class="kpi-value">{{ stats.get('stats:conversions_success', 0) }}</div>
</div>
<div class="kpi">
<div class="kpi-label">Errors 4xx</div>
<div class="kpi-value">{{ stats.get('stats:errors_400', 0) }}</div>
</div>
<div class="kpi">
<div class="kpi-label">Errors 5xx</div>
<div class="kpi-value">{{ stats.get('stats:errors_500', 0) }}</div>
</div>
<div class="kpi">
<div class="kpi-label">Avg processing (s)</div>
<div class="kpi-value">{{ '%.3f' % detailed.processing_time_avg_sec }}</div>
</div>
<div class="kpi">
<div class="kpi-label">Avg content (bytes)</div>
<div class="kpi-value">{{ detailed.content_size_avg_bytes }}</div>
</div>
</div>
</section>
<!-- Recent converts -->
<section class="card">
<div class="section-head">
<h2>Recent converts (latest {{ recent|length }})</h2>
<div class="head-actions">
<input class="table-filter" type="search" placeholder="Filter…" data-action="filter-table"
data-target="#recent-table">
</div>
</div>
<div class="table-wrap">
<table id="recent-table" class="data-table">
<thead>
<tr>
<th>Time</th>
<th>URL</th>
<th>Target IP</th>
<th>Client</th>
<th>User agent</th>
<th></th>
</tr>
</thead>
<tbody>
{% for row in recent %}
{% set q = row.get('url','') %}
{% set parts = q.split('&ip=') %}
{% set url = parts[0].replace('/convert?url=', '') | urlencode %}
{% set ip = (parts[1] if parts|length > 1 else '') %}
<tr>
<td class="mono nowrap">{{ row.time|datetimeformat }}</td>
<td class="mono ellipsis" title="{{ url|safe }}">
{{ url|safe }}
</td>
<td class="mono">{{ ip }}</td>
<td class="mono ellipsis" title="{{ row.hostname }} ({{ row.ip }})">
{{ row.hostname }} ({{ row.ip }})
</td>
<td class="ellipsis" title="{{ row.user_agent }}">{{ row.user_agent }}</td>
<td class="actions">
<a class="btn tiny outline" href="{{ q }}" target="_blank" rel="noopener">Open</a>
<button class="btn tiny" data-action="copy-text" data-text="{{ q }}">Copy</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<!-- Top tables -->
<section class="card">
<div class="section-head">
<h2>Top sources</h2>
</div>
<div class="grid">
<div class="col-6">
<h3 class="subhead">Source URLs</h3>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>URL</th>
<th class="right">Hits</th>
</tr>
</thead>
<tbody>
{% for u, c in url_requests.items()|sort(attribute=1, reverse=True) %}
<tr>
<td class="mono ellipsis" title="{{ u }}">{{ u }}</td>
<td class="right">{{ c }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="col-6">
<h3 class="subhead">Target IPs</h3>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>IP</th>
<th class="right">Hits</th>
</tr>
</thead>
<tbody>
{% for ip, c in target_ips.items()|sort(attribute=1, reverse=True) %}
<tr>
<td class="mono">{{ ip }}</td>
<td class="right">{{ c }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="col-6">
<h3 class="subhead">User agents</h3>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>User agent</th>
<th class="right">Hits</th>
</tr>
</thead>
<tbody>
{% for ua, c in user_agents.items()|sort(attribute=1, reverse=True) %}
<tr>
<td class="ellipsis" title="{{ ua }}">{{ ua }}</td>
<td class="right">{{ c }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="col-6">
<h3 class="subhead">Client IPs</h3>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Client IP</th>
<th class="right">Hits</th>
</tr>
</thead>
<tbody>
{% for ip, c in client_ips.items()|sort(attribute=1, reverse=True) %}
<tr>
<td class="mono">{{ ip }}</td>
<td class="right">{{ c }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</section>
</main>
<footer class="site-footer">
<div>&copy; 2025 <a href="https://www.linuxiarz.pl" target="_blank" rel="noopener">linuxiarz.pl</a></div>
<div class="meta">Your IP: <strong>{{ request.remote_addr }}</strong></div>
</footer>
<div id="toast" role="status" aria-live="polite" aria-atomic="true"></div>
<script defer src="{{ url_for('static', filename='js/main.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/stats.js') }}"></script>
<script>(function () { const t = localStorage.getItem('theme') || 'dark'; document.documentElement.setAttribute('data-theme', t); })();</script>
</body>
</html>
{% extends 'base.html' %}
{% set page = 'stats' %}
{% block title %}Statistics{% endblock %}
{% block content %}
<section class="page-intro stats-intro">
<div><div class="eyebrow"><i class="signal" aria-hidden="true"></i> REDIS COUNTERS / OBSERVABILITY</div>
<h1>Every request, <em>in focus.</em></h1>
<p class="lead">Conversion traffic, output sizes, client activity and recent requests. Metrics reflect traffic reaching this application; proxy cache hits do not execute the converter.</p></div>
<div class="intro-action"><a class="outline" href="{{ url_for('stats_json') }}" target="_blank" rel="noopener noreferrer">Open JSON API ↗</a></div>
</section>
{% set error4xx = (stats.get('stats:errors_400',0)|int + stats.get('stats:errors_403',0)|int + stats.get('stats:errors_404',0)|int + stats.get('stats:errors_413',0)|int + stats.get('stats:errors_415',0)|int + stats.get('stats:errors_422',0)|int + stats.get('stats:errors_429',0)|int) %}
{% set error5xx = (stats.get('stats:errors_500',0)|int + stats.get('stats:errors_502',0)|int) %}
<section class="stats-grid" aria-label="Overview metrics">
<article class="stat"><span>Convert requests</span><strong>{{ stats.get('stats:convert_requests',0) }}</strong><small>Reached backend</small></article>
<article class="stat"><span>Successful conversions</span><strong>{{ stats.get('stats:conversions_success',0) }}</strong><small>Validated responses</small></article>
<article class="stat"><span>4xx / 5xx errors</span><strong>{{ error4xx + error5xx }}</strong><small>{{ error4xx }} client / {{ error5xx }} server</small></article>
<article class="stat"><span>Avg. processing</span><strong>{{ '%.3f'|format(detailed.processing_time_avg_sec) }}<small class="unit">s</small></strong><small>Requests served by backend</small></article>
</section>
<section class="stats-secondary" aria-label="Performance metrics">
<div><span>AVERAGE OUTPUT</span><strong>{{ '{:,.0f}'.format(detailed.content_size_avg_bytes) }} B</strong></div>
<div><span>TOTAL OUTPUT</span><strong>{{ '{:,.0f}'.format(detailed.content_size_total_bytes) }} B</strong></div>
<div><span>FASTEST REQUEST</span><strong>{{ '%.3f'|format(detailed.processing_time_min_sec) }} s</strong></div>
<div><span>SLOWEST REQUEST</span><strong>{{ '%.3f'|format(detailed.processing_time_max_sec) }} s</strong></div>
</section>
<section class="card" id="activity">
<header class="card-heading card-heading-responsive"><div><span class="small-label">01 / TRAFFIC</span><h2>Recent conversion requests <span class="count">{{ recent|length }}</span></h2></div>
<div class="head-actions"><label class="sr-only" for="stats-filter">Filter requests</label><input id="stats-filter" type="search" data-filter="#activity-table" placeholder="Search traffic…"></div></header>
<div class="table-scroll"><table class="data-table" id="activity-table">
<thead><tr><th>Time</th><th>Source</th><th>Target</th><th>Client</th><th>User agent</th><th>Open</th></tr></thead>
<tbody>
{% for row in recent %}
<tr>
<td class="mono subdued nowrap">{{ row.get('time','')|datetimeformat }}</td>
<td class="source-cell"><span class="source-glyph" aria-hidden="true">↗</span><span class="source-ellipsis" title="{{ row.get('source_url','') }}">{{ row.get('source_url','') or 'Unknown' }}</span></td>
<td class="mono"><span class="ip-label">{{ row.get('target_ip','') }}</span></td>
<td class="mono" title="{{ row.get('hostname','') }} ({{ row.get('ip','') }})">{{ row.get('ip','') }}</td>
<td title="{{ row.get('user_agent','') }}"><span class="source-ellipsis">{{ row.get('user_agent','') }}</span></td>
<td>{% if row.get('source_url') %}<a class="quiet tiny" href="{{ url_for('convert',url=row.get('source_url'),ip=row.get('target_ip')) }}" target="_blank" rel="noopener noreferrer">Open ↗</a>{% else %}—{% endif %}</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="6" class="empty-table"><span class="empty-title">No requests recorded</span><span>Recent conversion requests will appear here.</span></td></tr>
{% endfor %}
</tbody>
</table></div>
<p class="fine no-results" data-empty-for="#activity-table" hidden>No matching records.</p>
</section>
<section class="table-card-grid" id="top-sources">
<article class="card"><header class="card-heading"><div><span class="small-label">02 / LEADERBOARD</span><h2>Top sources</h2></div><span class="pill">URL</span></header>
<div class="table-scroll"><table class="data-table"><thead><tr><th>Source address</th><th class="right">Requests</th></tr></thead><tbody>
{% for url, total in url_requests.items()|sort(attribute=1,reverse=True) %}
<tr><td class="source-cell"><span class="source-ellipsis" title="{{ url }}">{{ url }}</span></td><td class="right mono">{{ total }}</td></tr>
{% else %}<tr><td colspan="2" class="empty-table">No sources recorded.</td></tr>{% endfor %}
</tbody></table></div>
</article>
<article class="card"><header class="card-heading"><div><span class="small-label">03 / LEADERBOARD</span><h2>Target addresses</h2></div><span class="pill">IP</span></header>
<div class="table-scroll"><table class="data-table"><thead><tr><th>Target IP</th><th class="right">Requests</th></tr></thead><tbody>
{% for ip, total in target_ips.items()|sort(attribute=1,reverse=True) %}
<tr><td class="mono"><span class="ip-label">{{ ip }}</span></td><td class="right mono">{{ total }}</td></tr>
{% else %}<tr><td colspan="2" class="empty-table">No targets recorded.</td></tr>{% endfor %}
</tbody></table></div>
</article>
<article class="card"><header class="card-heading"><div><span class="small-label">04 / CLIENTS</span><h2>Top client IPs</h2></div></header>
<div class="table-scroll"><table class="data-table"><thead><tr><th>Client IP</th><th class="right">Requests</th></tr></thead><tbody>
{% for ip, total in client_ips.items()|sort(attribute=1,reverse=True) %}
<tr><td class="mono"><span class="source-ellipsis">{{ ip }}</span></td><td class="right mono">{{ total }}</td></tr>
{% else %}<tr><td colspan="2" class="empty-table">No clients recorded.</td></tr>{% endfor %}
</tbody></table></div>
</article>
<article class="card"><header class="card-heading"><div><span class="small-label">05 / CLIENTS</span><h2>User agents</h2></div></header>
<div class="table-scroll"><table class="data-table"><thead><tr><th>User agent</th><th class="right">Requests</th></tr></thead><tbody>
{% for ua, total in user_agents.items()|sort(attribute=1,reverse=True) %}
<tr><td><span class="source-ellipsis" title="{{ ua }}">{{ ua }}</span></td><td class="right mono">{{ total }}</td></tr>
{% else %}<tr><td colspan="2" class="empty-table">No user agents recorded.</td></tr>{% endfor %}
</tbody></table></div>
</article>
</section>
<details class="card json-details"><summary><span><span class="small-label">06 / INSPECTION</span><strong>Raw JSON snapshot</strong></span><span class="fine">Expand <span aria-hidden="true">↓</span></span></summary>
<div class="json-actions"><p class="fine">This is application-side activity only. Requests served from RustProxy or Varnish cache are not counted here.</p><button type="button" class="quiet tiny" data-action="copy" data-target="#raw-json">Copy JSON</button></div>
<pre class="raw-json" id="raw-json">{{ raw_json }}</pre>
</details>
{% endblock %}
+280
View File
@@ -0,0 +1,280 @@
"""Standalone /convert smoke checks without third-party packages.
Run: python3 tests/smoke_cache_routes.py
These are stubs, not a substitute for an installed Flask/Gunicorn integration test.
"""
import hashlib
import importlib.util
import logging
import sys
import types
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
class HTTPError(Exception):
def __init__(self, code, description=None):
self.code = code
self.description = description
class Response:
def __init__(self, data=None, content_type=None, status=200, mimetype=None):
self.data = data
self.status_code = status
self.headers = {}
self.direct_passthrough = False
self.close_callbacks = []
if content_type:
self.headers["Content-Type"] = content_type
def call_on_close(self, callback):
self.close_callbacks.append(callback)
def make_conditional(self, request):
if request.headers.get("If-None-Match") == self.headers.get("ETag"):
self.status_code = 304
return self
class Flask:
def __init__(self, *args):
self.config = {}
self.debug = False
self.logger = logging.getLogger("adlist-test")
def route(self, *args, **kwargs):
return lambda func: func
template_filter = route
before_request = lambda self, fn: fn
after_request = lambda self, fn: fn
errorhandler = route
def abort(code, description=None):
raise HTTPError(code, description)
flask = types.ModuleType("flask")
flask.Flask = Flask
flask.Response = Response
flask.abort = abort
flask.render_template = lambda *args, **kwargs: "error-page"
flask.jsonify = lambda d: d
flask.g = types.SimpleNamespace()
flask.request = types.SimpleNamespace(
args={}, headers={}, method="GET", remote_addr="127.0.0.1",
full_path="/convert", path="/convert",
)
sys.modules["flask"] = flask
compress = types.ModuleType("flask_compress")
compress.Compress = lambda app: None
sys.modules["flask_compress"] = compress
limiter = types.ModuleType("flask_limiter")
class Limiter:
def __init__(self, **kwargs):
pass
def limit(self, *args):
return lambda func: func
limiter.Limiter = Limiter
sys.modules["flask_limiter"] = limiter
class FakeRedis:
def incr(self, *args):
pass
def incrby(self, *args):
pass
def incrbyfloat(self, *args):
pass
def get(self, *args):
return None
def set(self, *args):
pass
redis = types.ModuleType("redis")
redis.Redis = types.SimpleNamespace(from_url=lambda *args, **kwargs: FakeRedis())
sys.modules["redis"] = redis
asgiref = types.ModuleType("asgiref")
asgi_w = types.ModuleType("asgiref.wsgi")
asgi_w.WsgiToAsgi = lambda app: app
sys.modules["asgiref"] = asgiref
sys.modules["asgiref.wsgi"] = asgi_w
requests = types.ModuleType("requests")
class RequestException(Exception):
pass
requests.exceptions = types.SimpleNamespace(RequestException=RequestException)
sys.modules["requests"] = requests
spec = importlib.util.spec_from_file_location("adlist_cache_test_app", ROOT / "app.py")
app = importlib.util.module_from_spec(spec)
spec.loader.exec_module(app)
app.add_recent_convert = lambda: None
app.track_url_request = lambda *_: None
app.add_recent_link = lambda *_: None
class FakeUpstream:
def __init__(self, body, status=200, content_type="text/plain; charset=utf-8",
headers=None, fail=False):
self.body = body
self.status_code = status
self.headers = {"Content-Type": content_type, **(headers or {})}
self.closed = False
self.fail = fail
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:
raise RequestException("upstream disconnected")
def close(self):
self.closed = True
called = []
source = FakeUpstream(b"0.0.0.0 example.test\n")
def fake_get(*args, **kwargs):
called.append((args, kwargs))
return source
requests.get = fake_get
count = 0
def check(test, label):
global count
assert test, label
count += 1
def run(method="GET", client_headers=None, args=None):
flask.request.method = method
flask.request.args = args or {
"url": "https://source.test/list.txt", "ip": "0.0.0.0"}
flask.request.headers = client_headers or {}
flask.g.start_time = 0.0
return app.convert()
converted = b"0.0.0.0 example.test\n"
content_hash = hashlib.sha256(converted).hexdigest()
resp = run()
check(resp.status_code == 200, "valid list = 200")
check(resp.headers["Cache-Control"] ==
"public, max-age=3600, s-maxage=604800, stale-while-revalidate=86400, stale-if-error=604800",
"proxy TTL and staleness")
check(resp.headers["ETag"] == f'"{content_hash}"', "strong output-body ETag")
check(resp.headers["Content-Length"] == str(len(converted)), "content length")
check(b"".join(resp.data) == converted, "response bytes")
check(source.closed, "upstream closed after success")
check("Last-Modified" not in resp.headers, "origin validator is not forwarded")
source = FakeUpstream(converted, headers={"ETag": '"upstream-pretend"',
"Last-Modified": "Mon, 01 Jan 2024 00:00:00 GMT"})
resp = run(client_headers={"If-None-Match": f'"{content_hash}"'})
check(resp.status_code == 304, "client 304 after full body hash")
check("headers" not in called[-1][1], "client validators not forwarded to upstream")
check(source.closed, "conditional upstream closed")
check(resp.headers["ETag"] == f'"{content_hash}"', "304 keeps content validator")
source = FakeUpstream(b"0.0.0.0 another.test\n", headers={"ETag": '"upstream-pretend"'})
changed = run()
check(changed.headers["ETag"] != f'"{content_hash}"',
"changed converted content invalidates ETag even if source ETag stays the same")
check(b"".join(changed.data) == b"0.0.0.0 another.test\n", "changed content served")
source = FakeUpstream(converted, headers={"ETag": '"upstream-pretend"'})
resp = run(client_headers={"If-None-Match": '"other"'})
check(resp.status_code == 200, "wrong client ETag yields 200")
check(b"".join(resp.data) == converted, "wrong client ETag still receives body")
source = FakeUpstream(converted)
head = run("HEAD")
check(head.status_code == 200, "HEAD status")
check(head.data is None, "HEAD no data iterator")
check(head.headers["ETag"] == f'"{content_hash}"', "GET/HEAD same ETag")
check(head.headers["Content-Length"] == str(len(converted)), "HEAD real content length")
source = FakeUpstream(converted)
other = run(args={"url": "https://source.test/list.txt", "ip": "127.0.0.1"})
check(other.headers["ETag"] != resp.headers["ETag"], "different target IP yields different ETag")
check(b"127.0.0.1 example.test\n" == b"".join(other.data), "IP body changed")
source = FakeUpstream(converted)
app.config.DEBUG_ENABLE = True
app.config.DEBUG_KEY = "not-test-key"
debug = run(args={"url": "https://source.test/list.txt", "ip": "0.0.0.0", "debug": "1"})
check(debug.headers["Cache-Control"] == "private, no-store", "debug never cached")
check("ETag" not in debug.headers, "debug no shared ETag")
check(debug.status_code == 200, "debug displays full response")
check(debug.headers["X-Debug-Mode"] == "1", "debug diagnostic header")
app.config.DEBUG_ENABLE = False
source = FakeUpstream(b"<html><body>err</body></html>")
try:
run()
raise AssertionError("invalid body accepted")
except HTTPError as e:
check(e.code == 422 and "Unable to decode" in e.description, "unprocessable source English 422")
source = FakeUpstream(b"<html></html>", content_type="text/html")
try:
run()
raise AssertionError("html content type accepted")
except HTTPError as e:
check(e.code == 415 and "supported" in e.description, "unsupported type English 415")
source = FakeUpstream(b"not-found", status=404)
try:
run()
raise AssertionError("bad upstream status accepted")
except HTTPError as e:
check(e.code == 502 and "source server" in e.description, "upstream English 502")
source = FakeUpstream(b"0.0.0.0 example.test\n", fail=True)
try:
run()
raise AssertionError("interrupted source accepted")
except HTTPError as e:
check(e.code == 502 and "interrupted" in e.description, "interrupted download 502")
r = Response(status=422)
r.headers["Cache-Control"] = "public, max-age=90000"
for error_code in [400, 403, 413, 415, 422, 429, 500, 502]:
error = Response(status=error_code)
error.headers["Cache-Control"] = "public, max-age=90000"
check(app.finalize_response(error).headers["Cache-Control"] == "no-store",
f"HTTP {error_code} must not be cached")
test_cfg = app.config.CACHE_ENABLED
app.config.CACHE_ENABLED = False
check(app.cache_headers(content_hash)["Cache-Control"] == "no-store", "cache disabled")
app.config.CACHE_ENABLED = test_cfg
print("Standalone route/cache smoke tests passed:", count)
+299
View File
@@ -0,0 +1,299 @@
"""Standalone /convert smoke checks without third-party packages.
Run: python3 tests/smoke_routes.py
These are stubs, not a substitute for an installed Flask/Gunicorn integration test.
"""
import hashlib
import importlib.util
import logging
import sys
import types
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
class HTTPError(Exception):
def __init__(self, code, description=None):
self.code = code
self.description = description
class Response:
def __init__(self, data=None, content_type=None, status=200, mimetype=None):
self.data = data
self.status_code = status
self.headers = {}
self.direct_passthrough = False
self.close_callbacks = []
if content_type:
self.headers["Content-Type"] = content_type
def call_on_close(self, callback):
self.close_callbacks.append(callback)
def make_conditional(self, request):
if request.headers.get("If-None-Match") == self.headers.get("ETag"):
self.status_code = 304
return self
class Flask:
def __init__(self, *args):
self.config = {}
self.debug = False
self.logger = logging.getLogger("adlist-test")
def route(self, *args, **kwargs):
return lambda func: func
template_filter = route
before_request = lambda self, fn: fn
after_request = lambda self, fn: fn
errorhandler = route
add_url_rule = lambda self, *args, **kwargs: None
add_template_filter = lambda self, *args, **kwargs: None
register_error_handler = lambda self, *args, **kwargs: None
context_processor = lambda self, fn: fn
def abort(code, description=None):
raise HTTPError(code, description)
flask = types.ModuleType("flask")
flask.Flask = Flask
flask.Response = Response
flask.abort = abort
flask.render_template = lambda *args, **kwargs: "error-page"
flask.jsonify = lambda d: d
flask.g = types.SimpleNamespace()
flask.current_app = types.SimpleNamespace(logger=logging.getLogger("adlist-test"))
flask.request = types.SimpleNamespace(
args={}, headers={}, method="GET", remote_addr="127.0.0.1",
full_path="/convert", path="/convert",
)
sys.modules["flask"] = flask
compress = types.ModuleType("flask_compress")
compress.Compress = lambda app: None
sys.modules["flask_compress"] = compress
limiter = types.ModuleType("flask_limiter")
class Limiter:
def __init__(self, **kwargs):
pass
def limit(self, *args):
return lambda func: func
limiter.Limiter = Limiter
sys.modules["flask_limiter"] = limiter
class FakeRedis:
def incr(self, *args):
pass
def incrby(self, *args):
pass
def incrbyfloat(self, *args):
pass
def get(self, *args):
return None
def set(self, *args):
pass
redis = types.ModuleType("redis")
redis.Redis = types.SimpleNamespace(from_url=lambda *args, **kwargs: FakeRedis())
sys.modules["redis"] = redis
asgiref = types.ModuleType("asgiref")
asgi_w = types.ModuleType("asgiref.wsgi")
asgi_w.WsgiToAsgi = lambda app: app
sys.modules["asgiref"] = asgiref
sys.modules["asgiref.wsgi"] = asgi_w
requests = types.ModuleType("requests")
class RequestException(Exception):
pass
requests.exceptions = types.SimpleNamespace(RequestException=RequestException)
sys.modules["requests"] = requests
spec = importlib.util.spec_from_file_location("adlist_cache_test_app", ROOT / "app.py")
app = importlib.util.module_from_spec(spec)
spec.loader.exec_module(app)
app.converter.add_recent_convert = lambda: None
app.converter.track_url_request = lambda *_: None
app.converter.add_recent_link = lambda *_: None
class FakeUpstream:
def __init__(self, body, status=200, content_type="text/plain; charset=utf-8",
headers=None, fail=False):
self.body = body
self.status_code = status
self.headers = {"Content-Type": content_type, **(headers or {})}
self.closed = False
self.fail = fail
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:
raise RequestException("upstream disconnected")
def close(self):
self.closed = True
called = []
source = FakeUpstream(b"0.0.0.0 example.test\n")
def fake_get(*args, **kwargs):
called.append((args, kwargs))
return source
requests.get = fake_get
count = 0
def check(test, label):
global count
assert test, label
count += 1
def run(method="GET", client_headers=None, args=None):
flask.request.method = method
flask.request.args = args or {
"url": "https://source.test/list.txt", "ip": "0.0.0.0"}
flask.request.headers = client_headers or {}
flask.g.start_time = 0.0
return app.convert()
converted = b"0.0.0.0 example.test\n"
content_hash = hashlib.sha256(converted).hexdigest()
resp = run()
check(resp.status_code == 200, "valid list = 200")
check(resp.headers["Cache-Control"] ==
"public, max-age=3600, s-maxage=604800, stale-while-revalidate=86400, stale-if-error=604800",
"proxy TTL and staleness")
check(resp.headers["ETag"] == f'"{content_hash}"', "strong output-body ETag")
check(resp.headers["Content-Length"] == str(len(converted)), "content length")
check(b"".join(resp.data) == converted, "response bytes")
check(source.closed, "upstream closed after success")
check("Last-Modified" not in resp.headers, "origin validator is not forwarded")
source = FakeUpstream(converted, headers={"ETag": '"upstream-pretend"',
"Last-Modified": "Mon, 01 Jan 2024 00:00:00 GMT"})
resp = run(client_headers={"If-None-Match": f'"{content_hash}"'})
check(resp.status_code == 304, "client 304 after full body hash")
check("headers" not in called[-1][1], "client validators not forwarded to upstream")
check(source.closed, "conditional upstream closed")
check(resp.headers["ETag"] == f'"{content_hash}"', "304 keeps content validator")
source = FakeUpstream(b"0.0.0.0 another.test\n", headers={"ETag": '"upstream-pretend"'})
changed = run()
check(changed.headers["ETag"] != f'"{content_hash}"',
"changed converted content invalidates ETag even if source ETag stays the same")
check(b"".join(changed.data) == b"0.0.0.0 another.test\n", "changed content served")
source = FakeUpstream(converted, headers={"ETag": '"upstream-pretend"'})
resp = run(client_headers={"If-None-Match": '"other"'})
check(resp.status_code == 200, "wrong client ETag yields 200")
check(b"".join(resp.data) == converted, "wrong client ETag still receives body")
source = FakeUpstream(converted)
head = run("HEAD")
check(head.status_code == 200, "HEAD status")
check(head.data is None, "HEAD no data iterator")
check(head.headers["ETag"] == f'"{content_hash}"', "GET/HEAD same ETag")
check(head.headers["Content-Length"] == str(len(converted)), "HEAD real content length")
source = FakeUpstream(converted)
other = run(args={"url": "https://source.test/list.txt", "ip": "127.0.0.1"})
check(other.headers["ETag"] != resp.headers["ETag"], "different target IP yields different ETag")
check(b"127.0.0.1 example.test\n" == b"".join(other.data), "IP body changed")
source = FakeUpstream(converted)
app.config.DEBUG_ENABLE = True
app.config.DEBUG_KEY = "not-test-key"
debug = run(args={"url": "https://source.test/list.txt", "ip": "0.0.0.0", "debug": "1"})
check(debug.headers["Cache-Control"] == "private, no-store", "debug never cached")
check("ETag" not in debug.headers, "debug no shared ETag")
check(debug.status_code == 200, "debug displays full response")
check(debug.headers["X-Debug-Mode"] == "1", "debug diagnostic header")
app.config.DEBUG_ENABLE = False
source = FakeUpstream(b"<html><body>err</body></html>")
try:
run()
raise AssertionError("invalid body accepted")
except HTTPError as e:
check(e.code == 422 and "Unable to decode" in e.description, "unprocessable source English 422")
source = FakeUpstream(b"<html></html>", content_type="text/html")
try:
run()
raise AssertionError("html content type accepted")
except HTTPError as e:
check(e.code == 415 and "supported" in e.description, "unsupported type English 415")
source = FakeUpstream(b"not-found", status=404)
try:
run()
raise AssertionError("bad upstream status accepted")
except HTTPError as e:
check(e.code == 502 and "source server" in e.description, "upstream English 502")
source = FakeUpstream(b"0.0.0.0 example.test\n", fail=True)
try:
run()
raise AssertionError("interrupted source accepted")
except HTTPError as e:
check(e.code == 502 and "interrupted" in e.description, "interrupted download 502")
r = Response(status=422)
r.headers["Cache-Control"] = "public, max-age=90000"
for error_code in [400, 403, 413, 415, 422, 429, 500, 502]:
error = Response(status=error_code)
error.headers["Cache-Control"] = "public, max-age=90000"
check(app.services.finalize_response(error).headers["Cache-Control"] == "no-store",
f"HTTP {error_code} must not be cached")
# Private UI and statistics must never be stored by a shared proxy.
for private_path in ["/", "/stats", "/stats.json"]:
flask.request.path = private_path
private = app.services.finalize_response(Response(status=200))
check(private.headers.get("Cache-Control") == "private, no-store", f"private {private_path}")
flask.request.path = "/static/css/main.css"
flask.request.args = {"v": "asset123"}
static = app.services.finalize_response(Response(status=200))
check(static.headers.get("Cache-Control") == "public, max-age=31536000, immutable", "versioned CSS caching")
flask.request.args = {}
static = app.services.finalize_response(Response(status=200))
check(static.headers.get("Cache-Control") == "public, max-age=3600", "unversioned CSS expires")
flask.request.path = "/convert"
test_cfg = app.config.CACHE_ENABLED
app.config.CACHE_ENABLED = False
check(app.cache_headers(content_hash)["Cache-Control"] == "no-store", "cache disabled")
app.config.CACHE_ENABLED = test_cfg
print("Standalone route/cache smoke tests passed:", count)
+5
View File
@@ -1,4 +1,5 @@
import unittest
import hashlib
from source_conversion import (
InvalidSourceError,
@@ -58,6 +59,10 @@ class ConvertSourceTests(unittest.TestCase):
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)
self.assertEqual(
result.content_sha256,
hashlib.sha256(b'0.0.0.0 first.test\n0.0.0.0 second.test\n').hexdigest(),
)
def test_octet_stream_without_charset(self):
result = run_conversion(
+50
View File
@@ -0,0 +1,50 @@
import unittest
from adlist.cache import cache_headers
from adlist.validation import convert_host_line, validate_ip, validate_and_normalize_url
class ValidationAndCacheTests(unittest.TestCase):
def test_hosts_format(self):
self.assertEqual(convert_host_line('0.0.0.0 ads.example.com', '127.0.0.1'), '127.0.0.1 ads.example.com')
def test_adguard_format(self):
self.assertEqual(convert_host_line('||tracking.example.com^', '0.0.0.0'), '0.0.0.0 tracking.example.com')
def test_dnsmasq_format(self):
self.assertEqual(convert_host_line('address=/block.example.com/0.0.0.0', '0.0.0.0'), '0.0.0.0 block.example.com')
def test_plain_domains(self):
self.assertEqual(convert_host_line('tracking.example.com', '::'), ':: tracking.example.com')
def test_comments_ignored(self):
self.assertIsNone(convert_host_line('# comment', '0.0.0.0'))
def test_malformed_domains_ignored(self):
self.assertIsNone(convert_host_line('a<b.invalid', '0.0.0.0'))
def test_ipv6_target(self):
self.assertEqual(validate_ip('2001:db8::1'), '2001:db8::1')
def test_invalid_target(self):
with self.assertRaises(ValueError): validate_ip('999.999.999.999')
def test_nested_percent_escapes_preserved(self):
nested='https://example.com/a%2Fb?q=a%26b'
self.assertEqual(validate_and_normalize_url(nested), nested)
def test_https_default(self):
self.assertEqual(validate_and_normalize_url('example.com/list'), 'https://example.com/list')
def test_cache_directives(self):
headers=cache_headers('f'*64)
self.assertIn('s-maxage=604800', headers['Cache-Control'])
self.assertIn('stale-if-error=604800', headers['Cache-Control'])
self.assertEqual(headers['ETag'],'"'+'f'*64+'"')
def test_debug_no_store(self):
headers=cache_headers('f'*64,debug_mode=True)
self.assertEqual(headers['Cache-Control'], 'private, no-store')
self.assertNotIn('ETag',headers)
if __name__ == '__main__': unittest.main()