refactor ciagl dalszy

This commit is contained in:
Mateusz Gruszczyński
2025-08-29 10:46:50 +02:00
parent 01b8ff656e
commit bc45c91d92
15 changed files with 1592 additions and 1492 deletions

View File

@@ -39,3 +39,15 @@ BIND_PORT=8283
# Domyślny URL źródłowy (opcjonalnie)
DEFAULT_SOURCE_URL="https://raw.githubusercontent.com/217heidai/adblockfilters/main/rules/adblockdns.txt"
# Debug /convert
# Włącz/wyłącz tryb debug (domyślnie false)
DEBUG_ENABLE=false
# Tajny klucz do debug (opcjonalny).
# Jeśli pusty: debug tylko z prywatnych adresów (10.x.x.x, 192.168.x.x itp.)
# Jeśli ustawiony: debug dostępny po podaniu nagłówka X-Debug-Key: <sekret>
DEBUG_KEY=supersekretnyklucz
# Limit zapytań debug per-IP (np. 5 per minute)
DEBUG_RATE_LIMIT="5 per minute"

308
app.py
View File

@@ -4,20 +4,22 @@ import requests
import socket
import time
import json
import base64
import hashlib
import 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 Flask, request, render_template, abort, jsonify, stream_with_context, g, Response
from flask_compress import Compress
from flask_limiter import Limiter
import config
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = config.MAX_CONTENT_LENGTH
app.config["SECRET_KEY"] = config.SECRET_KEY
app.debug = config.FLASK_DEBUG
@@ -51,6 +53,19 @@ def track_request_data():
redis_client.incr(f"stats:client_ips:{get_client_ip()}")
redis_client.incr(f"stats:methods:{request.method}")
@app.after_request
def add_cache_headers(response):
if request.path.startswith("/static/"):
response.headers.pop("Content-Disposition", None)
if request.path.endswith((".css", ".js")):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
else:
response.headers["Cache-Control"] = "public, max-age=86400"
return response
@app.after_request
def after_request(response):
elapsed = time.perf_counter() - g.start_time
@@ -84,15 +99,12 @@ def basic_auth_required(realm: str, user: str, password: str):
def wrapper(*args, **kwargs):
if not config.STATS_BASIC_AUTH_ENABLED:
return f(*args, **kwargs)
auth = request.headers.get("Authorization", "")
if auth.startswith("Basic "):
try:
decoded = base64.b64decode(auth[6:]).decode("utf-8", errors="ignore")
u, p = decoded.split(":", 1)
if u == user and p == password:
return f(*args, **kwargs)
except Exception:
pass
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
@@ -111,18 +123,22 @@ def should_ignore_line(line):
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()
# komentarze/puste
if not line or line.startswith(("!", "#", "/", ";")):
return None
# wytnij komentarz końcowy (# lub ;) ostrożnie ze 'http://'
# usuwamy wszystko od ' #' lub ' ;' (spacja przed znacznikiem komentarza)
for sep in (" #", " ;"):
idx = line.find(sep)
if idx != -1:
@@ -131,7 +147,6 @@ def convert_host_line(line: str, target_ip: str):
if not line:
return None
# 1) AdGuard / uBlock DNS: ||domain^ (opcjonalnie z dodatkami po '^')
m = re.match(r"^\|\|([a-z0-9.-]+)\^", line, re.IGNORECASE)
if m:
domain = m.group(1).strip(".")
@@ -141,7 +156,6 @@ def convert_host_line(line: str, target_ip: str):
parts = line.split()
# 2) Klasyczny hosts: "IP domena [...]" (IPv4 lub IPv6)
if len(parts) >= 2 and (
re.match(r"^\d{1,3}(?:\.\d{1,3}){3}$", parts[0]) or ":" in parts[0]
):
@@ -150,7 +164,6 @@ def convert_host_line(line: str, target_ip: str):
return f"{target_ip} {domain}"
return None
# 3) dnsmasq: address=/domain/0.0.0.0 czy server=/domain/...
m = re.match(r"^(?:address|server)=/([a-z0-9.-]+)/", line, re.IGNORECASE)
if m:
domain = m.group(1).strip(".")
@@ -158,7 +171,6 @@ def convert_host_line(line: str, target_ip: str):
return f"{target_ip} {domain}"
return None
# 4) Domain-only: "example.com" lub "example.com # komentarz"
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}"
@@ -176,7 +188,7 @@ def cache_headers(etag: str, up_lm: Optional[str]):
"Vary": "Accept-Encoding",
"Content-Type": "text/plain; charset=utf-8",
"X-Content-Type-Options": "nosniff",
"Content-Disposition": "inline; filename=converted_hosts.txt",
#"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}"
@@ -198,6 +210,7 @@ def validate_and_normalize_url(url):
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()
link_data = f"{ts}|{url}|{target_ip}"
@@ -238,7 +251,7 @@ def add_recent_convert():
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, 49)
redis_client.ltrim("recent_converts", 0, 99)
@app.route("/favicon.ico", methods=["GET"])
def favicon():
@@ -282,22 +295,78 @@ def index():
@app.route("/convert")
@limiter.limit(config.RATE_LIMIT_CONVERT)
def convert():
import hmac, ipaddress
def is_private_client_ip() -> bool:
ip = get_client_ip()
try:
return ipaddress.ip_address(ip).is_private
except Exception:
return False
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):
ts = datetime.now().isoformat()
line = f"# [DEBUG {ts}] {msg}"
debug_lines.append(line)
app.logger.debug(line)
def debug_response(status=200):
body = "\n".join(debug_lines) + ("\n" if debug_lines else "")
resp = Response(body, mimetype="text/plain; charset=utf-8", status=status)
resp.headers["X-Debug-Mode"] = "1"
resp.headers["Cache-Control"] = "no-store"
return resp
try:
redis_client.incr("stats:convert_requests")
add_recent_convert()
if debug_mode:
d("Start /convert w trybie debug")
encoded_url = request.args.get("url")
if not encoded_url:
if debug_mode:
d("Brak parametru ?url")
return debug_response(status=400)
redis_client.incr("stats:errors_400")
abort(400, description="Missing URL parameter")
decoded_url = unquote(encoded_url)
normalized_url = validate_and_normalize_url(decoded_url)
try:
normalized_url = validate_and_normalize_url(decoded_url)
except ValueError as e:
if debug_mode:
d(f"Błąd walidacji URL: {e}")
return debug_response(status=400)
redis_client.incr("stats:errors_400")
abort(400)
target_ip = request.args.get("ip", "127.0.0.1")
if debug_mode:
d(f"URL (encoded): {encoded_url}")
d(f"URL (decoded): {decoded_url}")
d(f"URL (norm): {normalized_url}")
d(f"target_ip: {target_ip}")
track_url_request(normalized_url)
redis_client.incr(f"stats:target_ips:{target_ip}")
# nagłówki If-*
req_headers = {}
inm = request.headers.get("If-None-Match")
ims = request.headers.get("If-Modified-Since")
@@ -305,59 +374,99 @@ def convert():
req_headers["If-None-Match"] = inm
if ims:
req_headers["If-Modified-Since"] = ims
if debug_mode:
d("Wysyłam GET do upstreamu")
d(f"Nagłówki: {req_headers or '{}'}")
with requests.get(normalized_url, headers=req_headers, stream=True, timeout=(10, 60)) as r:
ct = r.headers.get("Content-Type", "")
# pozwól na text/* oraz octet-stream (często używane przez listy)
if "text" not in ct and "octet-stream" not in ct and ct != "":
abort(415, description="Unsupported Media Type")
r = requests.get(normalized_url, headers=req_headers, stream=True, timeout=(10, 60))
if r.status_code == 304:
etag = build_etag(r.headers.get("ETag"), r.headers.get("Last-Modified"), target_ip)
resp = Response(status=304)
resp.headers.update(cache_headers(etag, r.headers.get("Last-Modified")))
resp.direct_passthrough = True
return resp
ct = r.headers.get("Content-Type", "")
if debug_mode:
d(f"Upstream status: {r.status_code}")
d(f"Content-Type: {ct or '(brak)'}")
d(f"ETag: {r.headers.get('ETag')}")
d(f"Last-Modified: {r.headers.get('Last-Modified')}")
up_etag = r.headers.get("ETag")
up_lm = r.headers.get("Last-Modified")
etag = build_etag(up_etag, up_lm, target_ip)
if "text" not in ct and "octet-stream" not in ct and ct != "":
if debug_mode:
d("Unsupported Media Type -> 415")
r.close()
return debug_response(status=415)
r.close()
abort(415, description="Unsupported Media Type")
@stream_with_context
def body_gen():
total = 0
# iter_lines pewnie tnie po \n/\r\n i dekoduje do str
if r.status_code == 304:
etag = build_etag(r.headers.get("ETag"), r.headers.get("Last-Modified"), target_ip)
if debug_mode:
d("Upstream 304 zwracam 304")
r.close()
return debug_response(status=304)
resp = Response(status=304)
resp.headers.update(cache_headers(etag, r.headers.get("Last-Modified")))
resp.direct_passthrough = True
r.close()
return resp
up_etag = r.headers.get("ETag")
up_lm = r.headers.get("Last-Modified")
etag = build_etag(up_etag, up_lm, target_ip)
if debug_mode:
d(f"Etag dla klienta: {etag}")
@stream_with_context
def body_gen():
lines_read = 0
lines_emitted = 0
try:
if debug_mode:
yield "\n".join(debug_lines) + "\n"
debug_lines.clear()
for line in r.iter_lines(decode_unicode=True, chunk_size=config.READ_CHUNK):
if line is None:
continue
# zabezpieczenie przed megadługimi wierszami
lines_read += 1
if len(line) > config.STREAM_LINE_LIMIT:
if debug_mode and lines_read <= 5:
yield f"# [DEBUG] pominięto długi wiersz ({len(line)} bajtów)\n"
continue
out = convert_host_line(line, target_ip)
if out:
s = out + "\n"
total += len(s)
yield s
# statystyki po zakończeniu streamu
redis_client.incrby("stats:content_size_total", total)
lines_emitted += 1
yield out + "\n"
if debug_mode and lines_read <= 5:
preview = line[:200].replace("\r", "\\r").replace("\n", "\\n")
yield f"# [DEBUG] podgląd linii {lines_read}: {preview}\n"
if debug_mode:
yield f"# [DEBUG] podsumowanie: przeczytano={lines_read}, wyemitowano={lines_emitted}\n"
if lines_emitted == 0:
yield "# [DEBUG] Uwaga: 0 linii wynikowych czy format listy pasuje?\n"
redis_client.incrby("stats:content_size_total", 0)
redis_client.incr("stats:content_size_count")
finally:
r.close()
resp = Response(body_gen(), mimetype="text/plain; charset=utf-8")
resp.headers.update(cache_headers(etag, up_lm))
# wyłącz kompresję/buforowanie dla strumienia
resp.direct_passthrough = True
redis_client.incr("stats:conversions_success")
return resp
resp = Response(body_gen(), mimetype="text/plain; charset=utf-8")
resp.headers.update(cache_headers(etag, up_lm))
resp.direct_passthrough = True
redis_client.incr("stats:conversions_success")
return resp
except requests.exceptions.RequestException as e:
app.logger.error(f"Request error: {str(e)}")
redis_client.incr("stats:errors_500")
if debug_mode:
d(f"Wyjątek requests: {e}")
return debug_response(status=502)
abort(500)
except ValueError as e:
app.logger.error(f"URL validation error: {str(e)}")
redis_client.incr("stats:errors_400")
if debug_mode:
d(f"Wyjątek ValueError: {e}")
return debug_response(status=400)
abort(400)
@app.route("/convert", methods=["HEAD"])
def convert_head():
encoded_url = request.args.get("url", config.DEFAULT_SOURCE_URL)
@@ -372,6 +481,7 @@ def convert_head():
resp.direct_passthrough = True
return resp
@app.route("/stats")
@basic_auth_required(
realm=config.STATS_BASIC_AUTH_REALM,
@@ -379,11 +489,95 @@ def convert_head():
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).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
@@ -400,7 +594,7 @@ def stats():
stats_data[key_str] = value
recent_converts = []
for entry in redis_client.lrange("recent_converts", 0, 49):
for entry in redis_client.lrange("recent_converts", 0, 99):
try:
recent_converts.append(json.loads(entry.decode()))
except Exception:
@@ -437,6 +631,7 @@ def stats():
}
)
@app.errorhandler(400)
@app.errorhandler(403)
@app.errorhandler(404)
@@ -445,9 +640,12 @@ def stats():
@app.errorhandler(500)
def handle_errors(e):
try:
return render_template("error.html", error=e), e.code
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": e.code}), e.code
return jsonify({"error": getattr(e, "description", str(e)), "code": getattr(e, "code", 500)}), getattr(e, "code", 500)
if __name__ == "__main__":
app.run(host=config.BIND_HOST, port=config.BIND_PORT)

