diff --git a/CACHE_SETUP.md b/CACHE_SETUP.md new file mode 100644 index 0000000..f339728 --- /dev/null +++ b/CACHE_SETUP.md @@ -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: "" +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 +``` diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..9eb8f31 --- /dev/null +++ b/DEPLOY.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e9fa6d --- /dev/null +++ b/README.md @@ -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. diff --git a/adlist/__init__.py b/adlist/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adlist/cache.py b/adlist/cache.py new file mode 100644 index 0000000..54721fb --- /dev/null +++ b/adlist/cache.py @@ -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 diff --git a/adlist/converter.py b/adlist/converter.py new file mode 100644 index 0000000..46f9f99 --- /dev/null +++ b/adlist/converter.py @@ -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 diff --git a/adlist/metrics.py b/adlist/metrics.py new file mode 100644 index 0000000..bbe9555 --- /dev/null +++ b/adlist/metrics.py @@ -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()) diff --git a/adlist/pages.py b/adlist/pages.py new file mode 100644 index 0000000..34aab0d --- /dev/null +++ b/adlist/pages.py @@ -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 diff --git a/adlist/services.py b/adlist/services.py new file mode 100644 index 0000000..bcbf787 --- /dev/null +++ b/adlist/services.py @@ -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) diff --git a/adlist/validation.py b/adlist/validation.py new file mode 100644 index 0000000..ce51e0d --- /dev/null +++ b/adlist/validation.py @@ -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") diff --git a/app.py b/app.py index 2b14377..731a05e 100644 --- a/app.py +++ b/app.py @@ -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) diff --git a/config.py b/config.py index 0b587e3..20e9870 100644 --- a/config.py +++ b/config.py @@ -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) diff --git a/requirements.txt b/requirements.txt index e22f2c6..93412a0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,5 @@ redis requests aiohttp asgiref -unicorn gunicorn -uvicorn \ No newline at end of file +uvicorn diff --git a/source_conversion.py b/source_conversion.py index af6b370..0993ccf 100644 --- a/source_conversion.py +++ b/source_conversion.py @@ -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 diff --git a/start_dev.sh b/start_dev.sh index 1d55796..7388521 100644 --- a/start_dev.sh +++ b/start_dev.sh @@ -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 \ No newline at end of file +venv/bin/gunicorn -k uvicorn.workers.UvicornWorker --workers 1 --bind 127.0.0.1:8283 --keep-alive 30 --timeout 60 app:asgi_app diff --git a/static/css/main.css b/static/css/main.css index e970c42..338d571 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -1,744 +1,301 @@ +/* The visual language follows RustProxy 0.29.3: same palette, density, sidebar, + typography, control geometry and two-theme token set. No third-party CSS. */ :root { - --bg: #0f1115; - --bg-elev: #131722; - --card: #161b26; - --text: #e7eef7; - --muted: #a9b4c3; - --border: #243043; - --brand: #5b9dff; - --brand-2: #7bd4ff; - --success: #29c36a; - --danger: #ff5d5d; - --shadow: 0 10px 30px rgba(0, 0, 0, .35); - color-scheme: dark; + color-scheme: dark; + --bg: #131615; --panel: #1b1f1d; --panel2: #202622; --line: #323a34; + --muted: #a0aaa0; --text: #edf0e9; --accent: #f69b74; --green: #bdd293; + --red: #ffaaa2; --sidebar-bg: #101310; --brand-secondary: #cfe0ac; + --nav-active-bg: #242b23; --nav-hover-bg: #20251f; --text-subtle: #879082; + --separator: #596454; --border-hover: #89917c; --field-bg: #131813; + --field-line: #47523f; --placeholder: #778070; --row-line: #2b332c; + --outline-line: #717b67; --code-bg: #101810; --accent-ink: #221810; + --accent-hover: #ffb090; --pill-line: #6d5a45; --pill-live-line: #576847; + --toast-bg: #d0dfb4; --toast-text: #1c2818; --toast-error-bg: #ffd2bd; + --toast-error-text: #42241b; --scrollbar-track: #151a16; --scrollbar-thumb: #4f5d50; + --mono: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + --sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} +html[data-theme="light"] { + color-scheme: light; + --bg: #f4f6f2; --panel: #fff; --panel2: #edf1eb; --line: #cbd3c7; + --muted: #5e685f; --text: #1d241f; --accent: #b85b38; --green: #5f783f; + --red: #b64a43; --sidebar-bg: #eef2ec; --brand-secondary: #6b8249; + --nav-active-bg: #e1e8dc; --nav-hover-bg: #e8ede6; --text-subtle: #687369; + --separator: #9ba69b; --border-hover: #839083; --field-bg: #fff; + --field-line: #b7c1b5; --placeholder: #7a867b; --row-line: #e0e5dd; + --outline-line: #98a497; --code-bg: #edf3e9; --accent-ink: #fff; + --accent-hover: #a84f30; --pill-line: #cbb28f; --pill-live-line: #a8ba8d; + --toast-bg: #d0dfb4; --toast-text: #1c2818; --toast-error-bg: #ffd2bd; + --toast-error-text: #42241b; --scrollbar-track: #e7ece4; --scrollbar-thumb: #a9b5a6; +} +*, *::before, *::after { box-sizing: border-box; } +html { scroll-behavior: smooth; scroll-padding-top: 24px; } +body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 var(--sans); } +body, input, button, textarea { -webkit-font-smoothing: antialiased; } +[hidden] { display: none !important; } +a { color: inherit; text-decoration: none; } +button, input { font: inherit; } +button { cursor: pointer; } +button:disabled { opacity: .46; cursor: not-allowed; } +button, a, input { -webkit-tap-highlight-color: transparent; } +button:focus-visible, a:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } +h1, h2, h3, p { margin: 0; } +h2 { font-size: 17px; font-weight: 650; letter-spacing: -.5px; } +.mono { font-family: var(--mono); } +.nowrap { white-space: nowrap; } +.subdued, .fine { color: var(--muted); } +.fine { font-size: 11px; line-height: 1.65; } +.sr-only { position: absolute; overflow: hidden; width: 1px; height: 1px; padding: 0; border: 0; white-space: nowrap; clip: rect(0,0,0,0); } +.shell { display: grid; grid-template-columns: 218px minmax(0,1fr); min-height: 100vh; } +.sidebar { position: sticky; top: 0; height: 100vh; background: var(--sidebar-bg); border-right: 1px solid var(--line); padding: 34px 22px; display: flex; flex-direction: column; z-index: 9; } +.brand { display: flex; gap: 8px; align-items: center; font-weight: 780; letter-spacing: -.6px; font-size: 17px; line-height: 1.1; white-space: nowrap; } +.brand-mark { flex: 0 0 47px; height: 47px; border: 1px solid var(--accent); color: var(--accent); font: 700 27px var(--mono); letter-spacing: -3px; display: flex; align-items: center; justify-content: center; } +.brand-mark span { color: var(--brand-secondary); } +.brand-name { letter-spacing: -.8px; } +.brand-suffix { font-weight: 450; font-size: 12px; display: block; letter-spacing: -.2px; color: var(--muted); margin-top: 3px; } +.edition { font: 9px/1.6 var(--mono); color: var(--muted); letter-spacing: 1px; margin: 22px 0 52px; } +.main-navigation { display: flex; flex-direction: column; gap: 9px; } +.nav { border: 1px solid transparent; min-height: 42px; display: flex; align-items: center; gap: 13px; padding: 10px 10px; color: var(--muted); font-size: 12px; border-radius: 3px; } +.nav:hover { background: var(--nav-hover-bg); color: var(--text); } +.nav.active { background: var(--nav-active-bg); color: var(--text); border-color: var(--line); } +.nav.active .nav-index { color: var(--accent); } +.nav-index { width: 18px; font: 10px var(--mono); color: var(--text-subtle); } +.nav b { font-size: 12px; font-weight: 600; } +.nav-arrow { margin-left: auto; opacity: 0; color: var(--accent); font-size: 12px; } +.nav:hover .nav-arrow { opacity: 1; } +.sidebar-bottom { margin-top: auto; padding-top: 24px; border-top: 1px solid var(--line); } +.small-label { display: block; color: var(--muted); font: 9px/1.6 var(--mono); letter-spacing: 1.2px; } +.sidebar-bottom p { color: var(--muted); font: 10px/1.9 var(--mono); margin: 8px 0 17px; } +.local-tag { display: flex; gap: 8px; align-items: center; border: 1px solid var(--pill-live-line); color: var(--green); font: 9px/1.6 var(--mono); letter-spacing: .65px; padding: 8px; } +.signal, .local-tag i { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--green); flex: none; } +.sidebar-credit { margin-top: 22px; font: 10px var(--mono); color: var(--muted); } +.sidebar-credit a:hover { color: var(--accent); } +.workarea { min-width: 0; display: flex; flex-direction: column; max-width: 1710px; width: 100%; margin: 0 auto; padding: 0 44px; } +main { width: 100%; flex: 1; min-width: 0; } +.topbar { min-height: 86px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; gap: 14px; } +.topbar-left { display: flex; align-items: center; gap: 12px; min-width: 0; } +.breadcrumb { font: 10px var(--mono); color: var(--muted); letter-spacing: 1px; white-space: nowrap; } +.breadcrumb span { margin: 0 13px; color: var(--separator); } +.breadcrumb b { font-weight: 400; color: var(--text); } +.top-actions { display: flex; align-items: center; gap: 10px; } +.top-annotation { font: 10px var(--mono); color: var(--text-subtle); margin-right: 6px; } +.quiet, .outline { border: 1px solid var(--line); background: transparent; color: var(--muted); border-radius: 3px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; white-space: nowrap; padding: 8px 13px; font-size: 12px; line-height: 1.45; } +.quiet:hover { color: var(--text); border-color: var(--border-hover); } +.outline { color: var(--text); border-color: var(--outline-line); } +.outline:hover { border-color: var(--accent); } +.primary { border: 1px solid var(--accent); background: var(--accent); color: var(--accent-ink); font-size: 12px; font-weight: 650; line-height: 1.5; padding: 10px 17px; border-radius: 3px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; white-space: nowrap; } +.primary:hover:not(:disabled) { background: var(--accent-hover); } +.menu-toggle { display: none; width: 38px; height: 36px; padding: 0; } +.theme-icon-moon { display: none; } +html[data-theme="light"] .theme-icon-sun { display: none; } +html[data-theme="light"] .theme-icon-moon { display: block; } +.page-intro { padding: 45px 0 34px; display: flex; align-items: center; justify-content: space-between; gap: 20px; } +.eyebrow { display: flex; gap: 8px; align-items: center; font: 10px var(--mono); letter-spacing: 2px; color: var(--green); margin-bottom: 16px; } +h1 { font-size: clamp(32px, 3.6vw, 52px); line-height: 1.1; font-weight: 520; letter-spacing: -2px; } +h1 em { color: var(--accent); font-style: normal; } +.lead { font-size: 13px; line-height: 1.75; color: var(--muted); max-width: 650px; margin-top: 16px; } +.hero-identity { width: 196px; height: 142px; border: 1px solid var(--line); background: linear-gradient(145deg, var(--panel), var(--panel2)); position: relative; flex: 0 0 auto; padding: 21px; overflow: hidden; } +.hero-identity:before { content: ''; position: absolute; border: 1px solid var(--line); inset: 9px; pointer-events: none; } +.hero-identity > span:first-child { font: 10px var(--mono); color: var(--green); letter-spacing: 1.5px; } +.hero-line { display: block; height: 1px; width: 30px; background: var(--accent); margin: 18px 0 7px; } +.hero-identity strong { font: 500 40px/.95 var(--mono); letter-spacing: -3px; color: var(--accent); display: block; } +.hero-identity small { display: block; font: 9px var(--mono); color: var(--muted); margin-top: 5px; } +.workspace-grid { display: grid; grid-template-columns: minmax(0,1.55fr) minmax(290px,1fr); gap: 18px; align-items: stretch; } +.card { border: 1px solid var(--line); background: var(--panel); padding: 24px; min-width: 0; } +.card-heading { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-bottom: 22px; } +.card-heading .small-label { margin-bottom: 6px; } +.pill { padding: 5px 9px; border: 1px solid var(--pill-line); color: var(--accent); font: 9px var(--mono); letter-spacing: 1px; flex-shrink: 0; } +.pill.live { display: inline-flex; align-items: center; gap: 7px; border-color: var(--pill-live-line); color: var(--green); } +.generator-card { border-top: 2px solid var(--accent); } +.field + .field { margin-top: 26px; } +.field label { display: block; margin-bottom: 9px; font-size: 12px; font-weight: 600; color: var(--text); } +.required { color: var(--accent); } +.input-wrap { position: relative; } +.input-prefix { color: var(--green); position: absolute; left: 14px; top: 50%; transform: translateY(-50%); font: 16px var(--mono); } +input[type="url"], input[type="text"], input[type="search"] { display: block; width: 100%; min-width: 0; background: var(--field-bg); border: 1px solid var(--field-line); border-radius: 3px; color: var(--text); padding: 12px 13px; line-height: 1.5; font-family: var(--mono); font-size: 12px; } +input::placeholder { color: var(--placeholder); } +input:focus { border-color: var(--accent); outline: 1px solid var(--accent); outline-offset: 0; } +input[aria-invalid="true"] { border-color: var(--red); } +.url-field input { padding-left: 37px; } +.field-hint { margin-top: 9px; font-size: 11px; color: var(--muted); } +.field-error { color: var(--red); font: 11px var(--mono); margin-top: 9px; } +.label-row { display: flex; align-items: start; justify-content: space-between; gap: 10px; } +.preset-row { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 8px; margin-bottom: 12px; } +.preset { background: var(--panel2); border: 1px solid var(--line); color: var(--text); padding: 11px 9px; min-width: 0; border-radius: 3px; font: 11px var(--mono); text-align: left; transition: border-color .13s, background .13s; } +.preset small { display: block; font: 8px var(--mono); color: var(--muted); margin-top: 6px; letter-spacing: .55px; } +.preset:hover { border-color: var(--border-hover); } +.preset.active { border-color: var(--accent); background: var(--nav-selected-bg, var(--nav-active-bg)); } +.preset.active small { color: var(--accent); } +.form-controls { display: flex; align-items: center; justify-content: space-between; gap: 12px; border-top: 1px solid var(--line); padding-top: 18px; margin-top: 26px; } +.form-status { display: inline-flex; gap: 8px; align-items: center; font-size: 11px; color: var(--muted); } +.form-status.ready { color: var(--green); } +.form-status.invalid { color: var(--red); } +.form-status.invalid .signal { background: var(--red); } +.side-column { display: flex; flex-direction: column; gap: 18px; } +.side-column > .card:first-child { flex: 1; } +.workflow-step { display: flex; gap: 15px; padding: 14px 0; border-bottom: 1px solid var(--row-line); } +.workflow-step:first-of-type { padding-top: 0; } +.workflow-step:last-child { border-bottom: 0; padding-bottom: 0; } +.step-num { flex: 0 0 27px; height: 27px; border: 1px solid var(--field-line); display: grid; place-items: center; color: var(--accent); font: 10px var(--mono); } +.workflow-step strong { font-size: 12px; font-weight: 650; } +.workflow-step p { color: var(--muted); font-size: 11px; line-height: 1.65; margin-top: 5px; } +.support-card .card-heading { margin-bottom: 15px; } +.format-list { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; } +.format-list span { border: 1px solid var(--line); background: var(--panel2); font: 10px var(--mono); padding: 6px 9px; color: var(--text); } +.result-card { margin-top: 18px; position: relative; } +.result-card .card-heading { margin-bottom: 10px; } +.result-intro { margin-bottom: 18px; } +.output-row { display: flex; gap: 12px; align-items: stretch; } +.output-field { min-width: 0; display: flex; flex: 1; background: var(--code-bg); border: 1px solid var(--field-line); border-radius: 3px; align-items: center; } +.output-mark { font: 10px var(--mono); margin: 0 0 0 13px; padding-right: 13px; border-right: 1px solid var(--line); color: var(--green); } +.output-field input { background: transparent; border: 0; outline: 0; color: var(--text); font: 11px var(--mono); flex: 1; width: 100%; min-width: 0; padding: 11px; text-overflow: ellipsis; } +.output-field input:focus { border: 0; outline: none; } +.output-actions { display: flex; align-items: stretch; gap: 8px; } +.output-actions .outline { border-color: var(--outline-line); } +a[aria-disabled="true"] { opacity: .43; pointer-events: none; cursor: not-allowed; } +.result-footer { display: flex; align-items: center; gap: 20px; justify-content: space-between; border-top: 1px solid var(--line); padding-top: 15px; margin-top: 18px; color: var(--muted); font: 10px var(--mono); } +.success-note { color: var(--green); display: inline-flex; align-items: center; gap: 9px; white-space: nowrap; letter-spacing: .5px; } +.recent-card { margin-top: 18px; } +.count { font: 10px var(--mono); color: var(--muted); border: 1px solid var(--line); padding: 4px 7px; vertical-align: 2px; margin-left: 5px; } +.head-actions input[type="search"] { min-height: 32px; padding: 7px 11px; width: 220px; font-size: 11px; } +.table-scroll { overflow: auto; max-width: 100%; } +.data-table { border-collapse: collapse; width: 100%; text-align: left; font-size: 11px; white-space: nowrap; } +.data-table th { font: 10px var(--mono); color: var(--muted); font-weight: 400; padding: 10px 12px 13px; border-bottom: 1px solid var(--line); } +.data-table td { font: 11px var(--mono); padding: 13px 12px; border-bottom: 1px solid var(--row-line); vertical-align: middle; } +.data-table tr:last-child td { border-bottom: none; } +.data-table tr:hover:not(.empty-row) td { background: var(--nav-hover-bg); } +.data-table td:first-child, .data-table th:first-child { padding-left: 0; } +.data-table td:last-child, .data-table th:last-child { padding-right: 0; } +.source-cell { max-width: 400px; min-width: 180px; } +.source-cell a, .source-ellipsis { display: inline-block; max-width: 100%; vertical-align: middle; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.source-cell a { max-width: calc(100% - 25px); color: var(--text); } +.source-cell a:hover { color: var(--accent); } +.source-glyph { color: var(--accent); margin-right: 6px; } +.ip-label { font: 10px var(--mono); background: var(--panel2); color: var(--green); border: 1px solid var(--line); padding: 5px 8px; } +.row-actions { text-align: right; white-space: nowrap; } +.row-actions .quiet + .quiet { margin-left: 3px; } +.tiny { padding: 5px 9px; font-size: 10px; } +.table-footer { border-top: 1px solid var(--line); padding-top: 17px; margin-top: 9px; display: flex; align-items: center; justify-content: space-between; } +.empty-table { text-align: center; color: var(--muted); padding: 30px 12px !important; } +.empty-table span { display: block; } +.empty-title { color: var(--text); font: 12px var(--sans); margin-bottom: 5px; } +.no-results { padding: 15px; text-align: center; } +.right { text-align: right; } +.app-footer { border-top: 1px solid var(--line); display: flex; justify-content: space-between; gap: 12px; padding: 25px 0 29px; margin-top: 48px; font: 9px var(--mono); color: var(--text-subtle); letter-spacing: .7px; } +.footer-divide { color: var(--accent); margin: 0 6px; } +.toast { position: fixed; right: 28px; bottom: 26px; z-index: 20; background: var(--toast-bg); border: 1px solid var(--green); color: var(--toast-text); padding: 12px 17px; border-radius: 3px; font: 12px var(--mono); box-shadow: 0 13px 35px #0004; opacity: 0; transform: translateY(10px); pointer-events: none; transition: opacity .18s,transform .18s; } +.toast.show { opacity: 1; transform: translateY(0); } +.toast.error { background: var(--toast-error-bg); color: var(--toast-error-text); border-color: var(--red); } +.stats-intro { padding-bottom: 30px; } +.stats-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 14px; margin-bottom: 14px; } +.stat { border: 1px solid var(--line); background: var(--panel); padding: 19px 21px; min-width: 0; } +.stat:first-child { border-top: 2px solid var(--accent); } +.stat > span { color: var(--muted); font-size: 12px; } +.stat strong { display: block; font: 450 clamp(24px,2.8vw,33px) var(--mono); letter-spacing: -1px; margin: 10px 0 6px; overflow-wrap: anywhere; } +.stat small { color: var(--muted); font-size: 10px; } +.stat:first-child > small { color: var(--green); } +.stat strong .unit { font: 12px var(--mono); margin-left: 4px; } +.stats-secondary { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 14px; margin-bottom: 18px; } +.stats-secondary > div { border: 1px solid var(--line); background: var(--panel2); padding: 14px 20px; min-width: 0; } +.stats-secondary span { display: block; font: 9px var(--mono); color: var(--muted); letter-spacing: .7px; } +.stats-secondary strong { display: block; font: 13px var(--mono); margin-top: 8px; overflow-wrap: anywhere; } +.table-card-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 18px; margin-top: 18px; } +.table-card-grid .card .card-heading { margin-bottom: 14px; } +.table-card-grid .source-ellipsis { max-width: 100%; } +.json-details { margin-top: 18px; } +.json-details summary { display: flex; align-items: center; justify-content: space-between; cursor: pointer; gap: 10px; list-style: none; } +.json-details summary::-webkit-details-marker { display: none; } +.json-details summary strong { display: block; font-size: 16px; margin-top: 5px; } +.json-actions { display: flex; justify-content: space-between; align-items: center; gap: 15px; margin: 20px 0 12px; } +.raw-json { max-height: 430px; overflow: auto; background: var(--code-bg); border: 1px solid var(--line); font: 11px/1.65 var(--mono); padding: 20px; color: var(--text); white-space: pre; } +.error-eyebrow { color: var(--red); } +.error-card { max-width: 780px; border-top: 2px solid var(--red); padding: 29px; } +.error-head { display: flex; align-items: center; gap: 17px; } +.error-head h2 { font-size: 23px; font-weight: 520; margin-top: 5px; } +.error-icon { flex: 0 0 49px; height: 49px; color: var(--red); border: 1px solid var(--red); font: 700 26px var(--mono); display: grid; place-items: center; } +.error-description { border-left: 2px solid var(--line); padding: 3px 15px; line-height: 1.7; color: var(--muted); margin: 27px 0; max-width: 670px; overflow-wrap: anywhere; } +.error-actions { display: flex; gap: 10px; flex-wrap: wrap; } +.error-details { border-top: 1px solid var(--line); margin-top: 28px; padding-top: 18px; } +.error-details summary { display: flex; justify-content: space-between; color: var(--muted); cursor: pointer; font: 11px var(--mono); } +.error-details dl { margin-top: 17px; background: var(--code-bg); padding: 15px; } +.error-details dl div { display: flex; gap: 12px; margin-bottom: 10px; font-size: 11px; } +.error-details dl div:last-child { margin: 0; } +.error-details dt { min-width: 100px; color: var(--muted); font: 10px var(--mono); } +.error-details dd { margin: 0; overflow-wrap: anywhere; font: 11px var(--mono); } +.error-tip { display: flex; align-items: center; gap: 10px; color: var(--muted); font: 11px var(--mono); margin: 20px 0; } +@media (max-width: 1250px) { + .workarea { padding: 0 28px; } + .workspace-grid { grid-template-columns: minmax(0,1.3fr) minmax(260px,1fr); } + .hero-identity { width: 150px; } + .preset { font-size: 10px; padding: 9px 5px; } +} +@media (max-width: 1020px) { + .shell { grid-template-columns: 185px minmax(0,1fr); } + .sidebar { padding: 28px 15px; } + .workarea { padding: 0 25px; } + .top-annotation { display: none; } + .workspace-grid { grid-template-columns: 1fr; } + .side-column { display: grid; grid-template-columns: 1fr 1fr; } + .hero-identity { display: none; } + .stats-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } + .stats-secondary { grid-template-columns: repeat(2,minmax(0,1fr)); } + .output-row { flex-wrap: wrap; } + .output-field { flex: 1 0 100%; min-height: 42px; } +} +@media (max-width: 720px) { + .shell { display: block; } + .sidebar { position: fixed; z-index: 12; top: 0; left: 0; height: 100vh; width: min(280px,80vw); box-shadow: 20px 0 40px #0004; transform: translateX(-102%); transition: transform .18s; } + body.menu-open .sidebar { transform: translateX(0); } + body.menu-open:before { content: ''; position: fixed; inset: 0; z-index: 11; background: #0009; } + .workarea { padding: 0 19px; } + .menu-toggle { display: inline-flex; } + .topbar { min-height: 69px; } + .top-external { display: none; } + .top-actions .theme-toggle { padding: 8px 10px; } + .theme-toggle span { display: none; } + .page-intro { padding: 35px 0 27px; } + .eyebrow { font-size: 9px; } + h1 { font-size: clamp(30px,7.7vw,42px); letter-spacing: -1.6px; } + .lead { font-size: 12px; } + .card { padding: 19px; } + .side-column { grid-template-columns: 1fr; } + .stats-grid, .stats-secondary { gap: 10px; } + .table-card-grid { grid-template-columns: 1fr; } + .card-heading-responsive { align-items: stretch; flex-direction: column; } + .head-actions input[type="search"] { width: 100%; } + .result-footer { flex-wrap: wrap; gap: 10px; } + .app-footer { flex-wrap: wrap; } + /* Recent records become full cards on mobile instead of a clipped table. */ + #recent-table, #recent-table tbody { display: block; width: 100%; } + #recent-table thead { display: none; } + #recent-table tr:not(.empty-row) { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); column-gap: 10px; row-gap: 12px; border-bottom: 1px solid var(--row-line); padding: 14px 0; } + #recent-table tr:not(.empty-row):last-child { border-bottom: 0; } + #recent-table tr:not(.empty-row) td { display: block; border: 0; padding: 0; min-width: 0; } + #recent-table tr:not(.empty-row) td.source-cell { grid-column: 1 / -1; width: 100%; max-width: none; } + #recent-table tr:not(.empty-row) td.source-cell a { max-width: calc(100% - 23px); } + #recent-table tr:not(.empty-row) td:nth-child(3) { text-align: right; } + #recent-table tr:not(.empty-row) td.row-actions { grid-column: 1 / -1; text-align: left; } + #recent-table tr:not(.empty-row) td.row-actions .quiet { min-width: 58px; } + #recent-table .empty-table { display: block; } +} +@media (max-width: 460px) { + .workarea { padding: 0 14px; } + .card { padding: 16px; } + .topbar .breadcrumb { font-size: 9px; } + .breadcrumb span { margin: 0 7px; } + .preset-row { grid-template-columns: 1fr 1fr; } + .form-controls { align-items: flex-start; } + .form-controls .quiet { padding: 6px 9px; } + .stats-grid { grid-template-columns: 1fr 1fr; } + .stat { padding: 12px; } + .stat > span { font-size: 10px; } + .stat strong { font-size: 23px; } + .stats-secondary > div { padding: 12px; } + .stats-secondary strong { font-size: 12px; } + .error-card { padding: 19px; } + .error-head h2 { font-size: 19px; } + .json-actions { align-items: flex-start; flex-direction: column; } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } } - -[data-theme="light"] { - --bg: #f6f8fb; - --bg-elev: #fff; - --card: #fff; - --text: #1d2433; - --muted: #5b6678; - --border: #e6eaf2; - --brand: #0054e6; - --brand-2: #3aa2ff; - --success: #1a9a56; - --danger: #d14646; - --shadow: 0 8px 24px rgba(0, 0, 0, .08); - color-scheme: light; -} - -* { - box-sizing: border-box -} - -html, -body { - height: 100% -} - -body { - margin: 0; - font-family: ui-sans-serif, system-ui, "Segoe UI", Roboto, Arial, sans-serif; - background: - /*radial-gradient(1200px 600px at 10% -10%, rgba(91, 157, 255, .08), transparent 60%), - radial-gradient(900px 500px at 110% 0%, rgba(123, 212, 255, .10), transparent 60%),*/ - var(--bg); - color: var(--text); -} - -/* Header */ -.site-header { - position: sticky; - top: 0; - z-index: 10; - display: flex; - justify-content: space-between; - align-items: center; - padding: 14px 18px; - background: var(--bg-elev); - border-bottom: 1px solid var(--border); - backdrop-filter: saturate(140%) blur(8px); -} - -.brand { - display: flex; - gap: 10px; - align-items: center; - font-weight: 700; - letter-spacing: .2px -} - -.brand svg { - color: var(--brand) -} - -.actions { - display: flex; - gap: 8px; - align-items: center -} - -/* Layout */ -.container { - max-width: 980px; - margin: 24px auto; - padding: 0 16px; - display: grid; - gap: 18px -} - -.card { - background: linear-gradient(180deg, var(--card), color-mix(in srgb, var(--card) 90%, #000 10%)); - border: 1px solid var(--border); - border-radius: 16px; - box-shadow: var(--shadow); -} - -.section-head { - display: flex; - justify-content: space-between; - align-items: center; - padding: 14px 16px; - border-bottom: 1px dashed var(--border) -} - -/* Hero */ -.hero { - display: flex; - align-items: center; - justify-content: space-between; - padding: 24px; - gap: 18px -} - -.hero h1 { - margin: 0 0 6px; - font-size: clamp(22px, 3.4vw, 30px) -} - -.hero .muted { - color: var(--muted) -} - -.hero-cta .large { - font-size: 1.05rem; - padding: 14px 20px -} - -/* Grid */ -.grid { - display: grid; - grid-template-columns: repeat(12, minmax(0, 1fr)); - gap: 14px -} - -.col-12 { - grid-column: span 12 -} - -.col-6 { - grid-column: span 6 -} - -@media (max-width:720px) { - .col-6 { - grid-column: span 12 - } -} - -/* Form */ -.form-card { - padding: 16px -} - -.form-group label { - display: block; - font-weight: 600; - margin-bottom: 6px -} - -input[type="url"], -input[type="text"], -select { - width: 100%; - padding: 12px 14px; - border-radius: 12px; - background: linear-gradient(0deg, var(--bg-elev), var(--bg-elev)); - border: 1px solid var(--border); - color: var(--text); - outline: none; - transition: border .15s, box-shadow .15s, transform .05s; -} - -input:focus, -select:focus { - border-color: color-mix(in srgb, var(--brand) 60%, var(--border) 40%); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 30%, transparent); -} - -.hint { - color: var(--muted); - display: block; - margin-top: 6px; - font-size: .9rem -} - -.error { - color: var(--danger); - min-height: 1.2em; - margin-top: 6px; - font-size: .9rem -} - -.form-actions { - display: flex; - gap: 10px; - align-items: center; - justify-content: flex-end; -} - -/* Result */ -.result-box { - margin-top: 14px; - padding: 12px; - border: 1px dashed var(--border); - border-radius: 12px; - background: var(--bg-elev) -} - -.result-row { - display: flex; - gap: 10px; - align-items: center -} - -.result-row input[readonly] { - flex: 1 1 auto; - min-width: 0 -} - -.result-buttons { - display: flex; - gap: 8px -} - -/* Recent */ -.recent-card { - padding: 0 -} - -.recent-list { - padding: 12px -} - -.link-item { - display: flex; - flex-direction: column; - gap: 6px; - padding: 12px; - border: 1px solid var(--border); - border-radius: 12px; - background: linear-gradient(180deg, var(--bg-elev), color-mix(in srgb, var(--bg-elev) 92%, #000 8%)); - transition: transform .12s ease-out, border-color .15s; -} - -.link-item:hover { - transform: translateY(-2px); - border-color: color-mix(in srgb, var(--brand) 40%, var(--border) 60%) -} - -.link-main { - display: flex; - gap: 8px; - align-items: center; - overflow: auto; - scrollbar-width: thin -} - -.link-main { - display: grid; - grid-template-columns: 1fr auto auto; - gap: 8px; - align-items: center -} - -.link-main .mono.ellipsis:first-child { - min-width: 0 -} - -.mono { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - white-space: nowrap -} - -.arrow { - opacity: .6 -} - -.link-meta { - display: flex; - justify-content: space-between; - gap: 8px; - align-items: center -} - -.timestamp { - color: var(--muted); - font-size: .92rem -} - -.link-actions { - display: flex; - gap: 6px -} - -/* Footer */ -.site-footer { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; - justify-content: space-between; - margin: 28px auto; - padding: 10px 16px; - max-width: 980px; - color: var(--muted) -} - -.site-footer a { - color: color-mix(in srgb, var(--brand) 80%, var(--text) 20%) -} - -/* Buttons */ -.btn { - -webkit-tap-highlight-color: transparent; - appearance: none; - border: none; - cursor: pointer; - user-select: none; - border-radius: 12px; - padding: 10px 14px; - font-weight: 700; - letter-spacing: .2px; - background: linear-gradient(180deg, color-mix(in srgb, var(--brand) 80%, var(--brand-2) 20%), var(--brand)); - color: #fff; - box-shadow: 0 10px 20px color-mix(in srgb, var(--brand) 35%, transparent); - transition: transform .04s ease, filter .15s ease, box-shadow .15s ease; - - display: inline-block; - text-decoration: none; - display: inline-block; -} - -.btn:hover { - transition: transform 0.15s ease; - display: inline-block; - transform: scale(1.05); -} - -.btn:active { - transform: scale(0.95); -} - -.btn.outline { - background: transparent; - color: var(--text); - border: 1px solid color-mix(in srgb, var(--brand) 60%, var(--border) 40%) -} - -.btn.ghost { - background: transparent; - color: var(--text); - border: 1px solid var(--border) -} - -.btn.tiny { - padding: 6px 10px; - font-weight: 600; - border-radius: 10px -} - -.btn.large { - padding: 14px 20px; - border-radius: 14px -} - -a.btn:hover { - text-decoration: none; -} - -.btn:disabled { - opacity: 0.5; - cursor: not-allowed; - pointer-events: none; -} - -a.btn[aria-disabled="true"] { - opacity: .5; - cursor: not-allowed; - pointer-events: none; - filter: none; - transform: none; -} - -a.btn[aria-disabled="true"]:hover { - filter: none; - transform: none; - text-decoration: none; -} - -/* Toast */ -#toast { - position: fixed; - left: 50%; - bottom: 24px; - transform: translateX(-50%) translateY(20px); - background: var(--bg-elev); - color: var(--text); - border: 1px solid var(--border); - border-radius: 12px; - padding: 10px 14px; - opacity: 0; - box-shadow: var(--shadow); - pointer-events: none; - transition: opacity .2s, transform .2s; -} - -#toast.show { - opacity: 1; - transform: translateX(-50%) translateY(0) -} - -/* Links & helpers */ -a { - color: color-mix(in srgb, var(--brand) 80%, var(--text) 20%); - text-decoration: none -} - -a:hover { - text-decoration: underline -} - -.ellipsis { - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap -} - -select.select, -.select { - appearance: none; - background: var(--bg-elev); - color: var(--text); - border: 1px solid var(--border) -} - -select option { - background: var(--bg-elev); - color: var(--text) -} - -select:focus { - border-color: color-mix(in srgb, var(--brand) 60%, var(--border) 40%); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 30%, transparent); -} - -.nowrap { - white-space: nowrap -} - -/* Stats */ -.kpi-card { - padding: 16px -} - -.section-title { - margin: 10px 12px 0 -} - -.kpi-grid { - display: grid; - gap: 12px; - grid-template-columns: repeat(6, minmax(0, 1fr)) -} - -@media (max-width:980px) { - .kpi-grid { - grid-template-columns: repeat(3, 1fr) - } -} - -@media (max-width:640px) { - .kpi-grid { - grid-template-columns: repeat(2, 1fr) - } -} - -.kpi { - border: 1px solid var(--border); - border-radius: 12px; - padding: 12px; - background: linear-gradient(180deg, var(--bg-elev), color-mix(in srgb, var(--bg-elev) 92%, #000 8%)) -} - -.kpi-label { - color: var(--muted); - font-weight: 600; - font-size: .9rem -} - -.kpi-value { - font-size: 1.4rem; - font-weight: 800; - margin-top: 4px -} - -.table-wrap { - overflow: auto -} - -.data-table { - width: 100%; - border-collapse: collapse; - font-size: .98rem -} - -.data-table th, -.data-table td { - padding: 10px; - border-bottom: 1px solid var(--border); - vertical-align: top -} - -.data-table thead th { - position: sticky; - top: 0; - background: var(--bg-elev); - z-index: 1 -} - -.data-table .right { - text-align: right -} - -.scrollbox { - max-height: 320px; - overflow: auto; - padding: 12px; - margin: 0; - background: linear-gradient(180deg, var(--bg-elev), color-mix(in srgb, var(--bg-elev) 92%, #000 8%)); - border: 1px solid var(--border); - border-radius: 12px; -} - -.subhead { - margin: 10px 6px -} - -.table-filter { - padding: 8px 12px; - border-radius: 10px; - border: 1px solid var(--border); - background: var(--bg-elev); - color: var(--text); - min-width: 220px -} - -/* Stats wide container */ -.container.container--wide { - max-width: 1280px; - padding: 0 20px -} - -.container.container--wide .card { - padding: 16px -} - -.container.container--wide .section-head { - padding: 12px 4px; - border-bottom: 1px dashed var(--border) -} - -.container.container--wide .data-table { - table-layout: fixed; - font-size: .96rem; - line-height: 1.35 -} - -.container.container--wide .data-table th, -.container.container--wide .data-table td { - padding: 8px 10px -} - -.container.container--wide .data-table td:nth-child(2), -.container.container--wide .data-table td:nth-child(5) { - max-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.container.container--wide .data-table tbody tr:nth-child(odd) td { - background: color-mix(in srgb, var(--bg-elev) 96%, #000 4%) -} - -.container.container--wide .table-wrap { - overflow: auto; - scrollbar-width: thin; - -webkit-overflow-scrolling: touch -} - -.container.container--wide .kpi-value { - font-size: 1.5rem -} - -@media (max-width:720px) { - .container.container--wide { - max-width: 100%; - padding: 0 12px - } - - .container.container--wide .data-table { - font-size: .94rem - } -} - -/* Error page */ -.error-card { - padding: 16px -} - -.error-hero { - display: flex; - gap: 18px; - align-items: center; - padding: 8px 6px 16px -} - -.error-illustration { - font-size: clamp(44px, 8vw, 72px); - filter: drop-shadow(0 10px 20px rgba(0, 0, 0, .25)) -} - -.error-main { - flex: 1 1 auto -} - -.status-badge { - display: inline-block; - padding: 6px 10px; - border-radius: 999px; - background: color-mix(in srgb, var(--danger) 18%, var(--bg-elev)); - border: 1px solid color-mix(in srgb, var(--danger) 60%, var(--border) 40%); - color: var(--text); - font-weight: 700; - letter-spacing: .2px; - margin-bottom: 6px; -} - -.error-title { - margin: 4px 0 6px; - font-size: clamp(22px, 3.4vw, 28px) -} - -.error-actions { - display: flex; - gap: 10px; - flex-wrap: wrap; - margin-top: 10px -} - -.error-details { - margin-top: 12px -} - -.error-details>summary { - list-style: none; - cursor: pointer; - padding: 10px 12px; - border: 1px solid var(--border); - border-radius: 10px; - background: var(--bg-elev); - color: var(--text); - display: flex; - align-items: center; - gap: 8px; -} - -.error-details>summary::-webkit-details-marker { - display: none -} - -.error-details[open]>summary { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0 -} - -.summary-title { - font-weight: 700 -} - -.summary-hint { - color: var(--muted); - font-size: .9rem -} - -.details-body { - border: 1px solid var(--border); - border-top: none; - border-radius: 0 0 10px 10px; - background: var(--bg-elev) -} - -.result-box .hint { - display: block; - text-align: center; - font-size: .9rem; - color: var(--muted); - opacity: 0.5; - opacity: 1.1; - text-shadow: 0 0 4px rgba(123, 212, 255, 0.4); -} - -#error-dump { - margin: 0; - padding: 12px; - max-height: 360px; - overflow: auto; - background: linear-gradient(180deg, var(--bg-elev), color-mix(in srgb, var(--bg-elev) 92%, #000 8%)); - border-bottom: 1px solid var(--border); - white-space: pre-wrap; - word-break: break-word; - overflow-wrap: anywhere; -} - - -@media (max-width:720px) { - .error-card { - padding: 12px - } - - .error-details>summary { - padding: 8px 10px - } - - #error-dump { - max-height: 300px - } -} - -.result-box { - margin-top: 14px; - padding: 12px; - border: 1px dashed var(--border); - border-radius: 12px; - background: var(--bg-elev); - box-shadow: 0 0 8px 2px color-mix(in srgb, var(--brand) 50%, transparent 50%); - transition: box-shadow 0.3s ease; -} - -.result-box:hover { - box-shadow: 0 0 12px 4px color-mix(in srgb, var(--brand) 70%, transparent 30%); -} \ No newline at end of file diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..0f3ff98 --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1 @@ +a/l diff --git a/static/js/main.js b/static/js/main.js index eab72ec..8bbd62f 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -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); diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..c628306 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,75 @@ + + + + + + + + {% block title %}Adlist Converter{% endblock %} / Control room + + + + {% block head %}{% endblock %} + + +
+ +
+
+
+ + +
+
+ HOSTS / MIKROTIK + + linuxiarz.pl +
+
+
+ {% block content %}{% endblock %} +
+
ADLIST CONVERTER / INDEPENDENT UTILITYPowered by Flask · Built for MikroTik
+
+
+
+ +{% block scripts %}{% endblock %} + + diff --git a/templates/error.html b/templates/error.html index bec1bb0..266a665 100644 --- a/templates/error.html +++ b/templates/error.html @@ -1,88 +1,13 @@ - - - - - - - Error {{ error.code or 500 }} - - - - - - - - -
-
-
- - -
-
Error {{ error.code or 500 }}
-

- {% 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ługiwany format listy - {% elif (error.code or 500) == 422 %}Nie można przetworzyć listy - {% elif (error.code or 500) == 502 %}Nie udało się pobrać listy - {% elif (error.code or 500) == 500 %}Internal server error - {% else %}Something went wrong - {% endif %} -

-

{{ (error.description|string)|e }}

- -
- - Go home -
-
-
- -
- - Error details - click to expand - -
-
-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 '' }}
-
-
- -
-
-
-
-
- - - -
- - - - - - \ No newline at end of file +{% extends 'base.html' %} +{% set page = 'error' %} +{% block title %}Error {{ code }}{% endblock %} +{% block content %} +
REQUEST / ERROR {{ code }}

We couldn't complete that.

Your request could not be processed. Review the details below and try a different source if needed.

+
+
HTTP {{ code }} / REQUEST FAILED

{% 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 %}

+

{{ error.description }}

+
Back to converter
+
Technical details
HTTP STATUS
{{ code }}
METHOD
{{ request.method }}
PATH
{{ request.path }}
+
+
Invalid or failed conversion responses are not cached by the reverse proxy.
+{% endblock %} diff --git a/templates/form.html b/templates/form.html index 377b28c..5f1cad9 100644 --- a/templates/form.html +++ b/templates/form.html @@ -1,156 +1,111 @@ - - +{% extends 'base.html' %} +{% set page = 'converter' %} +{% block title %}Converter{% endblock %} +{% block content %} +
+
+
THE CONVERSION WORKSPACE
+

Blocklists, made compatible.

+

Turn public hosts, DNS and adblock sources into MikroTik-compatible lists. One source URL. One output link. Ready for your DNS setup.

+
+ +
- - - - Hosts Converter - - - - - - -