redesign
This commit is contained in:
1 parent
ad620c976a
commit
0973129c17
26 files changed
+2253
-2243
No files matched your search
@@ -0,0 +1,78 @@
|
|||||||
|
# `/convert` shared cache — RustProxy 0.29.3 and Varnish
|
||||||
|
|
||||||
|
Only fully downloaded, decoded and converted lists return a cacheable HTTP 200.
|
||||||
|
All errors (400, 413, 415, 422, 429, 502, etc.) return `Cache-Control: no-store`.
|
||||||
|
Authorized debug responses (`?debug=1`) are never shared or cached.
|
||||||
|
|
||||||
|
## Example successful response
|
||||||
|
|
||||||
|
```http
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Content-Type: text/plain; charset=utf-8
|
||||||
|
Content-Length: 12345
|
||||||
|
ETag: "<sha256 of converted response bytes>"
|
||||||
|
Cache-Control: public, max-age=3600, s-maxage=604800, stale-while-revalidate=86400, stale-if-error=604800
|
||||||
|
X-Content-Type-Options: nosniff
|
||||||
|
```
|
||||||
|
|
||||||
|
- `max-age`: cache freshness for browsers/clients (default 1 hour).
|
||||||
|
- `s-maxage`: freshness for shared caches such as RustProxy/Varnish (default 7 days).
|
||||||
|
- `stale-while-revalidate`: permits serving a stored stale object while the proxy refreshes it (1 additional day by default; cache support required).
|
||||||
|
- `stale-if-error`: permits serving an already-stored stale object if refresh fails (7 additional days by default; cache support required).
|
||||||
|
- `ETag`: SHA-256 of the **converted bytes**, not the upstream file ETag. Identical converted bodies have matching ETags. `If-None-Match` can produce an HTTP 304, but only after the application checks the complete source on a backend request. On a normal proxy HIT, no application work is performed.
|
||||||
|
- The upstream source is always fetched as an unconditional GET. Client validators must not be sent to the **source list** because they refer to the **converted** resource.
|
||||||
|
- GET and HEAD use the same validated body, ETag and length; HEAD does not send response bytes.
|
||||||
|
|
||||||
|
Environment variables in `/var/www/adlist_mikrotik/.env`:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
CACHE_ENABLED=true
|
||||||
|
CACHE_MAX_AGE=3600
|
||||||
|
CACHE_S_MAXAGE=604800
|
||||||
|
CACHE_STALE_WHILE_REVALIDATE=86400
|
||||||
|
CACHE_STALE_IF_ERROR=604800
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing `.env` files override `config.py` defaults, so update the actual deployed `.env` after upgrading. Restart the `listapp` service to apply changes.
|
||||||
|
|
||||||
|
## RustProxy (important: origin headers alone cannot override BYPASS)
|
||||||
|
|
||||||
|
1. Enable the global cache and caching on the `listapp.linuxiarz.pl` vhost.
|
||||||
|
2. Set `max_ttl_seconds=604800` globally and/or on that vhost. RustProxy 0.29.3 has a **default cap of 86400 seconds**, and an allowed maximum of **604800 seconds**. Longer origin `s-maxage` values cannot override the cap.
|
||||||
|
3. Set `bypass_query=false`: `/convert` **requires** a query string (`url=...&ip=...`). Check `bypass_prefixes` to make sure `/convert` is not excluded.
|
||||||
|
4. Allow sufficient `max_object_bytes` for the **converted result**, not the source. The RustProxy default is 16 MiB; objects larger than the limit will not be stored. If necessary, increase the total HOT/transient budgets too.
|
||||||
|
5. Optionally enable `cold_enabled=true` and size COLD appropriately, so cached objects can survive RAM eviction and regular restarts. COLD is still bounded and not permanent storage.
|
||||||
|
6. Requests with `Cookie`, `Authorization`, `Cache-Control: no-store`, or various forced revalidation directives may BYPASS / revalidate. Normal unauthenticated periodic scans without these request headers are best for high cache hit rate. If you configure an exception, scope it only to public `/convert` requests and consider the security implications.
|
||||||
|
7. Enable cache diagnostics `expose_status`, `expose_cache_status`, and optionally `expose_resident_age` to inspect MISS/HIT/BYPASS and reason codes.
|
||||||
|
|
||||||
|
A long TTL does not guarantee physical retention: eviction, explicit purge, size limits and settings may remove entries. Source updates are not visible to normal cache users until freshness expires or the cache is purged; choose TTL accordingly.
|
||||||
|
|
||||||
|
## Varnish
|
||||||
|
|
||||||
|
Varnish can use `Cache-Control: public, s-maxage=...` (subject to VCL policy, cookies, Authorization, cache size and eviction). It must be configured to pass/cache the `/convert` route appropriately. The application does not use an internal Redis response-body cache; the shared proxy stores successful responses.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Use the *same full URL* repeatedly, with GET (not HEAD), without cookies or request `Cache-Control: no-cache`. For example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE='https://listapp.linuxiarz.pl/convert'
|
||||||
|
for n in 1 2 3; do
|
||||||
|
echo "Request $n"
|
||||||
|
curl -sS --get \
|
||||||
|
--data-urlencode 'url=https://v.firebog.net/hosts/RPiList-Malware.txt' \
|
||||||
|
--data-urlencode 'ip=0.0.0.0' \
|
||||||
|
-D - -o /dev/null "$BASE" | \
|
||||||
|
grep -Ei '^(HTTP/|cache-control:|etag:|age:|x-cache:|cache-status:|content-length:|content-type:)'
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect `HTTP 200` and the `public, s-maxage` header from the application. If RustProxy caching is eligible, a typical sequence is `MISS`, `HIT`, `HIT`, while `Age` increases. `BYPASS` means the proxy did not even attempt a cache fill (check request and proxy configuration); `MISS` followed by repeated `MISS` can mean oversize objects, excluded responses or aborted body transfer. These labels require corresponding diagnostics to be enabled on the proxy.
|
||||||
|
|
||||||
|
Run local code checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest discover -s tests -p 'test_*.py' -v
|
||||||
|
python3 tests/smoke_cache_routes.py
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
Whitespace-only changes.
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -1,768 +1,68 @@
|
|||||||
import re
|
"""WSGI / ASGI application entrypoint. Gunicorn target stays app:asgi_app.
|
||||||
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
|
|
||||||
|
|
||||||
from flask import (
|
Routes, caching, validation, and Redis statistics live in the adlist package.
|
||||||
Flask,
|
"""
|
||||||
request,
|
import hashlib
|
||||||
render_template,
|
from pathlib import Path
|
||||||
abort,
|
from flask import Flask
|
||||||
jsonify,
|
|
||||||
stream_with_context,
|
|
||||||
g,
|
|
||||||
Response,
|
|
||||||
)
|
|
||||||
from flask_compress import Compress
|
from flask_compress import Compress
|
||||||
from source_conversion import (
|
|
||||||
InvalidSourceError,
|
|
||||||
SourceTooLargeError,
|
|
||||||
convert_source,
|
|
||||||
iter_completed_bytes,
|
|
||||||
)
|
|
||||||
from flask_limiter import Limiter
|
from flask_limiter import Limiter
|
||||||
|
from asgiref.wsgi import WsgiToAsgi
|
||||||
import config
|
import config
|
||||||
|
from adlist import services, pages, converter, metrics
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
app.config.update(MAX_CONTENT_LENGTH=config.MAX_CONTENT_LENGTH, SECRET_KEY=config.SECRET_KEY)
|
||||||
app.config["MAX_CONTENT_LENGTH"] = config.MAX_CONTENT_LENGTH
|
|
||||||
app.config["SECRET_KEY"] = config.SECRET_KEY
|
|
||||||
app.debug = config.FLASK_DEBUG
|
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(
|
limiter = Limiter(
|
||||||
key_func=get_client_ip,
|
key_func=services.get_client_ip,
|
||||||
app=app,
|
app=app,
|
||||||
default_limits=[config.RATE_LIMIT_DEFAULT],
|
default_limits=[config.RATE_LIMIT_DEFAULT],
|
||||||
storage_uri=config.REDIS_URL,
|
storage_uri=config.REDIS_URL,
|
||||||
)
|
)
|
||||||
|
|
||||||
Compress(app)
|
Compress(app)
|
||||||
|
app.before_request(services.track_request_data)
|
||||||
|
app.after_request(services.finalize_response)
|
||||||
@app.before_request
|
app.add_template_filter(services.datetimeformat_filter, "datetimeformat")
|
||||||
def track_request_data():
|
|
||||||
g.start_time = time.perf_counter()
|
# Fingerprinting keeps long-lived cache safe for upgraded CSS / JS assets.
|
||||||
redis_client.incr(
|
def _asset_revision():
|
||||||
f"stats:user_agents:{quote(request.headers.get('User-Agent', 'Unknown'), safe='')}"
|
root = Path(__file__).resolve().parent / "static"
|
||||||
)
|
digest = hashlib.sha256()
|
||||||
redis_client.incr(f"stats:client_ips:{get_client_ip()}")
|
for asset in sorted(root.rglob("*.css")) + sorted(root.rglob("*.js")):
|
||||||
redis_client.incr(f"stats:methods:{request.method}")
|
digest.update(asset.relative_to(root).as_posix().encode("utf-8"))
|
||||||
|
digest.update(asset.read_bytes())
|
||||||
|
return digest.hexdigest()[:12]
|
||||||
@app.after_request
|
|
||||||
def finalize_response(response):
|
ASSET_REVISION = _asset_revision()
|
||||||
elapsed = time.perf_counter() - g.start_time
|
|
||||||
redis_client.incrbyfloat("stats:processing_time_total", elapsed)
|
@app.context_processor
|
||||||
redis_client.incr("stats:processing_time_count")
|
def inject_common():
|
||||||
try:
|
return {"asset_revision": ASSET_REVISION}
|
||||||
current_min = float(redis_client.get("stats:processing_time_min") or elapsed)
|
|
||||||
if elapsed < current_min:
|
app.add_url_rule("/", "index", pages.index, methods=["GET"])
|
||||||
redis_client.set("stats:processing_time_min", elapsed)
|
app.add_url_rule("/favicon.ico", "favicon", pages.favicon, methods=["GET"])
|
||||||
except Exception:
|
app.add_url_rule("/convert", "convert", limiter.limit(config.RATE_LIMIT_CONVERT)(converter.convert), methods=["GET", "HEAD"])
|
||||||
redis_client.set("stats:processing_time_min", elapsed)
|
|
||||||
try:
|
for name, func in (("stats", metrics.stats), ("stats_json", metrics.stats_json)):
|
||||||
current_max = float(redis_client.get("stats:processing_time_max") or elapsed)
|
wrapped = services.basic_auth_required(
|
||||||
if elapsed > current_max:
|
realm=config.STATS_BASIC_AUTH_REALM,
|
||||||
redis_client.set("stats:processing_time_max", elapsed)
|
user=config.STATS_BASIC_AUTH_USER,
|
||||||
except Exception:
|
password=config.STATS_BASIC_AUTH_PASS,
|
||||||
redis_client.set("stats:processing_time_max", elapsed)
|
)(func)
|
||||||
|
app.add_url_rule("/" + ("stats" if name == "stats" else "stats.json"), name, wrapped, methods=["GET"])
|
||||||
path = request.path or "/"
|
|
||||||
|
for code in (400, 403, 404, 413, 415, 422, 429, 500, 502):
|
||||||
if response.status_code >= 400:
|
app.register_error_handler(code, pages.handle_errors)
|
||||||
response.headers["Cache-Control"] = "no-store"
|
|
||||||
return response
|
# Compatibility: integrations refer to app:app or app:asgi_app.
|
||||||
|
asgi_app = WsgiToAsgi(app)
|
||||||
if path.startswith("/static/"):
|
convert = converter.convert
|
||||||
response.headers.pop("Content-Disposition", None)
|
stats = metrics.stats
|
||||||
if path.endswith((".css", ".js")):
|
stats_json = metrics.stats_json
|
||||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
redis_client = services.redis_client
|
||||||
else:
|
cache_headers = converter.cache_headers
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(host=config.BIND_HOST, port=config.BIND_PORT)
|
app.run(host=config.BIND_HOST, port=config.BIND_PORT)
|
||||||
else:
|
|
||||||
from asgiref.wsgi import WsgiToAsgi
|
|
||||||
|
|
||||||
asgi_app = WsgiToAsgi(app)
|
|
||||||
@@ -19,7 +19,7 @@ def getenv_float(key: str, default: float) -> float:
|
|||||||
return default
|
return default
|
||||||
|
|
||||||
# Podstawowe
|
# Podstawowe
|
||||||
FLASK_DEBUG = getenv_bool("FLASK_DEBUG", True)
|
FLASK_DEBUG = getenv_bool("FLASK_DEBUG", False)
|
||||||
SECRET_KEY = os.getenv("SECRET_KEY", "secretkey")
|
SECRET_KEY = os.getenv("SECRET_KEY", "secretkey")
|
||||||
|
|
||||||
# Rozmiary/limity
|
# 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_USER = os.getenv("STATS_BASIC_AUTH_USER", "admin").strip()
|
||||||
STATS_BASIC_AUTH_PASS = os.getenv("STATS_BASIC_AUTH_PASS", "admin").strip()
|
STATS_BASIC_AUTH_PASS = os.getenv("STATS_BASIC_AUTH_PASS", "admin").strip()
|
||||||
|
|
||||||
# Cache/ETag dla Varnisha
|
# Shared proxy cache (Cache-Control); RustProxy's own max_ttl_seconds cap
|
||||||
CACHE_ENABLED = getenv_bool("CACHE_ENABLED", True)
|
# must also be configured to at least CACHE_S_MAXAGE (maximum 604800 seconds).
|
||||||
CACHE_S_MAXAGE = getenv_int("CACHE_S_MAXAGE", 43200) # 12h
|
CACHE_ENABLED = getenv_bool("CACHE_ENABLED", True)
|
||||||
CACHE_MAX_AGE = getenv_int("CACHE_MAX_AGE", 3600) # 1h
|
CACHE_S_MAXAGE = max(0, getenv_int("CACHE_S_MAXAGE", 604800)) # 7 days at proxy
|
||||||
USE_REDIS_BODY_CACHE = getenv_bool("USE_REDIS_BODY_CACHE", False)
|
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/stream
|
||||||
AIOHTTP_TOTAL_TIMEOUT = getenv_float("AIOHTTP_TOTAL_TIMEOUT", 70.0)
|
AIOHTTP_TOTAL_TIMEOUT = getenv_float("AIOHTTP_TOTAL_TIMEOUT", 70.0)
|
||||||
|
|||||||
@@ -5,6 +5,5 @@ redis
|
|||||||
requests
|
requests
|
||||||
aiohttp
|
aiohttp
|
||||||
asgiref
|
asgiref
|
||||||
unicorn
|
|
||||||
gunicorn
|
gunicorn
|
||||||
uvicorn
|
uvicorn
|
||||||
+18
-14
@@ -6,6 +6,7 @@ sent to the browser or reverse proxy.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import codecs
|
import codecs
|
||||||
|
import hashlib
|
||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -26,6 +27,7 @@ class ConversionResult:
|
|||||||
lines_read: int
|
lines_read: int
|
||||||
lines_emitted: int
|
lines_emitted: int
|
||||||
output_size: int
|
output_size: int
|
||||||
|
content_sha256: str
|
||||||
|
|
||||||
|
|
||||||
def _get_encoding(content_type: str) -> str:
|
def _get_encoding(content_type: str) -> str:
|
||||||
@@ -37,11 +39,11 @@ def _get_encoding(content_type: str) -> str:
|
|||||||
try:
|
try:
|
||||||
encoding = codecs.lookup(encoding).name
|
encoding = codecs.lookup(encoding).name
|
||||||
except (LookupError, ValueError) as exc:
|
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
|
# The reader splits on ASCII newline octets. Multi-byte encodings need a
|
||||||
# different framing protocol and must not be interpreted as hosts text.
|
# 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"}:
|
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
|
return encoding
|
||||||
|
|
||||||
|
|
||||||
@@ -55,10 +57,10 @@ def _bounded_lines(response, *, chunk_size: int, line_limit: int, source_limit:
|
|||||||
if not chunk:
|
if not chunk:
|
||||||
continue
|
continue
|
||||||
if not isinstance(chunk, bytes):
|
if not isinstance(chunk, bytes):
|
||||||
raise InvalidSourceError("Zrodlo nie zwrocilo poprawnych danych binarnych.")
|
raise InvalidSourceError("Source returned invalid binary data.")
|
||||||
total += len(chunk)
|
total += len(chunk)
|
||||||
if total > source_limit:
|
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")
|
pieces = chunk.split(b"\n")
|
||||||
for part in pieces[:-1]:
|
for part in pieces[:-1]:
|
||||||
if skipping or len(pending) + len(part) > line_limit:
|
if skipping or len(pending) + len(part) > line_limit:
|
||||||
@@ -104,7 +106,7 @@ def convert_source(
|
|||||||
if length:
|
if length:
|
||||||
try:
|
try:
|
||||||
if int(length) > source_limit:
|
if int(length) > source_limit:
|
||||||
raise SourceTooLargeError("Plik zrodlowy przekracza dopuszczalny rozmiar.")
|
raise SourceTooLargeError("Source list exceeds the maximum allowed size.")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -112,13 +114,15 @@ def convert_source(
|
|||||||
lines_read = 0
|
lines_read = 0
|
||||||
lines_emitted = 0
|
lines_emitted = 0
|
||||||
written = 0
|
written = 0
|
||||||
|
content_hasher = hashlib.sha256()
|
||||||
|
|
||||||
def append(data: bytes):
|
def append(data: bytes):
|
||||||
nonlocal written
|
nonlocal written
|
||||||
if written + len(data) > output_limit:
|
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)
|
output.write(data)
|
||||||
written += len(data)
|
written += len(data)
|
||||||
|
content_hasher.update(data)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if debug_lines is not None and debug_lines:
|
if debug_lines is not None and debug_lines:
|
||||||
@@ -133,37 +137,37 @@ def convert_source(
|
|||||||
lines_read += 1
|
lines_read += 1
|
||||||
if raw is None:
|
if raw is None:
|
||||||
if debug_lines is not None and lines_read <= 5:
|
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
|
continue
|
||||||
if b"\x00" in raw:
|
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:
|
try:
|
||||||
line = raw.decode(encoding, errors="strict")
|
line = raw.decode(encoding, errors="strict")
|
||||||
if lines_read == 1:
|
if lines_read == 1:
|
||||||
line = line.lstrip("\ufeff")
|
line = line.lstrip("\ufeff")
|
||||||
except UnicodeError as exc:
|
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:
|
try:
|
||||||
converted = convert_line(line, target_ip)
|
converted = convert_line(line, target_ip)
|
||||||
except (TypeError, ValueError) as exc:
|
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:
|
if converted:
|
||||||
lines_emitted += 1
|
lines_emitted += 1
|
||||||
append((converted + "\n").encode("utf-8"))
|
append((converted + "\n").encode("utf-8"))
|
||||||
if debug_lines is not None and lines_read <= 5:
|
if debug_lines is not None and lines_read <= 5:
|
||||||
preview = line[:200].replace("\r", "\\r").replace("\n", "\\n")
|
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:
|
if lines_emitted == 0:
|
||||||
raise InvalidSourceError(
|
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:
|
if debug_lines is not None:
|
||||||
append(
|
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)
|
output.seek(0)
|
||||||
return ConversionResult(output, lines_read, lines_emitted, written)
|
return ConversionResult(output, lines_read, lines_emitted, written, content_hasher.hexdigest())
|
||||||
except BaseException:
|
except BaseException:
|
||||||
output.close()
|
output.close()
|
||||||
raise
|
raise
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
#/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
venv/bin/gunicorn -k uvicorn.workers.UvicornWorker --workers 1 --bind 127.0.0.1:8283 --keep-alive 30 --timeout 60 app:asgi_app
|
venv/bin/gunicorn -k uvicorn.workers.UvicornWorker --workers 1 --bind 127.0.0.1:8283 --keep-alive 30 --timeout 60 app:asgi_app
|
||||||
+299
-742
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="8" fill="#131615"/><rect x="6" y="6" width="52" height="52" fill="none" stroke="#f69b74" stroke-width="2"/><text x="32" y="42" text-anchor="middle" fill="#f69b74" font-family="monospace" font-size="30" font-weight="700">a/l</text></svg>
|
||||||
|
After Width: | Height: | Size: 330 B |
+210
-273
@@ -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 toastEl = $('#toast');
|
||||||
const $$ = (q, c = document) => Array.from(c.querySelectorAll(q));
|
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 ---
|
function isValidIPv4(ip) {
|
||||||
const setTheme = (t) => {
|
const octets = ip.split('.');
|
||||||
document.documentElement.setAttribute('data-theme', t);
|
return octets.length === 4 && octets.every(octet => {
|
||||||
try { localStorage.setItem('theme', t) } catch { }
|
if (!/^(0|[1-9]\d{0,2})$/.test(octet)) return false;
|
||||||
};
|
return Number(octet) <= 255;
|
||||||
window.setTheme = setTheme;
|
});
|
||||||
|
}
|
||||||
const toast = (msg) => {
|
function isValidIPv6(ip) {
|
||||||
const el = $('#toast'); if (!el) return;
|
if (!ip.includes(':') || ip.includes('%') || ip.split('::').length > 2) return false;
|
||||||
el.textContent = msg; el.classList.add('show');
|
let raw = ip;
|
||||||
clearTimeout(el._t); el._t = setTimeout(() => el.classList.remove('show'), 2000);
|
let ipv4Count = 0;
|
||||||
};
|
if (raw.includes('.')) {
|
||||||
const host = () => `${location.protocol}//${location.host}`;
|
const cut = raw.lastIndexOf(':');
|
||||||
|
if (cut < 0 || !isValidIPv4(raw.slice(cut + 1))) return false;
|
||||||
// --- IP validators ---
|
raw = raw.slice(0, cut) + ':0:0';
|
||||||
function isValidIPv4(ip) {
|
ipv4Count = 1;
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
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) {
|
function normalizeSource(input) {
|
||||||
let work = ip;
|
const value = (input || '').trim();
|
||||||
const idx = work.lastIndexOf(':');
|
if (!value || /[\r\n]/.test(value)) return null;
|
||||||
if (idx !== -1) {
|
try {
|
||||||
const tail = work.slice(idx + 1);
|
const candidate = /^[a-z][a-z\d+.-]*:\/\//i.test(value) ? value : `https://${value}`;
|
||||||
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(tail)) {
|
const parsed = new URL(candidate);
|
||||||
if (!isValidIPv4(tail)) return false;
|
if (!['https:', 'http:'].includes(parsed.protocol) || !parsed.hostname) return null;
|
||||||
work = work.slice(0, idx) + ':0:0';
|
return parsed.toString();
|
||||||
}
|
} catch (_) { return null; }
|
||||||
}
|
}
|
||||||
if (work.split('::').length > 2) return false;
|
function conversionUrl(source, target) {
|
||||||
const hasCompress = work.includes('::');
|
const origin = /^https?:$/.test(window.location.protocol) ? window.location.origin : 'https://listapp.linuxiarz.pl';
|
||||||
const parts = work.split(':').filter(Boolean);
|
const url = new URL('/convert', origin);
|
||||||
if ((!hasCompress && parts.length !== 8) || (hasCompress && parts.length > 7)) return false;
|
url.searchParams.set('url', source);
|
||||||
return parts.every(g => /^[0-9a-fA-F]{1,4}$/.test(g));
|
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;
|
||||||
}
|
}
|
||||||
|
if (linkState) {
|
||||||
function isValidIP(ip) {
|
linkState.textContent = ready ? 'LINK READY' : 'AWAITING INPUT';
|
||||||
const v = (ip || '').trim();
|
linkState.classList.toggle('live', ready);
|
||||||
return isValidIPv4(v) || isValidIPv6(v);
|
|
||||||
}
|
}
|
||||||
|
const status = validationStatus?.parentElement;
|
||||||
// --- URL helpers ---
|
if (status && validationStatus) {
|
||||||
function normalizeUrlMaybe(v) {
|
status.classList.toggle('ready', ready);
|
||||||
const raw = (v || '').trim();
|
status.classList.toggle('invalid', Boolean(rawSource && !source || ip && !ipValid));
|
||||||
if (!raw) return '';
|
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';
|
||||||
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 '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
[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) {
|
async function copy(text) {
|
||||||
if (!url || !ip) return '';
|
if (!text) { toast('Nothing to copy', true); return; }
|
||||||
try {
|
try {
|
||||||
const enc = encodeURIComponent(url);
|
if (!navigator.clipboard?.writeText) throw new Error('Clipboard API unavailable');
|
||||||
const ipClean = (ip || '').trim();
|
await navigator.clipboard.writeText(text);
|
||||||
return `${host()}/convert?url=${enc}&ip=${encodeURIComponent(ipClean)}`;
|
toast('Copied to clipboard');
|
||||||
} catch { return ''; }
|
} 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) => {
|
document.addEventListener('click', event => {
|
||||||
const t = e.target.closest('[data-action="toggle-theme"]');
|
const trigger = event.target.closest('[data-action]');
|
||||||
if (t) {
|
if (!trigger) return;
|
||||||
e.preventDefault();
|
const action = trigger.dataset.action;
|
||||||
const cur = document.documentElement.getAttribute('data-theme') || 'dark';
|
if (action === 'toggle-theme') {
|
||||||
setTheme(cur === 'dark' ? 'light' : 'dark');
|
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));
|
||||||
const urlInput = $('#url-input');
|
} else if (action === 'clear') {
|
||||||
const ipInput = $('#ip-input');
|
if (!urlInput || !ipInput) return;
|
||||||
const ipPreset = $('#ip-preset');
|
urlInput.value = '';
|
||||||
const out = $('#generated-link');
|
ipInput.value = '195.187.6.34';
|
||||||
const openBtn = $('#open-link');
|
setError(urlInput, ''); setError(ipInput, '');
|
||||||
const copyBtn = $('#copy-btn');
|
updateOutput();
|
||||||
|
urlInput.focus();
|
||||||
function showError(input, msg) {
|
} else if (action === 'copy') {
|
||||||
const id = input.getAttribute('id');
|
const node = $(trigger.dataset.target || '');
|
||||||
const box = document.querySelector(`.error[data-error-for="${id}"]`);
|
void copy(node?.value ?? node?.textContent ?? '');
|
||||||
if (box) box.textContent = msg || '';
|
} else if (action === 'copy-text') {
|
||||||
input.setAttribute('aria-invalid', msg ? 'true' : 'false');
|
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();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', event => {
|
||||||
function updatePreview() {
|
if (event.key === 'Escape' && document.body.classList.contains('menu-open')) {
|
||||||
const rawUrl = (urlInput?.value || '').trim();
|
document.body.classList.remove('menu-open');
|
||||||
const ip = (ipInput?.value || '').trim();
|
$('[data-action="menu"]')?.setAttribute('aria-expanded', 'false');
|
||||||
|
|
||||||
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('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
|
$$('[data-filter]').forEach(filter => filter.addEventListener('input', () => {
|
||||||
['input', 'change', 'blur'].forEach(evt => {
|
const table = $(filter.dataset.filter || '');
|
||||||
urlInput?.addEventListener(evt, updatePreview);
|
if (!table) return;
|
||||||
ipInput?.addEventListener(evt, updatePreview);
|
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++;
|
||||||
});
|
});
|
||||||
|
const empty = $(table.id === 'recent-table' ? '#recent-empty' : `[data-empty-for="#${table.id}"]`);
|
||||||
// presets
|
if (empty) empty.hidden = !query || visible > 0;
|
||||||
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'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// --- 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);
|
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="theme-color" content="#131615">
|
||||||
|
<meta name="description" content="Convert public hosts and adblocking lists into MikroTik-compatible host entries.">
|
||||||
|
<title>{% block title %}Adlist Converter{% endblock %} / Control room</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg', v=asset_revision) }}">
|
||||||
|
<script>
|
||||||
|
try { const theme = localStorage.getItem('adlist-theme');
|
||||||
|
if (theme === 'dark' || theme === 'light') document.documentElement.dataset.theme = theme;
|
||||||
|
} catch (_) {}
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css', v=asset_revision) }}">
|
||||||
|
{% block head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<aside class="sidebar" id="sidebar">
|
||||||
|
<a class="brand" href="{{ url_for('index') }}" aria-label="Adlist Converter home">
|
||||||
|
<span class="brand-mark" aria-hidden="true">a<span>/</span>l</span>
|
||||||
|
<span class="brand-name">Adlist<span class="brand-suffix"> Converter</span></span>
|
||||||
|
</a>
|
||||||
|
<div class="edition">HOSTS CONVERSION ENGINE</div>
|
||||||
|
<nav class="main-navigation" aria-label="Main navigation" id="main-navigation">
|
||||||
|
<a class="nav {% if page == 'converter' %}active{% endif %}" href="{{ url_for('index') }}" {% if page == 'converter' %}aria-current="page"{% endif %}>
|
||||||
|
<span class="nav-index">01</span><b>Converter</b><span class="nav-arrow" aria-hidden="true">↗</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav" href="{{ url_for('index') }}#recent-links">
|
||||||
|
<span class="nav-index">02</span><b>Recent links</b><span class="nav-arrow" aria-hidden="true">↗</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav {% if page == 'stats' %}active{% endif %}" href="{{ url_for('stats') }}" {% if page == 'stats' %}aria-current="page"{% endif %}>
|
||||||
|
<span class="nav-index">03</span><b>Statistics</b><span class="nav-arrow" aria-hidden="true">↗</span>
|
||||||
|
</a>
|
||||||
|
<a class="nav" href="{{ url_for('stats_json') }}" target="_blank" rel="noopener">
|
||||||
|
<span class="nav-index">04</span><b>Stats API</b><span class="nav-arrow" aria-hidden="true">↗</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-bottom">
|
||||||
|
<span class="small-label">RUNTIME</span>
|
||||||
|
<p>Python / Flask / Redis<br>WSGI / ASGI / HTTP cache</p>
|
||||||
|
<div class="local-tag"><i aria-hidden="true"></i> PROXY CACHE READY</div>
|
||||||
|
<div class="sidebar-credit"><a href="https://www.linuxiarz.pl" target="_blank" rel="noopener noreferrer">linuxiarz.pl <span aria-hidden="true">↗</span></a></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div class="workarea">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="topbar-left">
|
||||||
|
<button class="menu-toggle quiet" type="button" data-action="menu" aria-controls="main-navigation" aria-expanded="false" aria-label="Toggle menu">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="breadcrumb">CONTROL ROOM <span>/</span> <b>{% if page == 'stats' %}Statistics{% elif page == 'error' %}Error{% else %}Converter{% endif %}</b></div>
|
||||||
|
</div>
|
||||||
|
<div class="top-actions">
|
||||||
|
<span class="top-annotation">HOSTS / MIKROTIK</span>
|
||||||
|
<button type="button" class="quiet theme-toggle" data-action="toggle-theme" aria-label="Switch color theme" title="Switch color theme">
|
||||||
|
<svg class="theme-icon theme-icon-sun" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32 1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
|
||||||
|
<svg class="theme-icon theme-icon-moon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M20.5 14.1A8.5 8.5 0 0 1 9.9 3.5 8.5 8.5 0 1 0 20.5 14.1Z"/></svg>
|
||||||
|
<span>Theme</span>
|
||||||
|
</button>
|
||||||
|
<a class="quiet top-external" href="https://www.linuxiarz.pl" target="_blank" rel="noopener noreferrer">linuxiarz.pl <span aria-hidden="true">↗</span></a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main id="main-content">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
<footer class="app-footer"><span>ADLIST CONVERTER <span class="footer-divide">/</span> INDEPENDENT UTILITY</span><span>Powered by Flask · Built for MikroTik</span></footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||||
|
<script defer src="{{ url_for('static', filename='js/main.js', v=asset_revision) }}"></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+13
-88
@@ -1,88 +1,13 @@
|
|||||||
<!DOCTYPE html>
|
{% extends 'base.html' %}
|
||||||
<html lang="en" data-theme="dark">
|
{% set page = 'error' %}
|
||||||
|
{% block title %}Error {{ code }}{% endblock %}
|
||||||
<head>
|
{% block content %}
|
||||||
<meta charset="UTF-8" />
|
<section class="page-intro"><div><div class="eyebrow error-eyebrow">REQUEST / ERROR {{ code }}</div><h1>We couldn't <em>complete that.</em></h1><p class="lead">Your request could not be processed. Review the details below and try a different source if needed.</p></div></section>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<section class="card error-card">
|
||||||
<title>Error {{ error.code or 500 }}</title>
|
<div class="error-head"><div class="error-icon" aria-hidden="true">!</div><div><span class="small-label">HTTP {{ code }} / REQUEST FAILED</span><h2>{% if code == 400 %}Invalid request{% elif code == 403 %}Access denied{% elif code == 404 %}Page not found{% elif code == 413 %}Source file too large{% elif code == 415 %}Unsupported file format{% elif code == 422 %}Unable to decode this list{% elif code == 429 %}Rate limit exceeded{% elif code == 502 %}Source unavailable{% elif code == 500 %}Internal server error{% else %}Unable to process request{% endif %}</h2></div></div>
|
||||||
<meta name="theme-color" content="#0f1115" />
|
<p class="error-description">{{ error.description }}</p>
|
||||||
<link rel="preload" href="{{ url_for('static', filename='css/main.css') }}" as="style">
|
<div class="error-actions"><a href="{{ url_for('index') }}" class="primary">Back to converter</a><button type="button" class="outline" data-action="try-again">Retry request</button></div>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
|
<details class="error-details"><summary>Technical details <span aria-hidden="true">↓</span></summary><dl><div><dt>HTTP STATUS</dt><dd>{{ code }}</dd></div><div><dt>METHOD</dt><dd>{{ request.method }}</dd></div><div><dt>PATH</dt><dd>{{ request.path }}</dd></div></dl></details>
|
||||||
</head>
|
</section>
|
||||||
|
<div class="error-tip"><i class="signal" aria-hidden="true"></i><span>Invalid or failed conversion responses are not cached by the reverse proxy.</span></div>
|
||||||
<body>
|
{% endblock %}
|
||||||
<header class="site-header">
|
|
||||||
<div class="brand">
|
|
||||||
<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
|
|
||||||
<path d="M4 4h16v4H4zM4 10h10v4H4zM4 16h16v4H4z" fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
<span>Hosts Converter</span>
|
|
||||||
</div>
|
|
||||||
<nav class="actions">
|
|
||||||
<button class="btn ghost" type="button" data-action="toggle-theme" aria-label="Toggle theme">🌓</button>
|
|
||||||
<a class="btn primary" href="/" rel="nofollow">Home</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="container">
|
|
||||||
<section class="card error-card">
|
|
||||||
<div class="error-hero">
|
|
||||||
<div class="error-illustration" aria-hidden="true">⚠️</div>
|
|
||||||
|
|
||||||
<div class="error-main">
|
|
||||||
<div class="status-badge">Error {{ error.code or 500 }}</div>
|
|
||||||
<h1 class="error-title">
|
|
||||||
{% if (error.code or 500) == 400 %}Bad request
|
|
||||||
{% elif (error.code or 500) == 403 %}Forbidden
|
|
||||||
{% elif (error.code or 500) == 404 %}Not found
|
|
||||||
{% elif (error.code or 500) == 413 %}Payload too large
|
|
||||||
{% elif (error.code or 500) == 415 %}Nieobsł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 %}
|
|
||||||
</h1>
|
|
||||||
<p class="muted">{{ (error.description|string)|e }}</p>
|
|
||||||
|
|
||||||
<div class="error-actions">
|
|
||||||
<button class="btn" type="button" data-action="try-again">Try again</button>
|
|
||||||
<a class="btn outline" href="/" rel="nofollow">Go home</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<details class="error-details">
|
|
||||||
<summary>
|
|
||||||
<span class="summary-title">Error details</span>
|
|
||||||
<span class="summary-hint">click to expand</span>
|
|
||||||
</summary>
|
|
||||||
<div class="details-body">
|
|
||||||
<pre id="error-dump" class="mono">
|
|
||||||
code: {{ error.code or 500 }}
|
|
||||||
message: {{ (error.description|string) }}
|
|
||||||
path: {{ request.path if request else '/' }}
|
|
||||||
method: {{ request.method if request else 'GET' }}
|
|
||||||
user_ip: {{ request.remote_addr if request else '' }}
|
|
||||||
user_agent: {{ request.headers.get('User-Agent') if request else '' }}
|
|
||||||
</pre>
|
|
||||||
<div class="details-actions">
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="site-footer">
|
|
||||||
<div>© 2025 <a href="https://www.linuxiarz.pl" target="_blank" rel="noopener">linuxiarz.pl</a></div>
|
|
||||||
<div class="meta">Your IP: <strong>{{ request.remote_addr if request else '' }}</strong></div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<div id="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
|
||||||
|
|
||||||
<script defer src="{{ url_for('static', filename='js/main.js') }}"></script>
|
|
||||||
<script defer src="{{ url_for('static', filename='js/error.js') }}"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
+106
-151
@@ -1,156 +1,111 @@
|
|||||||
<!DOCTYPE html>
|
{% extends 'base.html' %}
|
||||||
<html lang="en" data-theme="dark">
|
{% set page = 'converter' %}
|
||||||
|
{% block title %}Converter{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-intro">
|
||||||
|
<div>
|
||||||
|
<div class="eyebrow"><i class="signal" aria-hidden="true"></i> THE CONVERSION WORKSPACE</div>
|
||||||
|
<h1>Blocklists, <em>made compatible.</em></h1>
|
||||||
|
<p class="lead">Turn public hosts, DNS and adblock sources into MikroTik-compatible lists. One source URL. One output link. Ready for your DNS setup.</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-identity" aria-hidden="true"><span>DNS</span><span class="hero-line"></span><strong>LIST</strong><small>/CONVERT</small></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<head>
|
<section class="workspace-grid" aria-label="Conversion tools">
|
||||||
<meta charset="UTF-8" />
|
<article class="card generator-card" id="generator">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<header class="card-heading">
|
||||||
<title>Hosts Converter</title>
|
<div><span class="small-label">01 / CONFIGURATION</span><h2>Link generator</h2></div>
|
||||||
<meta name="theme-color" content="#0f1115" />
|
<span class="pill live"><i class="signal" aria-hidden="true"></i> READY</span>
|
||||||
<link rel="preload" href="{{ url_for('static', filename='css/main.css') }}" as="style">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<header class="site-header">
|
|
||||||
<div class="brand">
|
|
||||||
<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
|
|
||||||
<path d="M4 4h16v4H4zM4 10h10v4H4zM4 16h16v4H4z" fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
<span>Hosts Converter</span>
|
|
||||||
</div>
|
|
||||||
<nav class="actions">
|
|
||||||
<button class="btn ghost" type="button" data-action="toggle-theme" aria-label="Toggle theme">🌓</button>
|
|
||||||
<a class="btn primary" href="https://www.linuxiarz.pl" target="_blank" rel="noopener">linuxiarz.pl</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
</header>
|
||||||
|
<form id="converter-form" novalidate>
|
||||||
|
<div class="field">
|
||||||
|
<label for="url-input">Source URL <span class="required">*</span></label>
|
||||||
|
<div class="input-wrap url-field">
|
||||||
|
<span class="input-prefix" aria-hidden="true">↗</span>
|
||||||
|
<input type="url" id="url-input" name="url" placeholder="https://example.com/blocklist.txt" inputmode="url" autocomplete="url" spellcheck="false" aria-describedby="url-help url-error" required>
|
||||||
|
</div>
|
||||||
|
<div class="field-hint" id="url-help">Paste a direct link to a plain-text hosts or adblock list.</div>
|
||||||
|
<p class="field-error" id="url-error" role="alert" hidden></p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<div class="label-row"><label for="ip-input">Target IP <span class="required">*</span></label><span class="fine">IPv4 / IPv6 supported</span></div>
|
||||||
|
<div class="preset-row" role="group" aria-label="Target IP presets">
|
||||||
|
<button type="button" class="preset" data-ip="0.0.0.0">0.0.0.0 <small>BLACKHOLE</small></button>
|
||||||
|
<button type="button" class="preset" data-ip="127.0.0.1">127.0.0.1 <small>LOOPBACK</small></button>
|
||||||
|
<button type="button" class="preset" data-ip="195.187.6.34">195.187.6.34 <small>DEFAULT</small></button>
|
||||||
|
</div>
|
||||||
|
<input id="ip-input" name="ip" type="text" value="195.187.6.34" inputmode="text" autocomplete="off" spellcheck="false" aria-describedby="ip-error" required>
|
||||||
|
<p class="field-error" id="ip-error" role="alert" hidden></p>
|
||||||
|
</div>
|
||||||
|
<div class="form-controls">
|
||||||
|
<div class="form-status"><i class="signal" aria-hidden="true"></i><span id="validation-status">Waiting for a source URL</span></div>
|
||||||
|
<button type="button" class="quiet" data-action="clear">Reset fields</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
<main class="container">
|
<aside class="side-column">
|
||||||
<section class="card hero">
|
<section class="card guide-card" id="guide">
|
||||||
<div class="hero-text">
|
<div class="card-heading"><div><span class="small-label">02 / WORKFLOW</span><h2>How it works</h2></div></div>
|
||||||
<h1>Convert adblock lists to MikroTik / hosts</h1>
|
<div class="workflow-step"><span class="step-num">01</span><div><strong>Choose a source</strong><p>Add a public URL to a supported list.</p></div></div>
|
||||||
<p class="muted">Paste a list URL (AdGuard/uBlock/hosts/dnsmasq), pick the target IP, and generate a
|
<div class="workflow-step"><span class="step-num">02</span><div><strong>Set your target</strong><p>Select the IP used for blocked domains.</p></div></div>
|
||||||
<kbd>/convert</kbd> link.
|
<div class="workflow-step"><span class="step-num">03</span><div><strong>Copy and deploy</strong><p>Use the generated address in IP → DNS → Adlist.</p></div></div>
|
||||||
</p>
|
</section>
|
||||||
</div>
|
<section class="card support-card">
|
||||||
<div class="hero-cta">
|
<header class="card-heading"><div><span class="small-label">INPUT / COMPATIBILITY</span><h2>Supported formats</h2></div><span class="pill">TEXT</span></header>
|
||||||
<a class="btn primary large" href="#form">Start</a>
|
<div class="format-list"><span>Hosts</span><span>AdGuard / uBlock</span><span>dnsmasq</span><span>Plain domains</span></div>
|
||||||
</div>
|
<p class="fine">The source is checked before the response is delivered. Invalid lists return an English error page.</p>
|
||||||
</section>
|
</section>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="form" class="card form-card" aria-labelledby="form-title">
|
<section class="card result-card" id="result" aria-labelledby="result-heading">
|
||||||
<h2 id="form-title">Link generator</h2>
|
<header class="card-heading">
|
||||||
|
<div><span class="small-label">03 / GENERATED OUTPUT</span><h2 id="result-heading">Your conversion endpoint</h2></div>
|
||||||
|
<span class="pill" id="link-state">AWAITING INPUT</span>
|
||||||
|
</header>
|
||||||
|
<p class="fine result-intro">The link updates as you type. Generating the address does not download the source; conversion runs when the endpoint is requested.</p>
|
||||||
|
<div class="output-row">
|
||||||
|
<div class="output-field"><span class="output-mark">GET</span><input id="generated-link" type="text" readonly aria-label="Generated conversion link" placeholder="Enter a valid source URL to generate your link"></div>
|
||||||
|
<div class="output-actions">
|
||||||
|
<button type="button" class="primary" data-action="copy" data-target="#generated-link" id="copy-btn" disabled>
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="1"/><path d="M16 8V4H4v12h4"/></svg> Copy link
|
||||||
|
</button>
|
||||||
|
<a class="outline" id="open-link" href="#" target="_blank" rel="noopener noreferrer" aria-disabled="true" tabindex="-1">Open <span aria-hidden="true">↗</span></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="result-footer"><span class="success-note"><i class="signal" aria-hidden="true"></i> VALID RESULTS ARE CACHEABLE</span><span>Shared cache: up to {{ cache_days }} days (proxy configuration required)</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<form method="GET" action="/" novalidate>
|
<section class="card recent-card" id="recent-links" aria-labelledby="recent-heading">
|
||||||
<div class="grid">
|
<header class="card-heading card-heading-responsive">
|
||||||
<div class="form-group col-12">
|
<div><span class="small-label">04 / RECENT ACTIVITY</span><h2 id="recent-heading">Recently converted sources <span class="count">{{ recent_links|length }}</span></h2></div>
|
||||||
<label for="url-input">URL to hosts/adblock list</label>
|
<div class="head-actions"><label class="sr-only" for="recent-filter">Filter recent sources</label><input id="recent-filter" type="search" placeholder="Search sources or IPs…" data-filter="#recent-table" autocomplete="off"></div>
|
||||||
<input id="url-input" type="url" name="url" required placeholder="https://example.com/list.txt"
|
</header>
|
||||||
inputmode="url" autocomplete="url" aria-describedby="url-help" autofocus>
|
<div class="table-scroll">
|
||||||
<small id="url-help" class="hint"> Supported formats: <code>||domain^</code>,
|
<table class="data-table" id="recent-table">
|
||||||
<code>address=/domain/</code>, hosts files, plain domains.</small>
|
<thead><tr><th>Source</th><th>Target IP</th><th>Last used</th><th class="actions-cell">Actions</th></tr></thead>
|
||||||
<div class="error" data-error-for="url-input"></div>
|
<tbody>
|
||||||
</div>
|
{% for item in recent_links %}
|
||||||
|
<tr {% if loop.index > 8 %}hidden data-extra-row="true"{% endif %}>
|
||||||
<div class="form-group col-6">
|
<td class="source-cell"><span class="source-glyph" aria-hidden="true">↗</span><a href="{{ url_for('convert',url=item[1],ip=item[2]) }}" target="_blank" rel="noopener noreferrer" title="{{ item[1] }}">{{ item[1] }}</a></td>
|
||||||
<label for="ip-input">Target IP</label>
|
<td class="mono"><span class="ip-label">{{ item[2] }}</span></td>
|
||||||
<input id="ip-input" type="text" name="ip" value="195.187.6.34" required inputmode="text"
|
<td class="mono subdued nowrap">{{ item[0]|datetimeformat }}</td>
|
||||||
autocomplete="off" aria-describedby="ip-help" spellcheck="false">
|
<td class="row-actions">
|
||||||
<small id="ip-help" class="hint">Common choices: <code>0.0.0.0</code>, <code>127.0.0.1</code>,
|
<button type="button" class="quiet tiny" data-action="use-source" data-url="{{ item[1] }}" data-ip="{{ item[2] }}">Use</button>
|
||||||
your device IP, supports IPv4 and IPv6.</small>
|
<button type="button" class="quiet tiny" data-action="copy-text" data-text="{{ url_for('convert', url=item[1], ip=item[2]) }}">Copy</button>
|
||||||
<div class="error" data-error-for="ip-input"></div>
|
<a class="quiet tiny" href="{{ url_for('convert',url=item[1],ip=item[2]) }}" target="_blank" rel="noopener noreferrer">Open ↗</a>
|
||||||
</div>
|
</td>
|
||||||
|
</tr>
|
||||||
<div class="form-group col-6">
|
{% else %}
|
||||||
<label for="ip-preset">Presets</label>
|
<tr class="empty-row"><td colspan="4" class="empty-table"><span class="empty-title">No recent links yet</span><span>Successful conversions will appear here.</span></td></tr>
|
||||||
<select id="ip-preset" class="select">
|
{% endfor %}
|
||||||
<option value="">— choose preset —</option>
|
</tbody>
|
||||||
<option value="0.0.0.0">0.0.0.0 (blackhole)</option>
|
</table>
|
||||||
<option value="127.0.0.1">127.0.0.1 (localhost)</option>
|
</div>
|
||||||
<option value="195.187.6.34">195.187.6.34 (current)</option>
|
{% if recent_links|length > 8 %}
|
||||||
|
<div class="table-footer"><span class="fine">Showing 8 of {{ recent_links|length }} entries</span><button class="quiet tiny" type="button" data-action="show-all" data-target="#recent-table">Show all entries ↓</button></div>
|
||||||
<option value="custom">Custom…</option>
|
{% endif %}
|
||||||
</select>
|
<p class="fine no-results" id="recent-empty" hidden>No entries match this search.</p>
|
||||||
</div>
|
</section>
|
||||||
|
{% endblock %}
|
||||||
<div class="form-actions col-12">
|
|
||||||
<button class="btn ghost" type="button" data-action="clear">Clear</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="result-box" data-state="empty" aria-live="polite" aria-atomic="true">
|
|
||||||
<div class="result-row">
|
|
||||||
<input id="generated-link" type="text" value="{{ generated_link or '' }}" readonly
|
|
||||||
placeholder="Link will appear here…">
|
|
||||||
<div class="result-buttons">
|
|
||||||
|
|
||||||
<button class="btn" type="button" id="copy-btn" data-action="copy"
|
|
||||||
data-target="#generated-link">Copy</button>
|
|
||||||
|
|
||||||
<a class="btn outline" id="open-link" href="{{ generated_link or '#' }}" target="_blank"
|
|
||||||
rel="noopener" aria-disabled="{{ 'false' if generated_link else 'true' }}">Open</a>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<small class="hint">Paste this link in your Mikrotik (IP -> DNS -> Adlist) or other DNS server /
|
|
||||||
ad blocking tool</small>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card recent-card">
|
|
||||||
<div class="section-head">
|
|
||||||
<h2>Recent converts</h2>
|
|
||||||
<div class="head-actions">
|
|
||||||
<button class="btn ghost" type="button" data-action="collapse" aria-expanded="false"
|
|
||||||
aria-controls="recent-list">Expand</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="recent-list" class="recent-list" style="display:none">
|
|
||||||
{% if recent_links %}
|
|
||||||
{% for link_data in recent_links %}
|
|
||||||
<article class="link-item">
|
|
||||||
<div class="link-main">
|
|
||||||
<a class="mono ellipsis" title="{{ link_data[1] }}"
|
|
||||||
href="/convert?url={{ link_data[1]|urlencode }}&ip={{ link_data[2] }}" target="_blank"
|
|
||||||
rel="noopener">
|
|
||||||
{{ link_data[1] }}
|
|
||||||
</a>
|
|
||||||
<span class="arrow">→</span>
|
|
||||||
<span class="mono ellipsis" title="{{ link_data[2] }}">{{ link_data[2] }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="link-meta">
|
|
||||||
<span class="timestamp">{{ link_data[0]|datetimeformat }}</span>
|
|
||||||
<div class="link-actions">
|
|
||||||
<button class="btn tiny" type="button" data-action="copy-text"
|
|
||||||
data-text="{{ url_for('convert', _external=True) }}?url={{ link_data[1]|urlencode }}&ip={{ link_data[2] }}">Copy</button>
|
|
||||||
<a class="btn tiny outline"
|
|
||||||
href="/convert?url={{ link_data[1]|urlencode }}&ip={{ link_data[2] }}" target="_blank"
|
|
||||||
rel="noopener">Open</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<p class="muted">Empty..</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="site-footer">
|
|
||||||
<div>© 2025 <a href="https://www.linuxiarz.pl" target="_blank" rel="noopener">linuxiarz.pl</a> · All rights
|
|
||||||
reserved</div>
|
|
||||||
<div class="meta">Your IP: <strong>{{ client_ip }}</strong> · UA: <strong>{{ user_agent }}</strong></div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<div id="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
|
||||||
|
|
||||||
<script defer src="{{ url_for('static', filename='js/main.js') }}"></script>
|
|
||||||
<script>
|
|
||||||
// no-flash theme bootstrap
|
|
||||||
(function () { const t = localStorage.getItem('theme') || 'dark'; document.documentElement.setAttribute('data-theme', t); })();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
+81
-216
@@ -1,216 +1,81 @@
|
|||||||
<!DOCTYPE html>
|
{% extends 'base.html' %}
|
||||||
<html lang="en" data-theme="dark">
|
{% set page = 'stats' %}
|
||||||
|
{% block title %}Statistics{% endblock %}
|
||||||
<head>
|
{% block content %}
|
||||||
<meta charset="UTF-8">
|
<section class="page-intro stats-intro">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<div><div class="eyebrow"><i class="signal" aria-hidden="true"></i> REDIS COUNTERS / OBSERVABILITY</div>
|
||||||
<title>Stats</title>
|
<h1>Every request, <em>in focus.</em></h1>
|
||||||
<meta name="theme-color" content="#0f1115">
|
<p class="lead">Conversion traffic, output sizes, client activity and recent requests. Metrics reflect traffic reaching this application; proxy cache hits do not execute the converter.</p></div>
|
||||||
<link rel="preload" href="{{ url_for('static', filename='css/main.css') }}" as="style">
|
<div class="intro-action"><a class="outline" href="{{ url_for('stats_json') }}" target="_blank" rel="noopener noreferrer">Open JSON API ↗</a></div>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
|
</section>
|
||||||
</head>
|
{% set error4xx = (stats.get('stats:errors_400',0)|int + stats.get('stats:errors_403',0)|int + stats.get('stats:errors_404',0)|int + stats.get('stats:errors_413',0)|int + stats.get('stats:errors_415',0)|int + stats.get('stats:errors_422',0)|int + stats.get('stats:errors_429',0)|int) %}
|
||||||
|
{% set error5xx = (stats.get('stats:errors_500',0)|int + stats.get('stats:errors_502',0)|int) %}
|
||||||
<body>
|
<section class="stats-grid" aria-label="Overview metrics">
|
||||||
<header class="site-header">
|
<article class="stat"><span>Convert requests</span><strong>{{ stats.get('stats:convert_requests',0) }}</strong><small>Reached backend</small></article>
|
||||||
<div class="brand">
|
<article class="stat"><span>Successful conversions</span><strong>{{ stats.get('stats:conversions_success',0) }}</strong><small>Validated responses</small></article>
|
||||||
<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
|
<article class="stat"><span>4xx / 5xx errors</span><strong>{{ error4xx + error5xx }}</strong><small>{{ error4xx }} client / {{ error5xx }} server</small></article>
|
||||||
<path d="M4 4h16v4H4zM4 10h10v4H4zM4 16h16v4H4z" fill="currentColor" />
|
<article class="stat"><span>Avg. processing</span><strong>{{ '%.3f'|format(detailed.processing_time_avg_sec) }}<small class="unit">s</small></strong><small>Requests served by backend</small></article>
|
||||||
</svg>
|
</section>
|
||||||
<span>Hosts Converter</span>
|
<section class="stats-secondary" aria-label="Performance metrics">
|
||||||
</div>
|
<div><span>AVERAGE OUTPUT</span><strong>{{ '{:,.0f}'.format(detailed.content_size_avg_bytes) }} B</strong></div>
|
||||||
<nav class="actions">
|
<div><span>TOTAL OUTPUT</span><strong>{{ '{:,.0f}'.format(detailed.content_size_total_bytes) }} B</strong></div>
|
||||||
<button class="btn ghost" type="button" data-action="toggle-theme" aria-label="Toggle theme">🌓</button>
|
<div><span>FASTEST REQUEST</span><strong>{{ '%.3f'|format(detailed.processing_time_min_sec) }} s</strong></div>
|
||||||
<a class="btn ghost" href="/" rel="nofollow">Home</a>
|
<div><span>SLOWEST REQUEST</span><strong>{{ '%.3f'|format(detailed.processing_time_max_sec) }} s</strong></div>
|
||||||
</nav>
|
</section>
|
||||||
</header>
|
<section class="card" id="activity">
|
||||||
|
<header class="card-heading card-heading-responsive"><div><span class="small-label">01 / TRAFFIC</span><h2>Recent conversion requests <span class="count">{{ recent|length }}</span></h2></div>
|
||||||
<main class="container container--wide">
|
<div class="head-actions"><label class="sr-only" for="stats-filter">Filter requests</label><input id="stats-filter" type="search" data-filter="#activity-table" placeholder="Search traffic…"></div></header>
|
||||||
<!-- KPIs -->
|
<div class="table-scroll"><table class="data-table" id="activity-table">
|
||||||
<section class="card kpi-card">
|
<thead><tr><th>Time</th><th>Source</th><th>Target</th><th>Client</th><th>User agent</th><th>Open</th></tr></thead>
|
||||||
<h2 class="section-title">Overview</h2>
|
<tbody>
|
||||||
<div class="kpi-grid">
|
{% for row in recent %}
|
||||||
<div class="kpi">
|
<tr>
|
||||||
<div class="kpi-label">Convert requests</div>
|
<td class="mono subdued nowrap">{{ row.get('time','')|datetimeformat }}</td>
|
||||||
<div class="kpi-value">{{ stats.get('stats:convert_requests', 0) }}</div>
|
<td class="source-cell"><span class="source-glyph" aria-hidden="true">↗</span><span class="source-ellipsis" title="{{ row.get('source_url','') }}">{{ row.get('source_url','') or 'Unknown' }}</span></td>
|
||||||
</div>
|
<td class="mono"><span class="ip-label">{{ row.get('target_ip','') }}</span></td>
|
||||||
<div class="kpi">
|
<td class="mono" title="{{ row.get('hostname','') }} ({{ row.get('ip','') }})">{{ row.get('ip','') }}</td>
|
||||||
<div class="kpi-label">Successful conversions</div>
|
<td title="{{ row.get('user_agent','') }}"><span class="source-ellipsis">{{ row.get('user_agent','') }}</span></td>
|
||||||
<div class="kpi-value">{{ stats.get('stats:conversions_success', 0) }}</div>
|
<td>{% if row.get('source_url') %}<a class="quiet tiny" href="{{ url_for('convert',url=row.get('source_url'),ip=row.get('target_ip')) }}" target="_blank" rel="noopener noreferrer">Open ↗</a>{% else %}—{% endif %}</td>
|
||||||
</div>
|
</tr>
|
||||||
<div class="kpi">
|
{% else %}
|
||||||
<div class="kpi-label">Errors 4xx</div>
|
<tr class="empty-row"><td colspan="6" class="empty-table"><span class="empty-title">No requests recorded</span><span>Recent conversion requests will appear here.</span></td></tr>
|
||||||
<div class="kpi-value">{{ stats.get('stats:errors_400', 0) }}</div>
|
{% endfor %}
|
||||||
</div>
|
</tbody>
|
||||||
<div class="kpi">
|
</table></div>
|
||||||
<div class="kpi-label">Errors 5xx</div>
|
<p class="fine no-results" data-empty-for="#activity-table" hidden>No matching records.</p>
|
||||||
<div class="kpi-value">{{ stats.get('stats:errors_500', 0) }}</div>
|
</section>
|
||||||
</div>
|
<section class="table-card-grid" id="top-sources">
|
||||||
<div class="kpi">
|
<article class="card"><header class="card-heading"><div><span class="small-label">02 / LEADERBOARD</span><h2>Top sources</h2></div><span class="pill">URL</span></header>
|
||||||
<div class="kpi-label">Avg processing (s)</div>
|
<div class="table-scroll"><table class="data-table"><thead><tr><th>Source address</th><th class="right">Requests</th></tr></thead><tbody>
|
||||||
<div class="kpi-value">{{ '%.3f' % detailed.processing_time_avg_sec }}</div>
|
{% for url, total in url_requests.items()|sort(attribute=1,reverse=True) %}
|
||||||
</div>
|
<tr><td class="source-cell"><span class="source-ellipsis" title="{{ url }}">{{ url }}</span></td><td class="right mono">{{ total }}</td></tr>
|
||||||
<div class="kpi">
|
{% else %}<tr><td colspan="2" class="empty-table">No sources recorded.</td></tr>{% endfor %}
|
||||||
<div class="kpi-label">Avg content (bytes)</div>
|
</tbody></table></div>
|
||||||
<div class="kpi-value">{{ detailed.content_size_avg_bytes }}</div>
|
</article>
|
||||||
</div>
|
<article class="card"><header class="card-heading"><div><span class="small-label">03 / LEADERBOARD</span><h2>Target addresses</h2></div><span class="pill">IP</span></header>
|
||||||
</div>
|
<div class="table-scroll"><table class="data-table"><thead><tr><th>Target IP</th><th class="right">Requests</th></tr></thead><tbody>
|
||||||
</section>
|
{% for ip, total in target_ips.items()|sort(attribute=1,reverse=True) %}
|
||||||
|
<tr><td class="mono"><span class="ip-label">{{ ip }}</span></td><td class="right mono">{{ total }}</td></tr>
|
||||||
<!-- Recent converts -->
|
{% else %}<tr><td colspan="2" class="empty-table">No targets recorded.</td></tr>{% endfor %}
|
||||||
<section class="card">
|
</tbody></table></div>
|
||||||
<div class="section-head">
|
</article>
|
||||||
<h2>Recent converts (latest {{ recent|length }})</h2>
|
<article class="card"><header class="card-heading"><div><span class="small-label">04 / CLIENTS</span><h2>Top client IPs</h2></div></header>
|
||||||
<div class="head-actions">
|
<div class="table-scroll"><table class="data-table"><thead><tr><th>Client IP</th><th class="right">Requests</th></tr></thead><tbody>
|
||||||
<input class="table-filter" type="search" placeholder="Filter…" data-action="filter-table"
|
{% for ip, total in client_ips.items()|sort(attribute=1,reverse=True) %}
|
||||||
data-target="#recent-table">
|
<tr><td class="mono"><span class="source-ellipsis">{{ ip }}</span></td><td class="right mono">{{ total }}</td></tr>
|
||||||
</div>
|
{% else %}<tr><td colspan="2" class="empty-table">No clients recorded.</td></tr>{% endfor %}
|
||||||
</div>
|
</tbody></table></div>
|
||||||
<div class="table-wrap">
|
</article>
|
||||||
<table id="recent-table" class="data-table">
|
<article class="card"><header class="card-heading"><div><span class="small-label">05 / CLIENTS</span><h2>User agents</h2></div></header>
|
||||||
<thead>
|
<div class="table-scroll"><table class="data-table"><thead><tr><th>User agent</th><th class="right">Requests</th></tr></thead><tbody>
|
||||||
<tr>
|
{% for ua, total in user_agents.items()|sort(attribute=1,reverse=True) %}
|
||||||
<th>Time</th>
|
<tr><td><span class="source-ellipsis" title="{{ ua }}">{{ ua }}</span></td><td class="right mono">{{ total }}</td></tr>
|
||||||
<th>URL</th>
|
{% else %}<tr><td colspan="2" class="empty-table">No user agents recorded.</td></tr>{% endfor %}
|
||||||
<th>Target IP</th>
|
</tbody></table></div>
|
||||||
<th>Client</th>
|
</article>
|
||||||
<th>User agent</th>
|
</section>
|
||||||
<th></th>
|
<details class="card json-details"><summary><span><span class="small-label">06 / INSPECTION</span><strong>Raw JSON snapshot</strong></span><span class="fine">Expand <span aria-hidden="true">↓</span></span></summary>
|
||||||
</tr>
|
<div class="json-actions"><p class="fine">This is application-side activity only. Requests served from RustProxy or Varnish cache are not counted here.</p><button type="button" class="quiet tiny" data-action="copy" data-target="#raw-json">Copy JSON</button></div>
|
||||||
</thead>
|
<pre class="raw-json" id="raw-json">{{ raw_json }}</pre>
|
||||||
<tbody>
|
</details>
|
||||||
{% for row in recent %}
|
{% endblock %}
|
||||||
{% set q = row.get('url','') %}
|
|
||||||
{% set parts = q.split('&ip=') %}
|
|
||||||
{% set url = parts[0].replace('/convert?url=', '') | urlencode %}
|
|
||||||
{% set ip = (parts[1] if parts|length > 1 else '') %}
|
|
||||||
<tr>
|
|
||||||
<td class="mono nowrap">{{ row.time|datetimeformat }}</td>
|
|
||||||
<td class="mono ellipsis" title="{{ url|safe }}">
|
|
||||||
{{ url|safe }}
|
|
||||||
</td>
|
|
||||||
<td class="mono">{{ ip }}</td>
|
|
||||||
<td class="mono ellipsis" title="{{ row.hostname }} ({{ row.ip }})">
|
|
||||||
{{ row.hostname }} ({{ row.ip }})
|
|
||||||
</td>
|
|
||||||
<td class="ellipsis" title="{{ row.user_agent }}">{{ row.user_agent }}</td>
|
|
||||||
<td class="actions">
|
|
||||||
<a class="btn tiny outline" href="{{ q }}" target="_blank" rel="noopener">Open</a>
|
|
||||||
<button class="btn tiny" data-action="copy-text" data-text="{{ q }}">Copy</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Top tables -->
|
|
||||||
<section class="card">
|
|
||||||
<div class="section-head">
|
|
||||||
<h2>Top sources</h2>
|
|
||||||
</div>
|
|
||||||
<div class="grid">
|
|
||||||
<div class="col-6">
|
|
||||||
<h3 class="subhead">Source URLs</h3>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table class="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>URL</th>
|
|
||||||
<th class="right">Hits</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for u, c in url_requests.items()|sort(attribute=1, reverse=True) %}
|
|
||||||
<tr>
|
|
||||||
<td class="mono ellipsis" title="{{ u }}">{{ u }}</td>
|
|
||||||
<td class="right">{{ c }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-6">
|
|
||||||
<h3 class="subhead">Target IPs</h3>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table class="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>IP</th>
|
|
||||||
<th class="right">Hits</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for ip, c in target_ips.items()|sort(attribute=1, reverse=True) %}
|
|
||||||
<tr>
|
|
||||||
<td class="mono">{{ ip }}</td>
|
|
||||||
<td class="right">{{ c }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-6">
|
|
||||||
<h3 class="subhead">User agents</h3>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table class="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>User agent</th>
|
|
||||||
<th class="right">Hits</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for ua, c in user_agents.items()|sort(attribute=1, reverse=True) %}
|
|
||||||
<tr>
|
|
||||||
<td class="ellipsis" title="{{ ua }}">{{ ua }}</td>
|
|
||||||
<td class="right">{{ c }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-6">
|
|
||||||
<h3 class="subhead">Client IPs</h3>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table class="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Client IP</th>
|
|
||||||
<th class="right">Hits</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for ip, c in client_ips.items()|sort(attribute=1, reverse=True) %}
|
|
||||||
<tr>
|
|
||||||
<td class="mono">{{ ip }}</td>
|
|
||||||
<td class="right">{{ c }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="site-footer">
|
|
||||||
<div>© 2025 <a href="https://www.linuxiarz.pl" target="_blank" rel="noopener">linuxiarz.pl</a></div>
|
|
||||||
<div class="meta">Your IP: <strong>{{ request.remote_addr }}</strong></div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<div id="toast" role="status" aria-live="polite" aria-atomic="true"></div>
|
|
||||||
<script defer src="{{ url_for('static', filename='js/main.js') }}"></script>
|
|
||||||
<script defer src="{{ url_for('static', filename='js/stats.js') }}"></script>
|
|
||||||
<script>(function () { const t = localStorage.getItem('theme') || 'dark'; document.documentElement.setAttribute('data-theme', t); })();</script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"""Standalone /convert smoke checks without third-party packages.
|
||||||
|
|
||||||
|
Run: python3 tests/smoke_cache_routes.py
|
||||||
|
These are stubs, not a substitute for an installed Flask/Gunicorn integration test.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPError(Exception):
|
||||||
|
def __init__(self, code, description=None):
|
||||||
|
self.code = code
|
||||||
|
self.description = description
|
||||||
|
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
def __init__(self, data=None, content_type=None, status=200, mimetype=None):
|
||||||
|
self.data = data
|
||||||
|
self.status_code = status
|
||||||
|
self.headers = {}
|
||||||
|
self.direct_passthrough = False
|
||||||
|
self.close_callbacks = []
|
||||||
|
if content_type:
|
||||||
|
self.headers["Content-Type"] = content_type
|
||||||
|
|
||||||
|
def call_on_close(self, callback):
|
||||||
|
self.close_callbacks.append(callback)
|
||||||
|
|
||||||
|
def make_conditional(self, request):
|
||||||
|
if request.headers.get("If-None-Match") == self.headers.get("ETag"):
|
||||||
|
self.status_code = 304
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class Flask:
|
||||||
|
def __init__(self, *args):
|
||||||
|
self.config = {}
|
||||||
|
self.debug = False
|
||||||
|
self.logger = logging.getLogger("adlist-test")
|
||||||
|
|
||||||
|
def route(self, *args, **kwargs):
|
||||||
|
return lambda func: func
|
||||||
|
|
||||||
|
template_filter = route
|
||||||
|
before_request = lambda self, fn: fn
|
||||||
|
after_request = lambda self, fn: fn
|
||||||
|
errorhandler = route
|
||||||
|
|
||||||
|
|
||||||
|
def abort(code, description=None):
|
||||||
|
raise HTTPError(code, description)
|
||||||
|
|
||||||
|
|
||||||
|
flask = types.ModuleType("flask")
|
||||||
|
flask.Flask = Flask
|
||||||
|
flask.Response = Response
|
||||||
|
flask.abort = abort
|
||||||
|
flask.render_template = lambda *args, **kwargs: "error-page"
|
||||||
|
flask.jsonify = lambda d: d
|
||||||
|
flask.g = types.SimpleNamespace()
|
||||||
|
flask.request = types.SimpleNamespace(
|
||||||
|
args={}, headers={}, method="GET", remote_addr="127.0.0.1",
|
||||||
|
full_path="/convert", path="/convert",
|
||||||
|
)
|
||||||
|
sys.modules["flask"] = flask
|
||||||
|
|
||||||
|
compress = types.ModuleType("flask_compress")
|
||||||
|
compress.Compress = lambda app: None
|
||||||
|
sys.modules["flask_compress"] = compress
|
||||||
|
|
||||||
|
limiter = types.ModuleType("flask_limiter")
|
||||||
|
|
||||||
|
|
||||||
|
class Limiter:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def limit(self, *args):
|
||||||
|
return lambda func: func
|
||||||
|
|
||||||
|
|
||||||
|
limiter.Limiter = Limiter
|
||||||
|
sys.modules["flask_limiter"] = limiter
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRedis:
|
||||||
|
def incr(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def incrby(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def incrbyfloat(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get(self, *args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
redis = types.ModuleType("redis")
|
||||||
|
redis.Redis = types.SimpleNamespace(from_url=lambda *args, **kwargs: FakeRedis())
|
||||||
|
sys.modules["redis"] = redis
|
||||||
|
|
||||||
|
asgiref = types.ModuleType("asgiref")
|
||||||
|
asgi_w = types.ModuleType("asgiref.wsgi")
|
||||||
|
asgi_w.WsgiToAsgi = lambda app: app
|
||||||
|
sys.modules["asgiref"] = asgiref
|
||||||
|
sys.modules["asgiref.wsgi"] = asgi_w
|
||||||
|
|
||||||
|
requests = types.ModuleType("requests")
|
||||||
|
|
||||||
|
|
||||||
|
class RequestException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
requests.exceptions = types.SimpleNamespace(RequestException=RequestException)
|
||||||
|
sys.modules["requests"] = requests
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("adlist_cache_test_app", ROOT / "app.py")
|
||||||
|
app = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(app)
|
||||||
|
app.add_recent_convert = lambda: None
|
||||||
|
app.track_url_request = lambda *_: None
|
||||||
|
app.add_recent_link = lambda *_: None
|
||||||
|
|
||||||
|
|
||||||
|
class FakeUpstream:
|
||||||
|
def __init__(self, body, status=200, content_type="text/plain; charset=utf-8",
|
||||||
|
headers=None, fail=False):
|
||||||
|
self.body = body
|
||||||
|
self.status_code = status
|
||||||
|
self.headers = {"Content-Type": content_type, **(headers or {})}
|
||||||
|
self.closed = False
|
||||||
|
self.fail = fail
|
||||||
|
|
||||||
|
def iter_content(self, chunk_size):
|
||||||
|
for i in range(0, len(self.body), chunk_size):
|
||||||
|
yield self.body[i:i+chunk_size]
|
||||||
|
if self.fail:
|
||||||
|
raise RequestException("upstream disconnected")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
called = []
|
||||||
|
source = FakeUpstream(b"0.0.0.0 example.test\n")
|
||||||
|
|
||||||
|
|
||||||
|
def fake_get(*args, **kwargs):
|
||||||
|
called.append((args, kwargs))
|
||||||
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
requests.get = fake_get
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(test, label):
|
||||||
|
global count
|
||||||
|
assert test, label
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
|
||||||
|
def run(method="GET", client_headers=None, args=None):
|
||||||
|
flask.request.method = method
|
||||||
|
flask.request.args = args or {
|
||||||
|
"url": "https://source.test/list.txt", "ip": "0.0.0.0"}
|
||||||
|
flask.request.headers = client_headers or {}
|
||||||
|
flask.g.start_time = 0.0
|
||||||
|
return app.convert()
|
||||||
|
|
||||||
|
|
||||||
|
converted = b"0.0.0.0 example.test\n"
|
||||||
|
content_hash = hashlib.sha256(converted).hexdigest()
|
||||||
|
resp = run()
|
||||||
|
check(resp.status_code == 200, "valid list = 200")
|
||||||
|
check(resp.headers["Cache-Control"] ==
|
||||||
|
"public, max-age=3600, s-maxage=604800, stale-while-revalidate=86400, stale-if-error=604800",
|
||||||
|
"proxy TTL and staleness")
|
||||||
|
check(resp.headers["ETag"] == f'"{content_hash}"', "strong output-body ETag")
|
||||||
|
check(resp.headers["Content-Length"] == str(len(converted)), "content length")
|
||||||
|
check(b"".join(resp.data) == converted, "response bytes")
|
||||||
|
check(source.closed, "upstream closed after success")
|
||||||
|
check("Last-Modified" not in resp.headers, "origin validator is not forwarded")
|
||||||
|
|
||||||
|
source = FakeUpstream(converted, headers={"ETag": '"upstream-pretend"',
|
||||||
|
"Last-Modified": "Mon, 01 Jan 2024 00:00:00 GMT"})
|
||||||
|
resp = run(client_headers={"If-None-Match": f'"{content_hash}"'})
|
||||||
|
check(resp.status_code == 304, "client 304 after full body hash")
|
||||||
|
check("headers" not in called[-1][1], "client validators not forwarded to upstream")
|
||||||
|
check(source.closed, "conditional upstream closed")
|
||||||
|
check(resp.headers["ETag"] == f'"{content_hash}"', "304 keeps content validator")
|
||||||
|
|
||||||
|
source = FakeUpstream(b"0.0.0.0 another.test\n", headers={"ETag": '"upstream-pretend"'})
|
||||||
|
changed = run()
|
||||||
|
check(changed.headers["ETag"] != f'"{content_hash}"',
|
||||||
|
"changed converted content invalidates ETag even if source ETag stays the same")
|
||||||
|
check(b"".join(changed.data) == b"0.0.0.0 another.test\n", "changed content served")
|
||||||
|
|
||||||
|
source = FakeUpstream(converted, headers={"ETag": '"upstream-pretend"'})
|
||||||
|
resp = run(client_headers={"If-None-Match": '"other"'})
|
||||||
|
check(resp.status_code == 200, "wrong client ETag yields 200")
|
||||||
|
check(b"".join(resp.data) == converted, "wrong client ETag still receives body")
|
||||||
|
|
||||||
|
source = FakeUpstream(converted)
|
||||||
|
head = run("HEAD")
|
||||||
|
check(head.status_code == 200, "HEAD status")
|
||||||
|
check(head.data is None, "HEAD no data iterator")
|
||||||
|
check(head.headers["ETag"] == f'"{content_hash}"', "GET/HEAD same ETag")
|
||||||
|
check(head.headers["Content-Length"] == str(len(converted)), "HEAD real content length")
|
||||||
|
|
||||||
|
source = FakeUpstream(converted)
|
||||||
|
other = run(args={"url": "https://source.test/list.txt", "ip": "127.0.0.1"})
|
||||||
|
check(other.headers["ETag"] != resp.headers["ETag"], "different target IP yields different ETag")
|
||||||
|
check(b"127.0.0.1 example.test\n" == b"".join(other.data), "IP body changed")
|
||||||
|
|
||||||
|
source = FakeUpstream(converted)
|
||||||
|
app.config.DEBUG_ENABLE = True
|
||||||
|
app.config.DEBUG_KEY = "not-test-key"
|
||||||
|
debug = run(args={"url": "https://source.test/list.txt", "ip": "0.0.0.0", "debug": "1"})
|
||||||
|
check(debug.headers["Cache-Control"] == "private, no-store", "debug never cached")
|
||||||
|
check("ETag" not in debug.headers, "debug no shared ETag")
|
||||||
|
check(debug.status_code == 200, "debug displays full response")
|
||||||
|
check(debug.headers["X-Debug-Mode"] == "1", "debug diagnostic header")
|
||||||
|
app.config.DEBUG_ENABLE = False
|
||||||
|
|
||||||
|
source = FakeUpstream(b"<html><body>err</body></html>")
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
raise AssertionError("invalid body accepted")
|
||||||
|
except HTTPError as e:
|
||||||
|
check(e.code == 422 and "Unable to decode" in e.description, "unprocessable source English 422")
|
||||||
|
|
||||||
|
source = FakeUpstream(b"<html></html>", content_type="text/html")
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
raise AssertionError("html content type accepted")
|
||||||
|
except HTTPError as e:
|
||||||
|
check(e.code == 415 and "supported" in e.description, "unsupported type English 415")
|
||||||
|
|
||||||
|
source = FakeUpstream(b"not-found", status=404)
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
raise AssertionError("bad upstream status accepted")
|
||||||
|
except HTTPError as e:
|
||||||
|
check(e.code == 502 and "source server" in e.description, "upstream English 502")
|
||||||
|
|
||||||
|
source = FakeUpstream(b"0.0.0.0 example.test\n", fail=True)
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
raise AssertionError("interrupted source accepted")
|
||||||
|
except HTTPError as e:
|
||||||
|
check(e.code == 502 and "interrupted" in e.description, "interrupted download 502")
|
||||||
|
|
||||||
|
r = Response(status=422)
|
||||||
|
r.headers["Cache-Control"] = "public, max-age=90000"
|
||||||
|
for error_code in [400, 403, 413, 415, 422, 429, 500, 502]:
|
||||||
|
error = Response(status=error_code)
|
||||||
|
error.headers["Cache-Control"] = "public, max-age=90000"
|
||||||
|
check(app.finalize_response(error).headers["Cache-Control"] == "no-store",
|
||||||
|
f"HTTP {error_code} must not be cached")
|
||||||
|
|
||||||
|
test_cfg = app.config.CACHE_ENABLED
|
||||||
|
app.config.CACHE_ENABLED = False
|
||||||
|
check(app.cache_headers(content_hash)["Cache-Control"] == "no-store", "cache disabled")
|
||||||
|
app.config.CACHE_ENABLED = test_cfg
|
||||||
|
|
||||||
|
print("Standalone route/cache smoke tests passed:", count)
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
"""Standalone /convert smoke checks without third-party packages.
|
||||||
|
|
||||||
|
Run: python3 tests/smoke_routes.py
|
||||||
|
These are stubs, not a substitute for an installed Flask/Gunicorn integration test.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPError(Exception):
|
||||||
|
def __init__(self, code, description=None):
|
||||||
|
self.code = code
|
||||||
|
self.description = description
|
||||||
|
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
def __init__(self, data=None, content_type=None, status=200, mimetype=None):
|
||||||
|
self.data = data
|
||||||
|
self.status_code = status
|
||||||
|
self.headers = {}
|
||||||
|
self.direct_passthrough = False
|
||||||
|
self.close_callbacks = []
|
||||||
|
if content_type:
|
||||||
|
self.headers["Content-Type"] = content_type
|
||||||
|
|
||||||
|
def call_on_close(self, callback):
|
||||||
|
self.close_callbacks.append(callback)
|
||||||
|
|
||||||
|
def make_conditional(self, request):
|
||||||
|
if request.headers.get("If-None-Match") == self.headers.get("ETag"):
|
||||||
|
self.status_code = 304
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class Flask:
|
||||||
|
def __init__(self, *args):
|
||||||
|
self.config = {}
|
||||||
|
self.debug = False
|
||||||
|
self.logger = logging.getLogger("adlist-test")
|
||||||
|
|
||||||
|
def route(self, *args, **kwargs):
|
||||||
|
return lambda func: func
|
||||||
|
|
||||||
|
template_filter = route
|
||||||
|
before_request = lambda self, fn: fn
|
||||||
|
after_request = lambda self, fn: fn
|
||||||
|
errorhandler = route
|
||||||
|
add_url_rule = lambda self, *args, **kwargs: None
|
||||||
|
add_template_filter = lambda self, *args, **kwargs: None
|
||||||
|
register_error_handler = lambda self, *args, **kwargs: None
|
||||||
|
context_processor = lambda self, fn: fn
|
||||||
|
|
||||||
|
|
||||||
|
def abort(code, description=None):
|
||||||
|
raise HTTPError(code, description)
|
||||||
|
|
||||||
|
|
||||||
|
flask = types.ModuleType("flask")
|
||||||
|
flask.Flask = Flask
|
||||||
|
flask.Response = Response
|
||||||
|
flask.abort = abort
|
||||||
|
flask.render_template = lambda *args, **kwargs: "error-page"
|
||||||
|
flask.jsonify = lambda d: d
|
||||||
|
flask.g = types.SimpleNamespace()
|
||||||
|
flask.current_app = types.SimpleNamespace(logger=logging.getLogger("adlist-test"))
|
||||||
|
flask.request = types.SimpleNamespace(
|
||||||
|
args={}, headers={}, method="GET", remote_addr="127.0.0.1",
|
||||||
|
full_path="/convert", path="/convert",
|
||||||
|
)
|
||||||
|
sys.modules["flask"] = flask
|
||||||
|
|
||||||
|
compress = types.ModuleType("flask_compress")
|
||||||
|
compress.Compress = lambda app: None
|
||||||
|
sys.modules["flask_compress"] = compress
|
||||||
|
|
||||||
|
limiter = types.ModuleType("flask_limiter")
|
||||||
|
|
||||||
|
|
||||||
|
class Limiter:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def limit(self, *args):
|
||||||
|
return lambda func: func
|
||||||
|
|
||||||
|
|
||||||
|
limiter.Limiter = Limiter
|
||||||
|
sys.modules["flask_limiter"] = limiter
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRedis:
|
||||||
|
def incr(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def incrby(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def incrbyfloat(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get(self, *args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
redis = types.ModuleType("redis")
|
||||||
|
redis.Redis = types.SimpleNamespace(from_url=lambda *args, **kwargs: FakeRedis())
|
||||||
|
sys.modules["redis"] = redis
|
||||||
|
|
||||||
|
asgiref = types.ModuleType("asgiref")
|
||||||
|
asgi_w = types.ModuleType("asgiref.wsgi")
|
||||||
|
asgi_w.WsgiToAsgi = lambda app: app
|
||||||
|
sys.modules["asgiref"] = asgiref
|
||||||
|
sys.modules["asgiref.wsgi"] = asgi_w
|
||||||
|
|
||||||
|
requests = types.ModuleType("requests")
|
||||||
|
|
||||||
|
|
||||||
|
class RequestException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
requests.exceptions = types.SimpleNamespace(RequestException=RequestException)
|
||||||
|
sys.modules["requests"] = requests
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("adlist_cache_test_app", ROOT / "app.py")
|
||||||
|
app = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(app)
|
||||||
|
app.converter.add_recent_convert = lambda: None
|
||||||
|
app.converter.track_url_request = lambda *_: None
|
||||||
|
app.converter.add_recent_link = lambda *_: None
|
||||||
|
|
||||||
|
|
||||||
|
class FakeUpstream:
|
||||||
|
def __init__(self, body, status=200, content_type="text/plain; charset=utf-8",
|
||||||
|
headers=None, fail=False):
|
||||||
|
self.body = body
|
||||||
|
self.status_code = status
|
||||||
|
self.headers = {"Content-Type": content_type, **(headers or {})}
|
||||||
|
self.closed = False
|
||||||
|
self.fail = fail
|
||||||
|
|
||||||
|
def iter_content(self, chunk_size):
|
||||||
|
for i in range(0, len(self.body), chunk_size):
|
||||||
|
yield self.body[i:i+chunk_size]
|
||||||
|
if self.fail:
|
||||||
|
raise RequestException("upstream disconnected")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
called = []
|
||||||
|
source = FakeUpstream(b"0.0.0.0 example.test\n")
|
||||||
|
|
||||||
|
|
||||||
|
def fake_get(*args, **kwargs):
|
||||||
|
called.append((args, kwargs))
|
||||||
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
requests.get = fake_get
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(test, label):
|
||||||
|
global count
|
||||||
|
assert test, label
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
|
||||||
|
def run(method="GET", client_headers=None, args=None):
|
||||||
|
flask.request.method = method
|
||||||
|
flask.request.args = args or {
|
||||||
|
"url": "https://source.test/list.txt", "ip": "0.0.0.0"}
|
||||||
|
flask.request.headers = client_headers or {}
|
||||||
|
flask.g.start_time = 0.0
|
||||||
|
return app.convert()
|
||||||
|
|
||||||
|
|
||||||
|
converted = b"0.0.0.0 example.test\n"
|
||||||
|
content_hash = hashlib.sha256(converted).hexdigest()
|
||||||
|
resp = run()
|
||||||
|
check(resp.status_code == 200, "valid list = 200")
|
||||||
|
check(resp.headers["Cache-Control"] ==
|
||||||
|
"public, max-age=3600, s-maxage=604800, stale-while-revalidate=86400, stale-if-error=604800",
|
||||||
|
"proxy TTL and staleness")
|
||||||
|
check(resp.headers["ETag"] == f'"{content_hash}"', "strong output-body ETag")
|
||||||
|
check(resp.headers["Content-Length"] == str(len(converted)), "content length")
|
||||||
|
check(b"".join(resp.data) == converted, "response bytes")
|
||||||
|
check(source.closed, "upstream closed after success")
|
||||||
|
check("Last-Modified" not in resp.headers, "origin validator is not forwarded")
|
||||||
|
|
||||||
|
source = FakeUpstream(converted, headers={"ETag": '"upstream-pretend"',
|
||||||
|
"Last-Modified": "Mon, 01 Jan 2024 00:00:00 GMT"})
|
||||||
|
resp = run(client_headers={"If-None-Match": f'"{content_hash}"'})
|
||||||
|
check(resp.status_code == 304, "client 304 after full body hash")
|
||||||
|
check("headers" not in called[-1][1], "client validators not forwarded to upstream")
|
||||||
|
check(source.closed, "conditional upstream closed")
|
||||||
|
check(resp.headers["ETag"] == f'"{content_hash}"', "304 keeps content validator")
|
||||||
|
|
||||||
|
source = FakeUpstream(b"0.0.0.0 another.test\n", headers={"ETag": '"upstream-pretend"'})
|
||||||
|
changed = run()
|
||||||
|
check(changed.headers["ETag"] != f'"{content_hash}"',
|
||||||
|
"changed converted content invalidates ETag even if source ETag stays the same")
|
||||||
|
check(b"".join(changed.data) == b"0.0.0.0 another.test\n", "changed content served")
|
||||||
|
|
||||||
|
source = FakeUpstream(converted, headers={"ETag": '"upstream-pretend"'})
|
||||||
|
resp = run(client_headers={"If-None-Match": '"other"'})
|
||||||
|
check(resp.status_code == 200, "wrong client ETag yields 200")
|
||||||
|
check(b"".join(resp.data) == converted, "wrong client ETag still receives body")
|
||||||
|
|
||||||
|
source = FakeUpstream(converted)
|
||||||
|
head = run("HEAD")
|
||||||
|
check(head.status_code == 200, "HEAD status")
|
||||||
|
check(head.data is None, "HEAD no data iterator")
|
||||||
|
check(head.headers["ETag"] == f'"{content_hash}"', "GET/HEAD same ETag")
|
||||||
|
check(head.headers["Content-Length"] == str(len(converted)), "HEAD real content length")
|
||||||
|
|
||||||
|
source = FakeUpstream(converted)
|
||||||
|
other = run(args={"url": "https://source.test/list.txt", "ip": "127.0.0.1"})
|
||||||
|
check(other.headers["ETag"] != resp.headers["ETag"], "different target IP yields different ETag")
|
||||||
|
check(b"127.0.0.1 example.test\n" == b"".join(other.data), "IP body changed")
|
||||||
|
|
||||||
|
source = FakeUpstream(converted)
|
||||||
|
app.config.DEBUG_ENABLE = True
|
||||||
|
app.config.DEBUG_KEY = "not-test-key"
|
||||||
|
debug = run(args={"url": "https://source.test/list.txt", "ip": "0.0.0.0", "debug": "1"})
|
||||||
|
check(debug.headers["Cache-Control"] == "private, no-store", "debug never cached")
|
||||||
|
check("ETag" not in debug.headers, "debug no shared ETag")
|
||||||
|
check(debug.status_code == 200, "debug displays full response")
|
||||||
|
check(debug.headers["X-Debug-Mode"] == "1", "debug diagnostic header")
|
||||||
|
app.config.DEBUG_ENABLE = False
|
||||||
|
|
||||||
|
source = FakeUpstream(b"<html><body>err</body></html>")
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
raise AssertionError("invalid body accepted")
|
||||||
|
except HTTPError as e:
|
||||||
|
check(e.code == 422 and "Unable to decode" in e.description, "unprocessable source English 422")
|
||||||
|
|
||||||
|
source = FakeUpstream(b"<html></html>", content_type="text/html")
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
raise AssertionError("html content type accepted")
|
||||||
|
except HTTPError as e:
|
||||||
|
check(e.code == 415 and "supported" in e.description, "unsupported type English 415")
|
||||||
|
|
||||||
|
source = FakeUpstream(b"not-found", status=404)
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
raise AssertionError("bad upstream status accepted")
|
||||||
|
except HTTPError as e:
|
||||||
|
check(e.code == 502 and "source server" in e.description, "upstream English 502")
|
||||||
|
|
||||||
|
source = FakeUpstream(b"0.0.0.0 example.test\n", fail=True)
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
raise AssertionError("interrupted source accepted")
|
||||||
|
except HTTPError as e:
|
||||||
|
check(e.code == 502 and "interrupted" in e.description, "interrupted download 502")
|
||||||
|
|
||||||
|
r = Response(status=422)
|
||||||
|
r.headers["Cache-Control"] = "public, max-age=90000"
|
||||||
|
for error_code in [400, 403, 413, 415, 422, 429, 500, 502]:
|
||||||
|
error = Response(status=error_code)
|
||||||
|
error.headers["Cache-Control"] = "public, max-age=90000"
|
||||||
|
check(app.services.finalize_response(error).headers["Cache-Control"] == "no-store",
|
||||||
|
f"HTTP {error_code} must not be cached")
|
||||||
|
|
||||||
|
# Private UI and statistics must never be stored by a shared proxy.
|
||||||
|
for private_path in ["/", "/stats", "/stats.json"]:
|
||||||
|
flask.request.path = private_path
|
||||||
|
private = app.services.finalize_response(Response(status=200))
|
||||||
|
check(private.headers.get("Cache-Control") == "private, no-store", f"private {private_path}")
|
||||||
|
flask.request.path = "/static/css/main.css"
|
||||||
|
flask.request.args = {"v": "asset123"}
|
||||||
|
static = app.services.finalize_response(Response(status=200))
|
||||||
|
check(static.headers.get("Cache-Control") == "public, max-age=31536000, immutable", "versioned CSS caching")
|
||||||
|
flask.request.args = {}
|
||||||
|
static = app.services.finalize_response(Response(status=200))
|
||||||
|
check(static.headers.get("Cache-Control") == "public, max-age=3600", "unversioned CSS expires")
|
||||||
|
flask.request.path = "/convert"
|
||||||
|
|
||||||
|
test_cfg = app.config.CACHE_ENABLED
|
||||||
|
app.config.CACHE_ENABLED = False
|
||||||
|
check(app.cache_headers(content_hash)["Cache-Control"] == "no-store", "cache disabled")
|
||||||
|
app.config.CACHE_ENABLED = test_cfg
|
||||||
|
|
||||||
|
print("Standalone route/cache smoke tests passed:", count)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
import hashlib
|
||||||
|
|
||||||
from source_conversion import (
|
from source_conversion import (
|
||||||
InvalidSourceError,
|
InvalidSourceError,
|
||||||
@@ -58,6 +59,10 @@ class ConvertSourceTests(unittest.TestCase):
|
|||||||
self.assertEqual(b''.join(body_chunks), b'0.0.0.0 first.test\n0.0.0.0 second.test\n')
|
self.assertEqual(b''.join(body_chunks), b'0.0.0.0 first.test\n0.0.0.0 second.test\n')
|
||||||
self.assertTrue(result.output.closed)
|
self.assertTrue(result.output.closed)
|
||||||
self.assertEqual(result.lines_emitted, 2)
|
self.assertEqual(result.lines_emitted, 2)
|
||||||
|
self.assertEqual(
|
||||||
|
result.content_sha256,
|
||||||
|
hashlib.sha256(b'0.0.0.0 first.test\n0.0.0.0 second.test\n').hexdigest(),
|
||||||
|
)
|
||||||
|
|
||||||
def test_octet_stream_without_charset(self):
|
def test_octet_stream_without_charset(self):
|
||||||
result = run_conversion(
|
result = run_conversion(
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import unittest
|
||||||
|
from adlist.cache import cache_headers
|
||||||
|
from adlist.validation import convert_host_line, validate_ip, validate_and_normalize_url
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationAndCacheTests(unittest.TestCase):
|
||||||
|
def test_hosts_format(self):
|
||||||
|
self.assertEqual(convert_host_line('0.0.0.0 ads.example.com', '127.0.0.1'), '127.0.0.1 ads.example.com')
|
||||||
|
|
||||||
|
def test_adguard_format(self):
|
||||||
|
self.assertEqual(convert_host_line('||tracking.example.com^', '0.0.0.0'), '0.0.0.0 tracking.example.com')
|
||||||
|
|
||||||
|
def test_dnsmasq_format(self):
|
||||||
|
self.assertEqual(convert_host_line('address=/block.example.com/0.0.0.0', '0.0.0.0'), '0.0.0.0 block.example.com')
|
||||||
|
|
||||||
|
def test_plain_domains(self):
|
||||||
|
self.assertEqual(convert_host_line('tracking.example.com', '::'), ':: tracking.example.com')
|
||||||
|
|
||||||
|
def test_comments_ignored(self):
|
||||||
|
self.assertIsNone(convert_host_line('# comment', '0.0.0.0'))
|
||||||
|
|
||||||
|
def test_malformed_domains_ignored(self):
|
||||||
|
self.assertIsNone(convert_host_line('a<b.invalid', '0.0.0.0'))
|
||||||
|
|
||||||
|
def test_ipv6_target(self):
|
||||||
|
self.assertEqual(validate_ip('2001:db8::1'), '2001:db8::1')
|
||||||
|
|
||||||
|
def test_invalid_target(self):
|
||||||
|
with self.assertRaises(ValueError): validate_ip('999.999.999.999')
|
||||||
|
|
||||||
|
def test_nested_percent_escapes_preserved(self):
|
||||||
|
nested='https://example.com/a%2Fb?q=a%26b'
|
||||||
|
self.assertEqual(validate_and_normalize_url(nested), nested)
|
||||||
|
|
||||||
|
def test_https_default(self):
|
||||||
|
self.assertEqual(validate_and_normalize_url('example.com/list'), 'https://example.com/list')
|
||||||
|
|
||||||
|
def test_cache_directives(self):
|
||||||
|
headers=cache_headers('f'*64)
|
||||||
|
self.assertIn('s-maxage=604800', headers['Cache-Control'])
|
||||||
|
self.assertIn('stale-if-error=604800', headers['Cache-Control'])
|
||||||
|
self.assertEqual(headers['ETag'],'"'+'f'*64+'"')
|
||||||
|
|
||||||
|
def test_debug_no_store(self):
|
||||||
|
headers=cache_headers('f'*64,debug_mode=True)
|
||||||
|
self.assertEqual(headers['Cache-Control'], 'private, no-store')
|
||||||
|
self.assertNotIn('ETag',headers)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__': unittest.main()
|
||||||
Reference in new issue
Block a user