259
app_1.py
View File

@@ -1,259 +0,0 @@
import re
import redis
import requests
from datetime import datetime
from flask import Flask, request, render_template, abort, jsonify
from urllib.parse import urlparse, quote, unquote, urljoin
from functools import wraps
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 2 * 1024 * 1024 * 1024 # limit
redis_client = redis.Redis(host='localhost', port=6379, db=7)
ALLOWED_IPS = {'127.0.0.1', '109.173.163.86'}
ALLOWED_DOMAIN = ''
@app.before_request
def track_request_data():
"""Track client IP and User-Agent for all requests"""
client_ip = get_client_ip()
user_agent = request.headers.get('User-Agent', 'Unknown')
# Track User-Agents
redis_client.incr(f'stats:user_agents:{quote(user_agent, safe="")}')
# Track client IPs
redis_client.incr(f'stats:client_ips:{client_ip}')
def get_client_ip():
"""Get real client IP considering proxies"""
x_forwarded_for = request.headers.get('X-Forwarded-For', '').split(',')
if x_forwarded_for and x_forwarded_for[0].strip():
return x_forwarded_for[0].strip()
return request.remote_addr
@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 ip_restriction(f):
@wraps(f)
def decorated(*args, **kwargs):
client_ip = get_client_ip()
host = request.host.split(':')[0]
allowed_conditions = [
client_ip in ALLOWED_IPS,
host == ALLOWED_DOMAIN,
request.headers.get('X-Forwarded-For', '').split(',')[0].strip() in ALLOWED_IPS
]
if any(allowed_conditions):
return f(*args, **kwargs)
redis_client.incr('stats:errors_403')
abort(403)
return decorated
def cache_key(source_url, ip):
return f"cache:{source_url}:{ip}"
#def convert_hosts(content, target_ip):
# """Convert IPs in hosts file content"""
# pattern = r'^\s*?(?P<ip>\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s+(?P<host>\S+).*$'
# return re.sub(pattern, f"{target_ip} \\g<host>", content, flags=re.MULTILINE)
def convert_hosts(content, target_ip):
"""Convert with enhanced validation"""
converted = []
for line in content.splitlines():
line = line.strip()
# Skip empty/comments
if not line or line[0] in ('!', '#', '/') or '$' in line:
continue
# AdGuard domains
if line.startswith(('||', '|')):
domain = line.split('^')[0].lstrip('|')
if 1 < len(domain) <= 253 and '.' in domain[1:-1]:
converted.append(f"{target_ip} {domain}")
continue
# Classic hosts format
if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\s+', line):
converted.append(re.sub(r'^\S+', target_ip, line, count=1))
return '\n'.join(converted)
def validate_and_normalize_url(url):
"""Validate and normalize input URL"""
parsed = urlparse(url)
if not parsed.scheme:
url = f'https://{url}'
parsed = urlparse(url)
if not parsed.netloc:
raise ValueError("Missing host in URL")
return parsed.geturl()
def track_url_request(url):
"""Track requests for specific URLs"""
redis_key = f"stats:url_requests:{quote(url, safe='')}"
redis_client.incr(redis_key)
def add_recent_link(url, target_ip):
"""Add to recent links history"""
timestamp = datetime.now().isoformat()
link_data = f"{timestamp}|{url}|{target_ip}"
with redis_client.pipeline() as pipe:
pipe.lpush("recent_links", link_data)
pipe.ltrim("recent_links", 0, 9)
pipe.execute()
redis_client.incr('stats:recent_links_added')
def get_recent_links():
"""Get last 10 recent links"""
links = redis_client.lrange("recent_links", 0, 9)
parsed_links = []
for link in links:
parts = link.decode().split("|")
if len(parts) >= 3:
parsed_links.append((parts[0], parts[1], parts[2]))
elif len(parts) == 2:
parsed_links.append((parts[0], parts[1], "127.0.0.1"))
return parsed_links
@app.route('/', methods=['GET'])
def index():
"""Main form page"""
generated_link = None
recent_links = get_recent_links()
url_param = request.args.get('url')
target_ip = request.args.get('ip', '127.0.0.1')
if url_param:
try:
normalized_url = validate_and_normalize_url(unquote(url_param))
encoded_url = quote(normalized_url, safe='')
generated_link = urljoin(
request.host_url,
f"convert?url={encoded_url}&ip={target_ip}"
)
add_recent_link(normalized_url, target_ip)
recent_links = get_recent_links()
except Exception as e:
app.logger.error(f"Error processing URL: {str(e)}")
return render_template('form.html',
generated_link=generated_link,
recent_links=recent_links)
@app.route('/convert')
def convert():
"""Conversion endpoint"""
try:
redis_client.incr('stats:convert_requests')
encoded_url = request.args.get('url')
if not encoded_url:
redis_client.incr('stats:errors_400')
abort(400, description="Missing URL parameter")
decoded_url = unquote(encoded_url)
normalized_url = validate_and_normalize_url(decoded_url)
target_ip = request.args.get('ip', '127.0.0.1')
# Track statistics
track_url_request(normalized_url)
redis_client.incr(f'stats:target_ips:{target_ip}')
# Check cache
cached = redis_client.get(cache_key(normalized_url, target_ip))
if cached:
redis_client.incr('stats:cache_hits')
return cached.decode('utf-8'), 200, {'Content-Type': 'text/plain'}
redis_client.incr('stats:cache_misses')
# Fetch and process
response = requests.get(normalized_url, stream=True, timeout=15)
response.raise_for_status()
content = b''
for chunk in response.iter_content(2048):
content += chunk
if len(content) > app.config['MAX_CONTENT_LENGTH']:
redis_client.incr('stats:errors_413')
abort(413)
converted = convert_hosts(content.decode('utf-8'), target_ip)
redis_client.setex(cache_key(normalized_url, target_ip), 43200, converted) # 12h cache
redis_client.incr('stats:conversions_success')
return converted, 200, {'Content-Type': 'text/plain'}
except requests.RequestException as e:
app.logger.error(f"Request error: {str(e)}")
redis_client.incr('stats:errors_500')
abort(500)
except ValueError as e:
app.logger.error(f"URL validation error: {str(e)}")
redis_client.incr('stats:errors_400')
abort(400)
@app.route('/stats')
@ip_restriction
def stats():
"""Statistics endpoint"""
stats_data = {}
target_ips = {}
url_requests = {}
user_agents = {}
client_ips = {}
# Aggregate stats from Redis
for key in redis_client.scan_iter("stats:*"):
key_str = key.decode()
value = redis_client.get(key).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
# Structure response
response_data = {
**stats_data,
'target_ips': target_ips,
'url_requests': url_requests,
'user_agents': user_agents,
'client_ips': client_ips
}
return jsonify(response_data)
@app.errorhandler(400)
@app.errorhandler(403)
@app.errorhandler(404)
@app.errorhandler(413)
@app.errorhandler(500)
def handle_errors(e):
"""Error handling"""
return render_template('error.html', error=e), e.code
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8283)

