Merge pull request 'refactor' (#1) from refactor into master

Reviewed-on: #1
This commit is contained in:
gru
2025-08-30 00:19:49 +02:00
17 changed files with 2197 additions and 1698 deletions

53
.env.example Normal file
View File

@@ -0,0 +1,53 @@
# Podstawy
FLASK_DEBUG=false
SECRET_KEY=change-me
# Limity
MAX_CONTENT_LENGTH=52428800
RATE_LIMIT_DEFAULT="100 per minute"
RATE_LIMIT_CONVERT="100 per minute"
# Redis wybierz REDIS_URL lub HOST/PORT/DB
REDIS_URL=redis://localhost:6379/7
# REDIS_HOST=localhost
# REDIS_PORT=6379
# REDIS_DB=7
# Basic Auth dla /stats
STATS_BASIC_AUTH_ENABLED=true
STATS_BASIC_AUTH_REALM=Stats
STATS_BASIC_AUTH_USER=admin
STATS_BASIC_AUTH_PASS=strong-password
# Cache (Varnish-friendly)
CACHE_ENABLED=true
CACHE_S_MAXAGE=43200
CACHE_MAX_AGE=3600
USE_REDIS_BODY_CACHE=false
# Stream / aiohttp
AIOHTTP_TOTAL_TIMEOUT=70
AIOHTTP_CONNECT_TIMEOUT=10
AIOHTTP_SOCK_CONNECT_TIMEOUT=10
AIOHTTP_SOCK_READ_TIMEOUT=60
READ_CHUNK=65536
STREAM_LINE_LIMIT=4096
# Serwer
BIND_HOST=127.0.0.1
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"

879
app.py

File diff suppressed because it is too large Load Diff

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)

70
config.py Normal file
View File