View File

@@ -1,350 +0,0 @@
import re
import redis
import requests
from datetime import datetime
from flask import Flask, request, render_template, abort, jsonify, g
from urllib.parse import urlparse, quote, unquote, urljoin
from functools import wraps
import json
import socket
import time
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 2 * 1024 * 1024 * 1024 # limit
redis_client = redis.Redis(host='localhost', port=6379, db=7)
ALLOWED_IPS = {'127.0.0.1', '109.173.163.86'}
ALLOWED_DOMAIN = ''
@app.before_request
def track_request_data():
"""Rejestracja IP klienta, User-Agent, metody HTTP oraz rozpoczęcie pomiaru czasu requestu"""
g.start_time = time.perf_counter() # rozpoczęcie pomiaru czasu
client_ip = get_client_ip()
user_agent = request.headers.get('User-Agent', 'Unknown')
method = request.method
# Rejestracja User-Agent
redis_client.incr(f'stats:user_agents:{quote(user_agent, safe="")}')
# Rejestracja adresu IP klienta
redis_client.incr(f'stats:client_ips:{client_ip}')
# Rejestracja metody HTTP
redis_client.incr(f'stats:methods:{method}')
def get_client_ip():
"""Pobranie prawdziwego adresu IP klienta (uwzględniając proxy)"""
x_forwarded_for = request.headers.get('X-Forwarded-For', '').split(',')
if x_forwarded_for and x_forwarded_for[0].strip():
return x_forwarded_for[0].strip()
return request.remote_addr
@app.after_request
def after_request(response):
"""Pomiar i rejestracja czasu przetwarzania żądania"""
elapsed = time.perf_counter() - g.start_time
# Aktualizacja statystyk czasu przetwarzania (w sekundach)
redis_client.incrbyfloat('stats:processing_time_total', elapsed)
redis_client.incr('stats:processing_time_count')
# Aktualizacja minimalnego czasu przetwarzania
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)
# Aktualizacja maksymalnego czasu przetwarzania
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)
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 ip_restriction(f):
@wraps(f)
def decorated(*args, **kwargs):
client_ip = get_client_ip()
host = request.host.split(':')[0]
allowed_conditions = [
client_ip in ALLOWED_IPS,
host == ALLOWED_DOMAIN,
request.headers.get('X-Forwarded-For', '').split(',')[0].strip() in ALLOWED_IPS
]
if any(allowed_conditions):
return f(*args, **kwargs)
redis_client.incr('stats:errors_403')
abort(403)
return decorated
def cache_key(source_url, ip):
return f"cache:{source_url}:{ip}"
def convert_hosts(content, target_ip):
"""Konwersja treści pliku hosts z uwzględnieniem walidacji"""
converted = []
for line in content.splitlines():
line = line.strip()
# Pomijanie pustych linii i komentarzy
if not line or line[0] in ('!', '#', '/') or '$' in line:
continue
# Reguły AdGuard
if line.startswith(('||', '|')):
domain = line.split('^')[0].lstrip('|')
if 1 < len(domain) <= 253 and '.' in domain[1:-1]:
converted.append(f"{target_ip} {domain}")
continue
# Klasyczny format hosts
if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\s+', line):
converted.append(re.sub(r'^\S+', target_ip, line, count=1))
return '\n'.join(converted)
def validate_and_normalize_url(url):
"""Walidacja i normalizacja adresu URL"""
parsed = urlparse(url)
if not parsed.scheme:
url = f'https://{url}'
parsed = urlparse(url)
if not parsed.netloc:
raise ValueError("Missing host in URL")
return parsed.geturl()
def track_url_request(url):
"""Rejestracja żądania dla określonego URL"""
redis_key = f"stats:url_requests:{quote(url, safe='')}"
redis_client.incr(redis_key)
def add_recent_link(url, target_ip):
"""Dodanie ostatniego linku do historii (ostatnie 10)"""
timestamp = datetime.now().isoformat()
link_data = f"{timestamp}|{url}|{target_ip}"
with redis_client.pipeline() as pipe:
pipe.lpush("recent_links", link_data)
pipe.ltrim("recent_links", 0, 9)
pipe.execute()
redis_client.incr('stats:recent_links_added')
def get_recent_links():
"""Pobranie ostatnich 10 linków"""
links = redis_client.lrange("recent_links", 0, 9)
parsed_links = []
for link in links:
parts = link.decode().split("|")
if len(parts) >= 3:
parsed_links.append((parts[0], parts[1], parts[2]))
elif len(parts) == 2:
parsed_links.append((parts[0], parts[1], "127.0.0.1"))
return parsed_links
# Nowa funkcja do logowania requestów dla endpointu /convert
def add_recent_convert():
"""Dodaje dane żądania do listy ostatnich konwersji (/convert)"""
ip = get_client_ip()
try:
hostname = socket.gethostbyaddr(ip)[0]
except Exception:
hostname = ip
user_agent = request.headers.get('User-Agent', 'Unknown')
time_str = datetime.now().astimezone().isoformat()
url = request.full_path # pełna ścieżka wraz z query string
data = {
"url": url,
"ip": ip,
"hostname": hostname,
"time": time_str,
"user_agent": user_agent
}
json_data = json.dumps(data)
redis_client.lpush("recent_converts", json_data)
redis_client.ltrim("recent_converts", 0, 49)
@app.route('/', methods=['GET'])
def index():
"""Strona główna z formularzem"""
generated_link = None
recent_links = get_recent_links()
url_param = request.args.get('url')
target_ip = request.args.get('ip', '127.0.0.1')
if url_param:
try:
normalized_url = validate_and_normalize_url(unquote(url_param))
encoded_url = quote(normalized_url, safe='')
generated_link = urljoin(
request.host_url,
f"convert?url={encoded_url}&ip={target_ip}"
)
add_recent_link(normalized_url, target_ip)
recent_links = get_recent_links()
except Exception as e:
app.logger.error(f"Error processing URL: {str(e)}")
return render_template('form.html',
generated_link=generated_link,
recent_links=recent_links)
@app.route('/convert')
def convert():
"""Endpoint do konwersji"""
try:
redis_client.incr('stats:convert_requests')
# Logowanie danych dla requestu do /convert
add_recent_convert()
encoded_url = request.args.get('url')
if not encoded_url:
redis_client.incr('stats:errors_400')
abort(400, description="Missing URL parameter")
decoded_url = unquote(encoded_url)
normalized_url = validate_and_normalize_url(decoded_url)
target_ip = request.args.get('ip', '127.0.0.1')
# Rejestracja statystyk dotyczących URL
track_url_request(normalized_url)
redis_client.incr(f'stats:target_ips:{target_ip}')
# Sprawdzenie pamięci podręcznej
cached = redis_client.get(cache_key(normalized_url, target_ip))
if cached:
redis_client.incr('stats:cache_hits')
return cached.decode('utf-8'), 200, {'Content-Type': 'text/plain'}
redis_client.incr('stats:cache_misses')
# Pobranie i przetworzenie treści
response = requests.get(normalized_url, stream=True, timeout=15)
response.raise_for_status()
content = b''
for chunk in response.iter_content(2048):
content += chunk
if len(content) > app.config['MAX_CONTENT_LENGTH']:
redis_client.incr('stats:errors_413')
abort(413)
# Rejestracja rozmiaru pobranej treści
content_size = len(content)
redis_client.incrby('stats:content_size_total', content_size)
redis_client.incr('stats:content_size_count')
converted = convert_hosts(content.decode('utf-8'), target_ip)
redis_client.setex(cache_key(normalized_url, target_ip), 43200, converted) # 12h cache
redis_client.incr('stats:conversions_success')
return converted, 200, {'Content-Type': 'text/plain'}
except requests.RequestException as e:
app.logger.error(f"Request error: {str(e)}")
redis_client.incr('stats:errors_500')
abort(500)
except ValueError as e:
app.logger.error(f"URL validation error: {str(e)}")
redis_client.incr('stats:errors_400')
abort(400)
@app.route('/stats')
@ip_restriction
def stats():
"""Endpoint statystyk"""
stats_data = {}
target_ips = {}
url_requests = {}
user_agents = {}
client_ips = {}
# Agregacja statystyk z Redisa
for key in redis_client.scan_iter("stats:*"):
key_str = key.decode()
value = redis_client.get(key).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
# Pobranie ostatnich 50 requestów dla endpointu /convert
recent_converts = []
convert_entries = redis_client.lrange("recent_converts", 0, 49)
for entry in convert_entries:
try:
data = json.loads(entry.decode())
recent_converts.append(data)
except Exception:
pass
# Obliczenie średniego czasu przetwarzania żądań
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
# Obliczenie średniego rozmiaru pobranej treści dla /convert
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
# Rozszerzone statystyki dotyczące wydajności i rozmiarów danych
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
}
# Struktura odpowiedzi
response_data = {
**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
}
return jsonify(response_data)
@app.errorhandler(400)
@app.errorhandler(403)
@app.errorhandler(404)
@app.errorhandler(413)
@app.errorhandler(500)
def handle_errors(e):
"""Obsługa błędów"""
return render_template('error.html', error=e), e.code
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8283)

View File

@@ -1,383 +0,0 @@
import re
import redis
import requests
import aiohttp
import asyncio
import socket
import time
import json
from datetime import datetime
from flask import Flask, request, render_template, abort, jsonify, g
from urllib.parse import urlparse, quote, unquote, urljoin
from functools import wraps
from flask_compress import Compress
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 2 * 1024 * 1024 * 1024 # limit
redis_client = redis.Redis(host='localhost', port=6379, db=7)
# Ustawienia do rate limiting 100 żądań na minutę
def get_client_ip():
"""Pobranie prawdziwego adresu IP klienta (uwzględniając proxy)"""
x_forwarded_for = request.headers.get('X-Forwarded-For', '').split(',')
if x_forwarded_for and x_forwarded_for[0].strip():
return x_forwarded_for[0].strip()
return request.remote_addr
limiter = Limiter(key_func=get_client_ip, default_limits=["100 per minute"], app=app)
Compress(app)
ALLOWED_IPS = {'127.0.0.1', '109.173.163.86'}
ALLOWED_DOMAIN = ''
@app.before_request
def track_request_data():
"""Rejestracja IP klienta, User-Agent, metody HTTP oraz rozpoczęcie pomiaru czasu requestu"""
g.start_time = time.perf_counter() # rozpoczęcie pomiaru czasu
client_ip = get_client_ip()
user_agent = request.headers.get('User-Agent', 'Unknown')
method = request.method
# Rejestracja User-Agent
redis_client.incr(f'stats:user_agents:{quote(user_agent, safe="")}')
# Rejestracja adresu IP klienta
redis_client.incr(f'stats:client_ips:{client_ip}')
# Rejestracja metody HTTP
redis_client.incr(f'stats:methods:{method}')
@app.after_request
def after_request(response):
"""Pomiar i rejestracja czasu przetwarzania żądania"""
elapsed = time.perf_counter() - g.start_time
# Aktualizacja statystyk czasu przetwarzania (w sekundach)
redis_client.incrbyfloat('stats:processing_time_total', elapsed)
redis_client.incr('stats:processing_time_count')
# Aktualizacja minimalnego czasu przetwarzania
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)
# Aktualizacja maksymalnego czasu przetwarzania
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)
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 ip_restriction(f):
@wraps(f)
def decorated(*args, **kwargs):
client_ip = get_client_ip()
host = request.host.split(':')[0]
allowed_conditions = [
client_ip in ALLOWED_IPS,
host == ALLOWED_DOMAIN,
request.headers.get('X-Forwarded-For', '').split(',')[0].strip() in ALLOWED_IPS
]
if any(allowed_conditions):
return f(*args, **kwargs)
redis_client.incr('stats:errors_403')
abort(403)
return decorated
def cache_key(source_url, ip):
return f"cache:{source_url}:{ip}"
def convert_hosts(content, target_ip):
"""Konwersja treści pliku hosts z uwzględnieniem walidacji"""
converted = []
for line in content.splitlines():
line = line.strip()
# Pomijanie pustych linii i komentarzy
if not line or line[0] in ('!', '#', '/') or '$' in line:
continue
# Reguły AdGuard
if line.startswith(('||', '|')):
domain = line.split('^')[0].lstrip('|')
if 1 < len(domain) <= 253 and '.' in domain[1:-1]:
converted.append(f"{target_ip} {domain}")
continue
# Klasyczny format hosts
if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\s+', line):
converted.append(re.sub(r'^\S+', target_ip, line, count=1))
return '\n'.join(converted)
def validate_and_normalize_url(url):
"""Walidacja i normalizacja adresu URL"""
parsed = urlparse(url)
if not parsed.scheme:
url = f'https://{url}'
parsed = urlparse(url)
if not parsed.netloc:
raise ValueError("Missing host in URL")
return parsed.geturl()
def track_url_request(url):
"""Rejestracja żądania dla określonego URL"""
redis_key = f"stats:url_requests:{quote(url, safe='')}"
redis_client.incr(redis_key)
def add_recent_link(url, target_ip):
"""Dodanie ostatniego linku do historii (ostatnie 10)"""
timestamp = datetime.now().isoformat()
link_data = f"{timestamp}|{url}|{target_ip}"
with redis_client.pipeline() as pipe:
pipe.lpush("recent_links", link_data)
pipe.ltrim("recent_links", 0, 9)
pipe.execute()
redis_client.incr('stats:recent_links_added')
def get_recent_links():
"""Pobranie ostatnich 10 linków"""
links = redis_client.lrange("recent_links", 0, 9)
parsed_links = []
for link in links:
parts = link.decode().split("|")
if len(parts) >= 3:
parsed_links.append((parts[0], parts[1], parts[2]))
elif len(parts) == 2:
parsed_links.append((parts[0], parts[1], "127.0.0.1"))
return parsed_links
def get_hostname(ip):
"""Cacheowanie wyników reverse DNS dla danego 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
# Cache na 1 godzinę
redis_client.setex(key, 3600, hostname)
return hostname
# Nowa funkcja do logowania requestów dla endpointu /convert
def add_recent_convert():
"""Dodaje dane żądania do listy ostatnich konwersji (/convert)"""
ip = get_client_ip()
hostname = get_hostname(ip)
user_agent = request.headers.get('User-Agent', 'Unknown')
time_str = datetime.now().astimezone().isoformat()
url = request.full_path # pełna ścieżka wraz z query string
data = {
"url": url,
"ip": ip,
"hostname": hostname,
"time": time_str,
"user_agent": user_agent
}
json_data = json.dumps(data)
redis_client.lpush("recent_converts", json_data)
redis_client.ltrim("recent_converts", 0, 49)
@app.route('/', methods=['GET'])
def index():
"""Strona główna z formularzem"""
generated_link = None
recent_links = get_recent_links()
url_param = request.args.get('url')
target_ip = request.args.get('ip', '127.0.0.1')
if url_param:
try:
normalized_url = validate_and_normalize_url(unquote(url_param))
encoded_url = quote(normalized_url, safe='')
generated_link = urljoin(
request.host_url,
f"convert?url={encoded_url}&ip={target_ip}"
)
add_recent_link(normalized_url, target_ip)
recent_links = get_recent_links()
except Exception as e:
app.logger.error(f"Error processing URL: {str(e)}")
return render_template('form.html',
generated_link=generated_link,
recent_links=recent_links)
@app.route('/convert')
@limiter.limit("100 per minute")
async def convert():
"""Asynchroniczny endpoint do konwersji z weryfikacją typu zawartości"""
try:
redis_client.incr('stats:convert_requests')
# Logowanie danych dla requestu do /convert
add_recent_convert()
encoded_url = request.args.get('url')
if not encoded_url:
redis_client.incr('stats:errors_400')
abort(400, description="Missing URL parameter")
decoded_url = unquote(encoded_url)
normalized_url = validate_and_normalize_url(decoded_url)
target_ip = request.args.get('ip', '127.0.0.1')
# Rejestracja statystyk dotyczących URL
track_url_request(normalized_url)
redis_client.incr(f'stats:target_ips:{target_ip}')
# Sprawdzenie pamięci podręcznej
cached = redis_client.get(cache_key(normalized_url, target_ip))
if cached:
redis_client.incr('stats:cache_hits')
return cached.decode('utf-8'), 200, {'Content-Type': 'text/plain'}
redis_client.incr('stats:cache_misses')
# Asynchroniczne pobranie zasobu za pomocą aiohttp
async with aiohttp.ClientSession() as session:
async with session.get(normalized_url, timeout=15) as response:
# Sprawdzanie typu zawartości musi zawierać "text"
content_type = response.headers.get("Content-Type", "")
if "text" not in content_type:
abort(415, description="Unsupported Media Type")
content = b""
while True:
chunk = await response.content.read(2048)
if not chunk:
break
content += chunk
if len(content) > app.config['MAX_CONTENT_LENGTH']:
redis_client.incr('stats:errors_413')
abort(413)
# Rejestracja rozmiaru pobranej treści
content_size = len(content)
redis_client.incrby('stats:content_size_total', content_size)
redis_client.incr('stats:content_size_count')
converted = convert_hosts(content.decode('utf-8'), target_ip)
redis_client.setex(cache_key(normalized_url, target_ip), 43200, converted) # 12h cache
redis_client.incr('stats:conversions_success')
return converted, 200, {'Content-Type': 'text/plain'}
except aiohttp.ClientError as e:
app.logger.error(f"Request error: {str(e)}")
redis_client.incr('stats:errors_500')
abort(500)
except ValueError as e:
app.logger.error(f"URL validation error: {str(e)}")
redis_client.incr('stats:errors_400')
abort(400)
@app.route('/stats')
@ip_restriction
def stats():
"""Endpoint statystyk"""
stats_data = {}
target_ips = {}
url_requests = {}
user_agents = {}
client_ips = {}
# Agregacja statystyk z Redisa
for key in redis_client.scan_iter("stats:*"):
key_str = key.decode()
value = redis_client.get(key).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
# Pobranie ostatnich 50 requestów dla endpointu /convert
recent_converts = []
convert_entries = redis_client.lrange("recent_converts", 0, 49)
for entry in convert_entries:
try:
data = json.loads(entry.decode())
recent_converts.append(data)
except Exception:
pass
# Obliczenie średniego czasu przetwarzania żądań
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
# Obliczenie średniego rozmiaru pobranej treści dla /convert
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
# Rozszerzone statystyki dotyczące wydajności i rozmiarów danych
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
}
# Struktura odpowiedzi
response_data = {
**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
}
return jsonify(response_data)
@app.errorhandler(400)
@app.errorhandler(403)
@app.errorhandler(404)
@app.errorhandler(413)
@app.errorhandler(415)
@app.errorhandler(500)
def handle_errors(e):
"""Obsługa błędów"""
return render_template('error.html', error=e), e.code
# Jeśli aplikacja jest uruchamiana bezpośrednio, korzystamy z Flask's run
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8283)
# W przeciwnym razie (np. przy uruchamianiu przez Gunicorn) opakowujemy aplikację w adapter ASGI
else:
from asgiref.wsgi import WsgiToAsgi
asgi_app = WsgiToAsgi(app)

View File

@@ -20,7 +20,7 @@ def getenv_float(key: str, default: float) -> float:
# Podstawowe
FLASK_DEBUG = getenv_bool("FLASK_DEBUG", True)
SECRET_KEY = os.getenv("SECRET_KEY", "change-me")
SECRET_KEY = os.getenv("SECRET_KEY", "secretkey")
# Rozmiary/limity
MAX_CONTENT_LENGTH = getenv_int("MAX_CONTENT_LENGTH", 50 * 1024 * 1024) # 50MB
@@ -36,8 +36,8 @@ REDIS_DB = getenv_int("REDIS_DB", 7)
# Basic Auth dla /stats
STATS_BASIC_AUTH_ENABLED = getenv_bool("STATS_BASIC_AUTH_ENABLED", True)
STATS_BASIC_AUTH_REALM = os.getenv("STATS_BASIC_AUTH_REALM", "Stats")
STATS_BASIC_AUTH_USER = os.getenv("STATS_BASIC_AUTH_USER", "admin")
STATS_BASIC_AUTH_PASS = os.getenv("STATS_BASIC_AUTH_PASS", "change-me")
STATS_BASIC_AUTH_USER = os.getenv("STATS_BASIC_AUTH_USER", "admin").strip()
STATS_BASIC_AUTH_PASS = os.getenv("STATS_BASIC_AUTH_PASS", "admin").strip()
# Cache/ETag dla Varnisha
CACHE_ENABLED = getenv_bool("CACHE_ENABLED", True)
@@ -63,3 +63,8 @@ DEFAULT_SOURCE_URL = os.getenv(
"DEFAULT_SOURCE_URL",
""
)
# Debug trybu /convert
DEBUG_ENABLE = getenv_bool("DEBUG_ENABLE", False)
DEBUG_KEY = os.getenv("DEBUG_KEY", "") # ustaw w env bezpieczny losowy sekret
DEBUG_RATE_LIMIT = os.getenv("DEBUG_RATE_LIMIT", "5 per minute")

View File

@@ -1 +1,3 @@
venv/bin/gunicorn -k uvicorn.workers.UvicornWorker --workers 4 --bind 127.0.0.1:8283 --keep-alive 30 --timeout 90 app:asgi_app
#/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

688
static/css/main.css Normal file
View File

@@ -0,0 +1,688 @@
:root {
--bg: #0f1115;
--bg-elev: #131722;
--card: #161b26;
--text: #e7eef7;
--muted: #a9b4c3;
--border: #243043;
--brand: #5b9dff;
--brand-2: #7bd4ff;
--success: #29c36a;
--danger: #ff5d5d;
--shadow: 0 10px 30px rgba(0, 0, 0, .35);
color-scheme: dark;
}
[data-theme="light"] {
--bg: #f6f8fb;
--bg-elev: #fff;
--card: #fff;
--text: #1d2433;
--muted: #5b6678;
--border: #e6eaf2;
--brand: #0054e6;
--brand-2: #3aa2ff;
--success: #1a9a56;
--danger: #d14646;
--shadow: 0 8px 24px rgba(0, 0, 0, .08);
color-scheme: light;
}
* {
box-sizing: border-box
}
html,
body {
height: 100%
}
body {
margin: 0;
font-family: ui-sans-serif, system-ui, "Segoe UI", Roboto, Arial, sans-serif;
background:
radial-gradient(1200px 600px at 10% -10%, rgba(91, 157, 255, .08), transparent 60%),
radial-gradient(900px 500px at 110% 0%, rgba(123, 212, 255, .10), transparent 60%),
var(--bg);
color: var(--text);
}
/* Header */
.site-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 18px;
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
backdrop-filter: saturate(140%) blur(8px);
}
.brand {
display: flex;
gap: 10px;
align-items: center;
font-weight: 700;
letter-spacing: .2px
}
.brand svg {
color: var(--brand)
}
.actions {
display: flex;
gap: 8px;
align-items: center
}
/* Layout */
.container {
max-width: 980px;
margin: 24px auto;
padding: 0 16px;
display: grid;
gap: 18px
}
.card {
background: linear-gradient(180deg, var(--card), color-mix(in srgb, var(--card) 80%, #000 20%));
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: var(--shadow);
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
border-bottom: 1px dashed var(--border)
}
/* Hero */
.hero {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px;
gap: 18px
}
.hero h1 {
margin: 0 0 6px;
font-size: clamp(22px, 3.4vw, 30px)
}
.hero .muted {
color: var(--muted)
}
.hero-cta .large {
font-size: 1.05rem;
padding: 14px 20px
}
/* Grid */
.grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 14px
}
.col-12 {
grid-column: span 12
}
.col-6 {
grid-column: span 6
}
@media (max-width:720px) {
.col-6 {
grid-column: span 12
}
}
/* Form */
.form-card {
padding: 16px
}
.form-group label {
display: block;
font-weight: 600;
margin-bottom: 6px
}
input[type="url"],
input[type="text"],
select {
width: 100%;
padding: 12px 14px;
border-radius: 12px;
background: linear-gradient(0deg, var(--bg-elev), var(--bg-elev));
border: 1px solid var(--border);
color: var(--text);
outline: none;
transition: border .15s, box-shadow .15s, transform .05s;
}
input:focus,
select:focus {
border-color: color-mix(in srgb, var(--brand) 60%, var(--border) 40%);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 30%, transparent);
}
.hint {
color: var(--muted);
display: block;
margin-top: 6px;
font-size: .9rem
}
.error {
color: var(--danger);
min-height: 1.2em;
margin-top: 6px;
font-size: .9rem
}
.form-actions {
display: flex;
gap: 10px;
align-items: center
}
/* Result */
.result-box {
margin-top: 14px;
padding: 12px;
border: 1px dashed var(--border);
border-radius: 12px;
background: var(--bg-elev)
}
.result-row {
display: flex;
gap: 10px;
align-items: center
}
.result-row input[readonly] {
flex: 1 1 auto;
min-width: 0
}
.result-buttons {
display: flex;
gap: 8px
}
/* Recent */
.recent-card {
padding: 0
}
.recent-list {
padding: 12px
}
.link-item {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 12px;
background: linear-gradient(180deg, var(--bg-elev), color-mix(in srgb, var(--bg-elev) 92%, #000 8%));
transition: transform .12s ease-out, border-color .15s;
}
.link-item:hover {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--brand) 40%, var(--border) 60%)
}
.link-main {
display: flex;
gap: 8px;
align-items: center;
overflow: auto;
scrollbar-width: thin
}
.link-main {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 8px;
align-items: center
}
.link-main .mono.ellipsis:first-child {
min-width: 0
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
white-space: nowrap
}
.arrow {
opacity: .6
}
.link-meta {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center
}
.timestamp {
color: var(--muted);
font-size: .92rem
}
.link-actions {
display: flex;
gap: 6px
}
/* Footer */
.site-footer {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: space-between;
margin: 28px auto;
padding: 10px 16px;
max-width: 980px;
color: var(--muted)
}
.site-footer a {
color: color-mix(in srgb, var(--brand) 80%, var(--text) 20%)
}
/* Buttons */
.btn {
-webkit-tap-highlight-color: transparent;
appearance: none;
border: none;
cursor: pointer;
user-select: none;
border-radius: 12px;
padding: 10px 14px;
font-weight: 700;
letter-spacing: .2px;
background: linear-gradient(180deg, color-mix(in srgb, var(--brand) 80%, var(--brand-2) 20%), var(--brand));
color: #fff;
box-shadow: 0 10px 20px color-mix(in srgb, var(--brand) 35%, transparent);
transition: transform .04s ease, filter .15s ease, box-shadow .15s ease;
}
.btn:hover {
filter: brightness(1.05)
}
.btn:active {
transform: translateY(1px)
}
.btn.outline {
background: transparent;
color: var(--text);
border: 1px solid color-mix(in srgb, var(--brand) 60%, var(--border) 40%)
}
.btn.ghost {
background: transparent;
color: var(--text);
border: 1px solid var(--border)
}
.btn.tiny {
padding: 6px 10px;
font-weight: 600;
border-radius: 10px
}
.btn.large {
padding: 14px 20px;
border-radius: 14px
}
/* Toast */
#toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%) translateY(20px);
background: var(--bg-elev);
color: var(--text);
border: 1px solid var(--border);
border-radius: 12px;
padding: 10px 14px;
opacity: 0;
box-shadow: var(--shadow);
pointer-events: none;
transition: opacity .2s, transform .2s;
}
#toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0)
}
/* Links & helpers */
a {
color: color-mix(in srgb, var(--brand) 80%, var(--text) 20%);
text-decoration: none
}
a:hover {
text-decoration: underline
}
.ellipsis {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap
}
select.select,
.select {
appearance: none;
background: var(--bg-elev);
color: var(--text);
border: 1px solid var(--border)
}
select option {
background: var(--bg-elev);
color: var(--text)
}
select:focus {
border-color: color-mix(in srgb, var(--brand) 60%, var(--border) 40%);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 30%, transparent);
}
.nowrap {
white-space: nowrap
}
/* Stats */
.kpi-card {
padding: 16px
}
.section-title {
margin: 10px 12px 0
}
.kpi-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(6, minmax(0, 1fr))
}
@media (max-width:980px) {
.kpi-grid {
grid-template-columns: repeat(3, 1fr)
}
}
@media (max-width:640px) {
.kpi-grid {
grid-template-columns: repeat(2, 1fr)
}
}
.kpi {
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px;
background: linear-gradient(180deg, var(--bg-elev), color-mix(in srgb, var(--bg-elev) 92%, #000 8%))
}
.kpi-label {
color: var(--muted);
font-weight: 600;
font-size: .9rem
}
.kpi-value {
font-size: 1.4rem;
font-weight: 800;
margin-top: 4px
}
.table-wrap {
overflow: auto
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: .98rem
}
.data-table th,
.data-table td {
padding: 10px;
border-bottom: 1px solid var(--border);
vertical-align: top
}
.data-table thead th {
position: sticky;
top: 0;
background: var(--bg-elev);
z-index: 1
}
.data-table .right {
text-align: right
}
.scrollbox {
max-height: 320px;
overflow: auto;
padding: 12px;
margin: 0;
background: linear-gradient(180deg, var(--bg-elev), color-mix(in srgb, var(--bg-elev) 92%, #000 8%));
border: 1px solid var(--border);
border-radius: 12px;
}
.subhead {
margin: 10px 6px
}
.table-filter {
padding: 8px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-elev);
color: var(--text);
min-width: 220px
}
/* Stats wide container */
.container.container--wide {
max-width: 1280px;
padding: 0 20px
}
.container.container--wide .card {
padding: 16px
}
.container.container--wide .section-head {
padding: 12px 4px;
border-bottom: 1px dashed var(--border)
}
.container.container--wide .data-table {
table-layout: fixed;
font-size: .96rem;
line-height: 1.35
}
.container.container--wide .data-table th,
.container.container--wide .data-table td {
padding: 8px 10px
}
.container.container--wide .data-table td:nth-child(2),
.container.container--wide .data-table td:nth-child(5) {
max-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.container.container--wide .data-table tbody tr:nth-child(odd) td {
background: color-mix(in srgb, var(--bg-elev) 96%, #000 4%)
}
.container.container--wide .table-wrap {
overflow: auto;
scrollbar-width: thin;
-webkit-overflow-scrolling: touch
}
.container.container--wide .kpi-value {
font-size: 1.5rem
}
@media (max-width:720px) {
.container.container--wide {
max-width: 100%;
padding: 0 12px
}
.container.container--wide .data-table {
font-size: .94rem
}
}
/* Error page */
.error-card {
padding: 16px
}
.error-hero {
display: flex;
gap: 18px;
align-items: center;
padding: 8px 6px 16px
}
.error-illustration {
font-size: clamp(44px, 8vw, 72px);
filter: drop-shadow(0 10px 20px rgba(0, 0, 0, .25))
}
.error-main {
flex: 1 1 auto
}
.status-badge {
display: inline-block;
padding: 6px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--danger) 18%, var(--bg-elev));
border: 1px solid color-mix(in srgb, var(--danger) 60%, var(--border) 40%);
color: var(--text);
font-weight: 700;
letter-spacing: .2px;
margin-bottom: 6px;
}
.error-title {
margin: 4px 0 6px;
font-size: clamp(22px, 3.4vw, 28px)
}
.error-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 10px
}
.error-details {
margin-top: 12px
}
.error-details>summary {
list-style: none;
cursor: pointer;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-elev);
color: var(--text);
display: flex;
align-items: center;
gap: 8px;
}
.error-details>summary::-webkit-details-marker {
display: none
}
.error-details[open]>summary {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0
}
.summary-title {
font-weight: 700
}
.summary-hint {
color: var(--muted);
font-size: .9rem
}
.details-body {
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 10px 10px;
background: var(--bg-elev)
}
#error-dump {
margin: 0;
padding: 12px;
max-height: 360px;
overflow: auto;
background: linear-gradient(180deg, var(--bg-elev), color-mix(in srgb, var(--bg-elev) 92%, #000 8%));
border-bottom: 1px solid var(--border);
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
}
@media (max-width:720px) {
.error-card {
padding: 12px
}
.error-details>summary {
padding: 8px 10px
}
#error-dump {
max-height: 300px
}
}

25
static/js/error.js Normal file
View File

@@ -0,0 +1,25 @@
(function () {
const t = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', t);
// prosty "try again"
document.querySelector('[data-action="try-again"]')?.addEventListener('click', () => {
location.reload();
});
// kopiowanie logs
document.querySelector('[data-action="copy-text"]')?.addEventListener('click', (e) => {
const sel = e.currentTarget.getAttribute('data-target');
const el = sel && document.querySelector(sel);
if (!el) return;
const txt = el.textContent || '';
navigator.clipboard.writeText(txt).then(() => {
const toast = document.getElementById('toast');
if (toast) {
toast.textContent = 'Copied!';
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 1200);
}
});
});
})();

161
static/js/main.js Normal file
View File

@@ -0,0 +1,161 @@
(function () {
const $ = (q, c = document) => c.querySelector(q);
const $$ = (q, c = document) => Array.from(c.querySelectorAll(q));
const setTheme = (t) => { document.documentElement.setAttribute('data-theme', t); try { localStorage.setItem('theme', t) } catch { } };
const toast = (msg) => {
const el = $('#toast'); if (!el) return;
el.textContent = msg; el.classList.add('show');
clearTimeout(el._t); el._t = setTimeout(() => el.classList.remove('show'), 2000);
};
const host = () => `${location.protocol}//${location.host}`;
function buildLink(url, ip) {
if (!url || !ip) return '';
try {
const enc = encodeURIComponent(url);
const ipClean = (ip || '').trim();
return `${host()}/convert?url=${enc}&ip=${encodeURIComponent(ipClean)}`;
} catch { return ''; }
}
document.addEventListener('click', (e) => {
const t = e.target.closest('[data-action="toggle-theme"]');
if (t) {
e.preventDefault();
const cur = document.documentElement.getAttribute('data-theme') || 'dark';
setTheme(cur === 'dark' ? 'light' : 'dark');
}
});
const urlInput = $('#url-input');
const ipInput = $('#ip-input');
const ipPreset = $('#ip-preset');
const out = $('#generated-link');
const openBtn = $('#open-link');
function updatePreview() {
const link = buildLink(urlInput.value.trim(), ipInput.value.trim());
out.value = link || '';
if (link) {
openBtn.setAttribute('href', link);
openBtn.setAttribute('aria-disabled', 'false');
} else {
openBtn.setAttribute('href', '#');
openBtn.setAttribute('aria-disabled', 'true');
}
$('.result-box')?.setAttribute('data-state', link ? 'ready' : 'empty');
}
['input', 'change', 'blur'].forEach(evt => {
urlInput?.addEventListener(evt, updatePreview);
ipInput?.addEventListener(evt, updatePreview);
});
ipPreset?.addEventListener('change', () => {
const v = ipPreset.value;
if (!v) return;
if (v !== 'custom') ipInput.value = v;
ipInput.focus();
updatePreview();
});
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(() => {
// Fallback
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"]');
const text = btn.getAttribute('data-text') || '';
if (!text) return;
navigator.clipboard?.writeText(text).then(() => toast('Copied'));
}
if (t.closest('[data-action="clear"]')) {
e.preventDefault();
urlInput.value = '';
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';
btn.setAttribute('aria-expanded', expanded ? 'false' : 'true');
panel.style.display = expanded ? 'none' : '';
}
});
function showError(input, msg) {
const id = input.getAttribute('id');
const box = document.querySelector(`.error[data-error-for="${id}"]`);
if (box) box.textContent = msg || '';
input.setAttribute('aria-invalid', msg ? 'true' : 'false');
}
urlInput?.addEventListener('blur', () => {
const v = urlInput.value.trim();
if (!v) return showError(urlInput, '');
try { new URL(v); showError(urlInput, ''); }
catch { showError(urlInput, 'Invalid URL'); }
});
ipInput?.addEventListener('blur', () => {
const v = ipInput.value.trim();
if (!v) return showError(ipInput, '');
const ok = /^\b\d{1,3}(?:\.\d{1,3}){3}\b$/.test(v);
showError(ipInput, ok ? '' : 'Invalid IPv4 address');
});
(function init() {
const serverLink = out?.value?.trim();
if (serverLink) {
$('.result-box')?.setAttribute('data-state', 'ready');
openBtn?.setAttribute('aria-disabled', 'false');
} else {
updatePreview();
}
})();
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'));
}
});
})();
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);

11
static/js/stats.js Normal file
View File

@@ -0,0 +1,11 @@
document.addEventListener('input', (e) => {
const el = e.target.closest('[data-action="filter-table"]');
if (!el) return;
const table = document.querySelector(el.getAttribute('data-target') || '');
if (!table) return;
const q = (el.value || '').toLowerCase();
table.querySelectorAll('tbody tr').forEach(tr => {
const text = (tr.innerText || tr.textContent || '').toLowerCase();
tr.style.display = text.includes(q) ? '' : 'none';
});
});

View File

@@ -1,87 +1,87 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error {{ error.code }}</title>
<style>
:root {
--bg-color: #1a1a1a;
--card-bg: #2d2d2d;
--text-color: #e0e0e0;
--accent: #007bff;
--border-color: #404040;
--error-color: #ff4444;
}
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 20px;
}
.error-container {
max-width: 600px;
padding: 40px;
background: var(--card-bg);
border-radius: 12px;
border: 1px solid var(--border-color);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
}
h1 {
color: var(--error-color);
font-size: 3.5em;
margin: 0 0 20px 0;
font-weight: 600;
}
p {
font-size: 1.2em;
margin: 10px 0;
color: #aaa;
}
a {
color: var(--accent);
text-decoration: none;
margin-top: 20px;
display: inline-block;
}
a:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.error-container {
padding: 25px;
margin: 15px;
}
h1 {
font-size: 2.5em;
}
p {
font-size: 1em;
}
}
</style>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Error {{ error.code or 500 }}</title>
<meta name="theme-color" content="#0f1115" />
<link rel="preload" href="{{ url_for('static', filename='css/main.css') }}" as="style">
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
</head>
<body>
<div class="error-container">
<h1>Error {{ error.code }}</h1>
<p>{{ error.description }}</p>
<a href="/">← Return to Home Page</a>
</div>
<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 %}Unsupported media type
{% 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">
<button class="btn tiny" type="button" data-action="copy-text"
data-target="#error-dump">Copy</button>
</div>
</div>
</details>
</section>
</main>
<footer class="site-footer">
<div>&copy; 2025 <a href="https://www.linuxiarz.pl" target="_blank" rel="noopener">linuxiarz.pl</a></div>
<div class="meta">Your IP: <strong>{{ request.remote_addr if request else '' }}</strong></div>
</footer>
<div id="toast" role="status" aria-live="polite" aria-atomic="true"></div>
<script defer src="{{ url_for('static', filename='js/main.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/error.js') }}"></script>
</body>
</html>
</html>

View File

@@ -1,292 +1,152 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hosts Converter</title>
<style>
:root {
--bg-color: #1a1a1a;
--card-bg: #2d2d2d;
--text-color: #e0e0e0;
--accent: #007bff;
--accent-light: #4da6ff;
--border-color: #404040;
--link-color: #4da6ff;
}
[data-theme="light"] {
--bg-color: #f5f5f5;
--card-bg: #ffffff;
--text-color: #333333;
--border-color: #dddddd;
--link-color: #0066cc;
--accent: #0066cc;
--accent-light: #007bff;
}
* {
transition: background-color 0.3s, color 0.3s;
}
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
max-width: 800px;
margin: 20px auto;
padding: 20px;
line-height: 1.6;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.theme-toggle {
position: fixed;
top: 20px;
right: 20px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 8px 15px;
cursor: pointer;
color: var(--text-color);
}
h1 {
color: var(--accent);
margin-bottom: 30px;
text-align: center;
}
form {
background: var(--card-bg);
padding: 25px;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
flex: 1;
}
.form-group {
margin: 15px 0;
padding: 0 15px;
}
input[type="text"] {
width: calc(100% - 30px);
padding: 10px 15px;
margin: 8px 0;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-color);
}
button {
background: linear-gradient(135deg, var(--accent), var(--accent-light));
color: white;
padding: 12px 25px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0 15px;
}
button:hover {
opacity: 0.9;
}
.result-box {
background: var(--card-bg);
padding: 20px;
border-radius: 12px;
margin: 25px 15px;
border: 1px solid var(--border-color);
}
.recent-links {
margin: 35px 15px 0;
padding: 25px 15px 0;
border-top: 1px solid var(--border-color);
}
.link-item {
background: var(--card-bg);
padding: 15px;
margin: 12px 0;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.link-item:hover {
transform: translateX(5px);
transition: transform 0.2s;
}
.timestamp {
color: #888;
font-size: 0.85em;
}
a {
color: var(--link-color);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
footer {
text-align: center;
margin-top: 40px;
padding: 20px;
border-top: 1px solid var(--border-color);
color: #888;
}
footer a {
color: var(--link-color);
text-decoration: none;
}
footer a:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
body {
padding: 15px;
margin: 10px;
}
form {
padding: 15px 0;
}
.form-group {
padding: 0 10px;
}
input[type="text"] {
width: calc(100% - 20px);
padding: 10px;
}
button {
width: calc(100% - 20px);
padding: 15px;
margin: 0 10px;
}
.result-box {
margin: 25px 10px;
padding: 15px;
}
.recent-links {
margin: 35px 10px 0;
padding: 25px 10px 0;
}
}
.copy-btn {
position: relative;
}
.copy-btn::after {
content: "Copied!";
position: absolute;
background: var(--card-bg);
color: var(--text-color);
padding: 5px 10px;
border-radius: 4px;
right: -80px;
top: 50%;
transform: translateY(-50%);
opacity: 0;
transition: opacity 0.3s;
}
.copy-btn.copied::after {
opacity: 1;
}
</style>
<meta name="theme-color" content="#0f1115" />
<link rel="preload" href="{{ url_for('static', filename='css/main.css') }}" as="style">
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
</head>
<body data-theme="dark">
<button class="theme-toggle" onclick="toggleTheme()">🌓 Toggle Theme</button>
<h1>Hosts File Converter</h1>
<form method="GET" action="/">
<div class="form-group">
<label>URL to hosts file:</label>
<input type="text" name="url" required
placeholder="ex. https://paulgb.github.io/BarbBlock/blacklists/hosts-file.txt">
<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>
<div class="form-group">
<label>Target IP:</label>
<input type="text" name="ip" pattern="\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"
value="195.187.6.34" required>
</div>
<button type="submit">Generate convert link</button>
</form>
{% if generated_link %}
<div class="result-box">
<h3>Link to MikroTik/Adguard:</h3>
<input type="text" value="{{ generated_link }}" readonly>
<button class="copy-btn" onclick="copyToClipboard(this)">Copy link</button>
</div>
{% endif %}
<div class="recent-links">
<h3>Last converts:</h3>
{% if recent_links %}
{% for link_data in recent_links %}
<div class="link-item">
<div class="timestamp">{{ link_data[0]|datetimeformat }}</div>
<a href="/convert?url={{ link_data[1]|urlencode }}&ip={{ link_data[2] }}" target="_blank">
{{ link_data[1] }} → {{ link_data[2] }}
</a>
<main class="container">
<section class="card hero">
<div class="hero-text">
<h1>Convert adblock lists to MikroTik / hosts</h1>
<p class="muted">Paste a list URL (AdGuard/uBlock/hosts/dnsmasq), pick the target IP, and generate a
<kbd>/convert</kbd> link.
</p>
</div>
{% endfor %}
{% else %}
<p>Empty..</p>
{% endif %}
</div>
<div class="hero-cta">
<a class="btn primary large" href="#form">Start</a>
</div>
</section>
<footer>
&copy; 2025 <a href="https://www.linuxiarz.pl" target="_blank">linuxiarz.pl</a> - All rights reserved <br>
Your IP address: <strong>{{ client_ip }}</strong> | Your User Agent: <strong>{{ user_agent }}</strong>
<section id="form" class="card form-card" aria-labelledby="form-title">
<h2 id="form-title">Link generator</h2>
<form method="GET" action="/" novalidate>
<div class="grid">
<div class="form-group col-12">
<label for="url-input">URL to hosts/adblock list</label>
<input id="url-input" type="url" name="url" required placeholder="https://example.com/list.txt"
inputmode="url" autocomplete="url" aria-describedby="url-help" autofocus>
<small id="url-help" class="hint"> Supported formats: <code>||domain^</code>,
<code>address=/domain/</code>, hosts files, plain domains.</small>
<div class="error" data-error-for="url-input"></div>
</div>
<div class="form-group col-6">
<label for="ip-input">Target IP</label>
<input id="ip-input" type="text" name="ip" pattern="^\d{1,3}(?:\.\d{1,3}){3}$"
value="195.187.6.34" required inputmode="numeric" autocomplete="off"
aria-describedby="ip-help">
<small id="ip-help" class="hint">Common choices: <code>0.0.0.0</code>, <code>127.0.0.1</code>,
or your device IP.</small>
<div class="error" data-error-for="ip-input"></div>
</div>
<div class="form-group col-6">
<label for="ip-preset">Presets</label>
<select id="ip-preset" class="select">
<option value="">— choose preset —</option>
<option value="0.0.0.0">0.0.0.0 (blackhole)</option>
<option value="127.0.0.1">127.0.0.1 (localhost)</option>
<option value="195.187.6.34">195.187.6.34 (current)</option>
<option value="custom">Custom…</option>
</select>
</div>
<div class="form-actions col-12">
<button type="submit" class="btn primary">Generate convert link</button>
<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" 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">The preview updates live while you type.</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="/convert?url={{ link_data[1]|urlencode }}&ip={{ link_data[2] }}">Copy</button>
<a class="btn tiny outline"
href="/convert?url={{ link_data[1]|urlencode }}&ip={{ link_data[2] }}" target="_blank"
rel="noopener">Open</a>
</div>
</div>
</article>
{% endfor %}
{% else %}
<p class="muted">Empty..</p>
{% endif %}
</div>
</section>
</main>
<footer class="site-footer">
<div>&copy; 2025 <a href="https://www.linuxiarz.pl" target="_blank" rel="noopener">linuxiarz.pl</a> · All rights
reserved</div>
<div class="meta">Your IP: <strong>{{ client_ip }}</strong> · UA: <strong>{{ user_agent }}</strong></div>
</footer>
<div id="toast" role="status" aria-live="polite" aria-atomic="true"></div>
<script defer src="{{ url_for('static', filename='js/main.js') }}"></script>
<script>
function toggleTheme() {
const body = document.body;
body.setAttribute('data-theme',
body.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
localStorage.setItem('theme', body.getAttribute('data-theme'));
}
function copyToClipboard(btn) {
const copyText = document.querySelector("input[readonly]");
copyText.select();
document.execCommand("copy");
btn.classList.add('copied');
setTimeout(() => btn.classList.remove('copied'), 2000);
}
// Load saved theme
const savedTheme = localStorage.getItem('theme') || 'dark';
document.body.setAttribute('data-theme', savedTheme);
// no-flash theme bootstrap
(function () { const t = localStorage.getItem('theme') || 'dark'; document.documentElement.setAttribute('data-theme', t); })();
</script>
</body>
</html>
</html>

View File

@@ -1,70 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Hosts Converter</title>
<style>
body { font-family: Arial, sans-serif; max-width: 800px; margin: 20px auto; padding: 20px; }
form { background: #f5f5f5; padding: 20px; border-radius: 5px; }
input[type="text"] { width: 100%; padding: 8px; margin: 5px 0; }
.result-box { margin: 20px 0; padding: 15px; border: 1px solid #ddd; background: #fff; }
.recent-links { margin-top: 30px; border-top: 1px solid #eee; padding-top: 20px; }
.link-item { margin: 10px 0; padding: 10px; background: #f8f9fa; border-radius: 3px; }
.timestamp { color: #666; font-size: 0.9em; }
button { padding: 8px 15px; background: #007bff; color: white; border: none; border-radius: 3px; cursor: pointer; }
button:hover { background: #0056b3; }
</style>
</head>
<body>
<h1>Hosts File Converter</h1>
<form method="GET" action="/">
<p>
<label>URL to hosts file:<br>
<input type="text" name="url" required
placeholder="np. paulgb.github.io/BarbBlock/blacklists/hosts-file.txt">
</label>
</p>
<p>
<label>Target IP:
<input type="text" name="ip" pattern="\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"
value="195.187.6.34" required>
</label>
</p>
<button type="submit">Generate convert link</button>
</form>
{% if generated_link %}
<div class="result-box">
<h3>Link to MikroTik/Adguard:</h3>
<input type="text" value="{{ generated_link }}" readonly
style="width: 100%; padding: 8px; margin: 5px 0;">
<button onclick="copyToClipboard()">Copy link</button>
</div>
{% endif %}
<div class="recent-links">
<h3>Last converts:</h3>
{% if recent_links %}
{% for link_data in recent_links %}
<div class="link-item">
<div class="timestamp">{{ link_data[0]|datetimeformat }}</div>
<a href="/convert?url={{ link_data[1]|urlencode }}&ip={{ link_data[2] }}" target="_blank">
{{ link_data[1] }} → {{ link_data[2] }}
</a>
</div>
{% endfor %}
{% else %}
<p>Empty..</p>
{% endif %}
</div>
<script>
function copyToClipboard() {
const copyText = document.querySelector("input[readonly]");
copyText.select();
document.execCommand("copy");
alert("OK!");
}
</script>
</body>
</html>

View File

@@ -1,16 +1,216 @@
<!-- templates/stats.html -->
<!DOCTYPE html>
<html>
<html lang="en" data-theme="dark">
<head>
<title>Statistics</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stats</title>
<meta name="theme-color" content="#0f1115">
<link rel="preload" href="{{ url_for('static', filename='css/main.css') }}" as="style">
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
</head>
<body>
<h1>Download Statistics</h1>
<table>
<tr><th>URL</th><th>Hits</th></tr>
{% for url, count in stats.items() %}
<tr><td>{{ url }}</td><td>{{ count }}</td></tr>
{% endfor %}
</table>
<header class="site-header">
<div class="brand">
<svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
<path d="M4 4h16v4H4zM4 10h10v4H4zM4 16h16v4H4z" fill="currentColor" />
</svg>
<span>Hosts Converter</span>
</div>
<nav class="actions">
<button class="btn ghost" type="button" data-action="toggle-theme" aria-label="Toggle theme">🌓</button>
<a class="btn ghost" href="/" rel="nofollow">Home</a>
</nav>
</header>
<main class="container container--wide">
<!-- KPIs -->
<section class="card kpi-card">
<h2 class="section-title">Overview</h2>
<div class="kpi-grid">
<div class="kpi">
<div class="kpi-label">Convert requests</div>
<div class="kpi-value">{{ stats.get('stats:convert_requests', 0) }}</div>
</div>
<div class="kpi">
<div class="kpi-label">Successful conversions</div>
<div class="kpi-value">{{ stats.get('stats:conversions_success', 0) }}</div>
</div>
<div class="kpi">
<div class="kpi-label">Errors 4xx</div>
<div class="kpi-value">{{ stats.get('stats:errors_400', 0) }}</div>
</div>
<div class="kpi">
<div class="kpi-label">Errors 5xx</div>
<div class="kpi-value">{{ stats.get('stats:errors_500', 0) }}</div>
</div>
<div class="kpi">
<div class="kpi-label">Avg processing (s)</div>
<div class="kpi-value">{{ '%.3f' % detailed.processing_time_avg_sec }}</div>
</div>
<div class="kpi">
<div class="kpi-label">Avg content (bytes)</div>
<div class="kpi-value">{{ detailed.content_size_avg_bytes }}</div>
</div>
</div>
</section>
<!-- Recent converts -->
<section class="card">
<div class="section-head">
<h2>Recent converts (latest {{ recent|length }})</h2>
<div class="head-actions">
<input class="table-filter" type="search" placeholder="Filter…" data-action="filter-table"
data-target="#recent-table">
</div>
</div>
<div class="table-wrap">
<table id="recent-table" class="data-table">
<thead>
<tr>
<th>Time</th>
<th>URL</th>
<th>Target IP</th>
<th>Client</th>
<th>User agent</th>
<th></th>
</tr>
</thead>
<tbody>
{% for row in recent %}
{% set q = row.get('url','') %}
{% set parts = q.split('&ip=') %}
{% set url = parts[0].replace('/convert?url=', '') | urlencode %}
{% set ip = (parts[1] if parts|length > 1 else '') %}
<tr>
<td class="mono nowrap">{{ row.time|datetimeformat }}</td>
<td class="mono ellipsis" title="{{ url|safe }}">
{{ url|safe }}
</td>
<td class="mono">{{ ip }}</td>
<td class="mono ellipsis" title="{{ row.hostname }} ({{ row.ip }})">
{{ row.hostname }} ({{ row.ip }})
</td>
<td class="ellipsis" title="{{ row.user_agent }}">{{ row.user_agent }}</td>
<td class="actions">
<a class="btn tiny outline" href="{{ q }}" target="_blank" rel="noopener">Open</a>
<button class="btn tiny" data-action="copy-text" data-text="{{ q }}">Copy</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<!-- Top tables -->
<section class="card">
<div class="section-head">
<h2>Top sources</h2>
</div>
<div class="grid">
<div class="col-6">
<h3 class="subhead">Source URLs</h3>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>URL</th>
<th class="right">Hits</th>
</tr>
</thead>
<tbody>
{% for u, c in url_requests.items()|sort(attribute=1, reverse=True) %}
<tr>
<td class="mono ellipsis" title="{{ u }}">{{ u }}</td>
<td class="right">{{ c }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="col-6">
<h3 class="subhead">Target IPs</h3>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>IP</th>
<th class="right">Hits</th>
</tr>
</thead>
<tbody>
{% for ip, c in target_ips.items()|sort(attribute=1, reverse=True) %}
<tr>
<td class="mono">{{ ip }}</td>
<td class="right">{{ c }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="col-6">
<h3 class="subhead">User agents</h3>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>User agent</th>
<th class="right">Hits</th>
</tr>
</thead>
<tbody>
{% for ua, c in user_agents.items()|sort(attribute=1, reverse=True) %}
<tr>
<td class="ellipsis" title="{{ ua }}">{{ ua }}</td>
<td class="right">{{ c }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="col-6">
<h3 class="subhead">Client IPs</h3>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Client IP</th>
<th class="right">Hits</th>
</tr>
</thead>
<tbody>
{% for ip, c in client_ips.items()|sort(attribute=1, reverse=True) %}
<tr>
<td class="mono">{{ ip }}</td>
<td class="right">{{ c }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</section>
</main>
<footer class="site-footer">
<div>&copy; 2025 <a href="https://www.linuxiarz.pl" target="_blank" rel="noopener">linuxiarz.pl</a></div>
<div class="meta">Your IP: <strong>{{ request.remote_addr }}</strong></div>
</footer>
<div id="toast" role="status" aria-live="polite" aria-atomic="true"></div>
<script defer src="{{ url_for('static', filename='js/main.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/stats.js') }}"></script>
<script>(function () { const t = localStorage.getItem('theme') || 'dark'; document.documentElement.setAttribute('data-theme', t); })();</script>
</body>
</html>
</html>