@@ -0,0 +1,70 @@
import os
def getenv_bool(key: str, default: bool = False) -> bool:
v = os.getenv(key)
if v is None:
return default
return v.lower() in ("1", "true", "t", "yes", "y", "on")
def getenv_int(key: str, default: int) -> int:
try:
return int(os.getenv(key, str(default)))
except ValueError:
return default
def getenv_float(key: str, default: float) -> float:
try:
return float(os.getenv(key, str(default)))
except ValueError:
return default
# Podstawowe
FLASK_DEBUG = getenv_bool("FLASK_DEBUG", True)
SECRET_KEY = os.getenv("SECRET_KEY", "secretkey")
# Rozmiary/limity
MAX_CONTENT_LENGTH = getenv_int("MAX_CONTENT_LENGTH", 50 * 1024 * 1024) # 50MB
RATE_LIMIT_DEFAULT = os.getenv("RATE_LIMIT_DEFAULT", "100 per minute")
RATE_LIMIT_CONVERT = os.getenv("RATE_LIMIT_CONVERT", "100 per minute")
# Redis
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/7")
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
REDIS_PORT = getenv_int("REDIS_PORT", 6379)
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").strip()
STATS_BASIC_AUTH_PASS = os.getenv("STATS_BASIC_AUTH_PASS", "admin").strip()
# Cache/ETag dla Varnisha
CACHE_ENABLED = getenv_bool("CACHE_ENABLED", True)
CACHE_S_MAXAGE = getenv_int("CACHE_S_MAXAGE", 43200) # 12h
CACHE_MAX_AGE = getenv_int("CACHE_MAX_AGE", 3600) # 1h
USE_REDIS_BODY_CACHE = getenv_bool("USE_REDIS_BODY_CACHE", False)
# AIOHTTP/stream
AIOHTTP_TOTAL_TIMEOUT = getenv_float("AIOHTTP_TOTAL_TIMEOUT", 70.0)
AIOHTTP_CONNECT_TIMEOUT = getenv_float("AIOHTTP_CONNECT_TIMEOUT", 10.0)
AIOHTTP_SOCK_CONNECT_TIMEOUT= getenv_float("AIOHTTP_SOCK_CONNECT_TIMEOUT", 10.0)
AIOHTTP_SOCK_READ_TIMEOUT = getenv_float("AIOHTTP_SOCK_READ_TIMEOUT", 60.0)
READ_CHUNK = getenv_int("READ_CHUNK", 64 * 1024) # 64 KiB
STREAM_LINE_LIMIT= getenv_int("STREAM_LINE_LIMIT", 4096)
# Serwer
BIND_HOST = os.getenv("BIND_HOST", "127.0.0.1")
BIND_PORT = getenv_int("BIND_PORT", 8283)
# Domyślny URL źródłowy (opcjonalny)
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,15 +1,22 @@
# /etc/systemd/system/listapp.service
[Unit]
Description=ListApp - Flask application for hosts file conversion
After=network.target redis.service
Description=Mikrotik Adlist - Flask application for hosts file conversion
After=network-online.target redis.service
Wants=network-online.target
[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/listapp
Environment="PATH=/var/www/listapp/venv/bin"
#ExecStart=/var/www/listapp/bin/gunicorn -w 2 --bind 127.0.0.1:8283 app:app
ExecStart=/var/www/listapp/bin/gunicorn -k uvicorn.workers.UvicornWorker -w 4 --bind 127.0.0.1:8283 app:asgi_app
WorkingDirectory=/var/www/adlist_mikrotik
EnvironmentFile=-/var/www/adlist_mikrotik/.env
Environment="PATH=/var/www/adlist_mikrotik/venv/bin"
ExecStart=/var/www/adlist_mikrotik/venv/bin/gunicorn \
-k uvicorn.workers.UvicornWorker \
--workers 4 \
--bind 127.0.0.1:8283 \
--keep-alive 30 \
--timeout 90 \
app:asgi_app
Restart=always
RestartSec=5

10
requirements.txt Normal file
View File

@@ -0,0 +1,10 @@
Flask
Flask-Compress
Flask-Limiter
redis
requests
aiohttp
asgiref
unicorn
gunicorn
uvicorn

3
start_dev.sh Normal file
View File

@@ -0,0 +1,3 @@
#/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) 90%, #000 10%));
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: var(--shadow);
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
border-bottom: 1px dashed var(--border)
}
/* Hero */
.hero {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px;
gap: 18px
}
.hero h1 {
margin: 0 0 6px;
font-size: clamp(22px, 3.4vw, 30px)
}
.hero .muted {
color: var(--muted)
}
.hero-cta .large {
font-size: 1.05rem;
padding: 14px 20px
}
/* Grid */
.grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 14px
}
.col-12 {
grid-column: span 12
}
.col-6 {
grid-column: span 6
}
@media (max-width:720px) {
.col-6 {
grid-column: span 12
}
}
/* Form */
.form-card {
padding: 16px
}
.form-group label {
display: block;
font-weight: 600;
margin-bottom: 6px
}
input[type="url"],
input[type="text"],
select {
width: 100%;
padding: 12px 14px;
border-radius: 12px;
background: linear-gradient(0deg, var(--bg-elev), var(--bg-elev));
border: 1px solid var(--border);
color: var(--text);
outline: none;
transition: border .15s, box-shadow .15s, transform .05s;
}
input:focus,
select:focus {
border-color: color-mix(in srgb, var(--brand) 60%, var(--border) 40%);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 30%, transparent);
}
.hint {
color: var(--muted);
display: block;
margin-top: 6px;
font-size: .9rem
}
.error {
color: var(--danger);
min-height: 1.2em;
margin-top: 6px;
font-size: .9rem
}
.form-actions {
display: flex;
gap: 10px;
align-items: center
}
/* 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
}
}

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

@@ -0,0 +1,9 @@
(function () {
const t = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', t);
document.querySelector('[data-action="try-again"]')?.addEventListener('click', () => {
location.reload();
});
})();

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

@@ -0,0 +1,279 @@
(function () {
const $ = (q, c = document) => c.querySelector(q);
const $$ = (q, c = document) => Array.from(c.querySelectorAll(q));
// --- theme ---
const setTheme = (t) => {
document.documentElement.setAttribute('data-theme', t);
try { localStorage.setItem('theme', t) } catch { }
};
window.setTheme = setTheme;
const toast = (msg) => {
const el = $('#toast'); if (!el) return;
el.textContent = msg; el.classList.add('show');
clearTimeout(el._t); el._t = setTimeout(() => el.classList.remove('show'), 2000);
};
const host = () => `${location.protocol}//${location.host}`;
// --- IP validators ---
function isValidIPv4(ip) {
if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(ip)) return false;
return ip.split('.').every(oct => {
if (oct.length > 1 && oct[0] === '0') return oct === '0';
const n = Number(oct);
return n >= 0 && n <= 255;
});
}
function isValidIPv6(ip) {
let work = ip;
const idx = work.lastIndexOf(':');
if (idx !== -1) {
const tail = work.slice(idx + 1);
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(tail)) {
if (!isValidIPv4(tail)) return false;
work = work.slice(0, idx) + ':0:0';
}
}
if (work.split('::').length > 2) return false;
const hasCompress = work.includes('::');
const parts = work.split(':').filter(Boolean);
if ((!hasCompress && parts.length !== 8) || (hasCompress && parts.length > 7)) return false;
return parts.every(g => /^[0-9a-fA-F]{1,4}$/.test(g));
}
function isValidIP(ip) {
const v = (ip || '').trim();
return isValidIPv4(v) || isValidIPv6(v);
}
// --- URL helpers ---
function normalizeUrlMaybe(v) {
const raw = (v || '').trim();
if (!raw) return '';
try {
const test = raw.includes('://') ? raw : `https://${raw}`;
const u = new URL(test);
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('scheme');
return u.toString();
} catch {
return '';
}
}
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 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');
}
function updatePreview() {
const rawUrl = (urlInput?.value || '').trim();
const ip = (ipInput?.value || '').trim();
if (!ip || !isValidIP(ip)) {
if (out) out.value = '';
if (openBtn) {
openBtn.setAttribute('href', '#');
openBtn.setAttribute('aria-disabled', 'true');
}
$('.result-box')?.setAttribute('data-state', 'empty');
return;
}
const normalized = normalizeUrlMaybe(rawUrl); // poprawny http/https lub ''
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');
}
$('.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');
} else {
openBtn.setAttribute('href', '#');
openBtn.setAttribute('aria-disabled', 'true');
}
}
$('.result-box')?.setAttribute('data-state', ok ? 'ready' : 'empty');
}
// live update
['input', 'change', 'blur'].forEach(evt => {
urlInput?.addEventListener(evt, updatePreview);
ipInput?.addEventListener(evt, updatePreview);
});
// presets
ipPreset?.addEventListener('change', () => {
const v = ipPreset.value;
if (!v) return;
if (v !== 'custom') ipInput.value = v;
ipInput.focus();
const ok = isValidIP(ipInput.value.trim());
showError(ipInput, ok ? '' : 'Invalid IP address');
updatePreview();
});
// event delegation
document.addEventListener('click', (e) => {
let t = e.target;
if (t.closest('[data-action="copy"]')) {
e.preventDefault();
const btn = t.closest('[data-action="copy"]');
const sel = btn.getAttribute('data-target') || '#generated-link';
const el = $(sel);
if (!el) return;
const text = el.value || el.textContent || '';
navigator.clipboard?.writeText(text).then(() => {
btn.classList.add('copied'); setTimeout(() => btn.classList.remove('copied'), 1200);
toast('Link copied');
}).catch(() => {
const range = document.createRange(); range.selectNodeContents(el);
const selObj = getSelection(); selObj.removeAllRanges(); selObj.addRange(range);
try { document.execCommand('copy'); toast('Link copied'); } catch { }
selObj.removeAllRanges();
});
}
if (t.closest('[data-action="copy-text"]')) {
e.preventDefault();
const btn = t.closest('[data-action="copy-text"]');
let text = btn.getAttribute('data-text') || '';
if (!text) return;
if (text.startsWith('/')) text = host() + text;
navigator.clipboard?.writeText(text).then(() => toast('Copied'));
}
if (t.closest('[data-action="clear"]')) {
e.preventDefault();
urlInput.value = '';
if (ipInput) ipInput.value = '';
if (ipPreset) ipPreset.value = '';
showError(urlInput, '');
showError(ipInput, '');
updatePreview();
urlInput.focus();
}
if (t.closest('[data-action="collapse"]')) {
e.preventDefault();
const btn = t.closest('[data-action="collapse"]');
const panel = $('#' + (btn.getAttribute('aria-controls') || ''));
if (!panel) return;
const expanded = btn.getAttribute('aria-expanded') === 'true';
const next = !expanded;
btn.setAttribute('aria-expanded', next ? 'true' : 'false');
panel.hidden = !next;
panel.style.display = next ? '' : 'none';
const newLabel = next ? 'Collapse' : 'Expand';
btn.textContent = newLabel;
btn.setAttribute('aria-label', newLabel);
}
});
// field-level validation
urlInput?.addEventListener('blur', () => {
const raw = urlInput.value.trim();
if (!raw) return showError(urlInput, '');
const normalized = normalizeUrlMaybe(raw);
showError(urlInput, normalized ? '' : 'Invalid URL');
});
ipInput?.addEventListener('blur', () => {
const v = ipInput.value.trim();
if (!v) return showError(ipInput, '');
const ok = isValidIP(v);
showError(ipInput, ok ? '' : 'Invalid IP address');
if (!ok) $('.result-box')?.setAttribute('data-state', 'empty');
});
// init (preview + recent-list sync)
(function init() {
const serverLink = out?.value?.trim();
if (serverLink) {
$('.result-box')?.setAttribute('data-state', 'ready');
openBtn?.setAttribute('aria-disabled', 'false');
} else {
updatePreview();
}
})();
document.addEventListener('DOMContentLoaded', () => {
const btn = document.querySelector('[data-action="collapse"]');
const panelId = btn?.getAttribute('aria-controls') || '';
const panel = panelId ? document.getElementById(panelId) : null;
if (!btn || !panel) return;
const expanded = btn.getAttribute('aria-expanded') === 'true';
panel.hidden = !expanded;
panel.style.display = expanded ? '' : 'none';
const label = expanded ? 'Collapse' : 'Expand';
btn.textContent = label;
btn.setAttribute('aria-label', label);
});
form?.addEventListener('submit', (e) => e.preventDefault());
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
const text = out?.value?.trim(); if (!text) return;
navigator.clipboard?.writeText(text).then(() => toast('Link copied'));
}
});
})();
// --- 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);

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,86 @@
<!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>
<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">
</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>

View File

@@ -1,292 +1,151 @@
<!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>
<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>
<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">
<main class="container">
<section class="card hero">
<div class="hero-text">
<h1>Convert adblock lists to MikroTik / hosts</h1>
<p class="muted">Paste a list URL (AdGuard/uBlock/hosts/dnsmasq), pick the target IP, and generate a
<kbd>/convert</kbd> link.
</p>
</div>
<div class="hero-cta">
<a class="btn primary large" href="#form">Start</a>
</div>
</section>
<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">
<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 class="form-group col-6">
<label for="ip-input">Target IP</label>
<input id="ip-input" type="text" name="ip" value="195.187.6.34" required inputmode="text"
autocomplete="off" aria-describedby="ip-help" spellcheck="false">
<small id="ip-help" class="hint">Common choices: <code>0.0.0.0</code>, <code>127.0.0.1</code>,
your device IP, supports IPv4 and IPv6.</small>
<div class="error" data-error-for="ip-input"></div>
</div>
<button type="submit">Generate convert link</button>
<div class="form-group col-6">
<label for="ip-preset">Presets</label>
<select id="ip-preset" class="select">
<option value="">— choose preset —</option>
<option value="0.0.0.0">0.0.0.0 (blackhole)</option>
<option value="127.0.0.1">127.0.0.1 (localhost)</option>
<option value="195.187.6.34">195.187.6.34 (current)</option>
<option value="custom">Custom…</option>
</select>
</div>
<div class="form-actions col-12">
<button class="btn ghost" type="button" data-action="clear">Clear</button>
</div>
</div>
</form>
{% 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 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>
{% endif %}
</div>
<small class="hint"><strong>Paste this link in your Mikrotik (IP -> DNS -> Adlist) or other DNS server / ad blocking tool</strong></small>
</div>
</section>
<div class="recent-links">
<h3>Last converts:</h3>
<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 %}
<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] }}
<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>Empty..</p>
<p class="muted">Empty..</p>
{% endif %}
</div>
</section>
</main>
<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>
<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>

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>
<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>