refactor
This commit is contained in:
41
.env.example
Normal file
41
.env.example
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# 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"
|
538
app.py
538
app.py
@@ -1,203 +1,224 @@
|
|||||||
import re
|
import re
|
||||||
import redis
|
import redis
|
||||||
import requests
|
import requests
|
||||||
import aiohttp
|
|
||||||
import asyncio
|
|
||||||
import socket
|
import socket
|
||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from flask import Flask, request, render_template, abort, jsonify, g
|
|
||||||
from urllib.parse import urlparse, quote, unquote, urljoin
|
from urllib.parse import urlparse, quote, unquote, urljoin
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from flask import Flask, request, render_template, abort, jsonify, stream_with_context, g, Response
|
||||||
from flask_compress import Compress
|
from flask_compress import Compress
|
||||||
from flask_limiter import Limiter
|
from flask_limiter import Limiter
|
||||||
from flask_limiter.util import get_remote_address
|
|
||||||
|
import config
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config['MAX_CONTENT_LENGTH'] = 50 * 1024 * 1024 # limit
|
app.config["MAX_CONTENT_LENGTH"] = config.MAX_CONTENT_LENGTH
|
||||||
redis_client = redis.Redis(host='localhost', port=6379, db=7)
|
app.config["SECRET_KEY"] = config.SECRET_KEY
|
||||||
|
app.debug = config.FLASK_DEBUG
|
||||||
|
|
||||||
|
def build_redis():
|
||||||
|
if config.REDIS_URL:
|
||||||
|
return redis.Redis.from_url(config.REDIS_URL)
|
||||||
|
return redis.Redis(host=config.REDIS_HOST, port=config.REDIS_PORT, db=config.REDIS_DB)
|
||||||
|
|
||||||
|
redis_client = build_redis()
|
||||||
|
|
||||||
# Ustawienia do rate limiting – 100 żądań na minutę
|
|
||||||
def get_client_ip():
|
def get_client_ip():
|
||||||
"""Pobranie prawdziwego adresu IP klienta (uwzględniając proxy)"""
|
xff = request.headers.get("X-Forwarded-For", "").split(",")
|
||||||
x_forwarded_for = request.headers.get('X-Forwarded-For', '').split(',')
|
if xff and xff[0].strip():
|
||||||
if x_forwarded_for and x_forwarded_for[0].strip():
|
return xff[0].strip()
|
||||||
return x_forwarded_for[0].strip()
|
|
||||||
return request.remote_addr
|
return request.remote_addr
|
||||||
|
|
||||||
limiter = Limiter(key_func=get_client_ip, default_limits=["100 per minute"], app=app)
|
limiter = Limiter(
|
||||||
Compress(app)
|
key_func=get_client_ip,
|
||||||
|
app=app,
|
||||||
|
default_limits=[config.RATE_LIMIT_DEFAULT],
|
||||||
|
storage_uri=config.REDIS_URL
|
||||||
|
)
|
||||||
|
|
||||||
ALLOWED_IPS = {'127.0.0.1', '109.173.163.139'}
|
Compress(app)
|
||||||
ALLOWED_DOMAIN = ''
|
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def track_request_data():
|
def track_request_data():
|
||||||
"""Rejestracja IP klienta, User-Agent, metody HTTP oraz rozpoczęcie pomiaru czasu requestu"""
|
g.start_time = time.perf_counter()
|
||||||
g.start_time = time.perf_counter() # rozpoczęcie pomiaru czasu
|
redis_client.incr(f"stats:user_agents:{quote(request.headers.get('User-Agent', 'Unknown'), safe='')}")
|
||||||
client_ip = get_client_ip()
|
redis_client.incr(f"stats:client_ips:{get_client_ip()}")
|
||||||
user_agent = request.headers.get('User-Agent', 'Unknown')
|
redis_client.incr(f"stats:methods:{request.method}")
|
||||||
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
|
@app.after_request
|
||||||
def after_request(response):
|
def after_request(response):
|
||||||
"""Pomiar i rejestracja czasu przetwarzania żądania"""
|
|
||||||
elapsed = time.perf_counter() - g.start_time
|
elapsed = time.perf_counter() - g.start_time
|
||||||
# Aktualizacja statystyk czasu przetwarzania (w sekundach)
|
redis_client.incrbyfloat("stats:processing_time_total", elapsed)
|
||||||
redis_client.incrbyfloat('stats:processing_time_total', elapsed)
|
redis_client.incr("stats:processing_time_count")
|
||||||
redis_client.incr('stats:processing_time_count')
|
|
||||||
|
|
||||||
# Aktualizacja minimalnego czasu przetwarzania
|
|
||||||
try:
|
try:
|
||||||
current_min = float(redis_client.get('stats:processing_time_min') or elapsed)
|
current_min = float(redis_client.get("stats:processing_time_min") or elapsed)
|
||||||
if elapsed < current_min:
|
if elapsed < current_min:
|
||||||
redis_client.set('stats:processing_time_min', elapsed)
|
redis_client.set("stats:processing_time_min", elapsed)
|
||||||
except Exception:
|
except Exception:
|
||||||
redis_client.set('stats:processing_time_min', elapsed)
|
redis_client.set("stats:processing_time_min", elapsed)
|
||||||
|
|
||||||
# Aktualizacja maksymalnego czasu przetwarzania
|
|
||||||
try:
|
try:
|
||||||
current_max = float(redis_client.get('stats:processing_time_max') or elapsed)
|
current_max = float(redis_client.get("stats:processing_time_max") or elapsed)
|
||||||
if elapsed > current_max:
|
if elapsed > current_max:
|
||||||
redis_client.set('stats:processing_time_max', elapsed)
|
redis_client.set("stats:processing_time_max", elapsed)
|
||||||
except Exception:
|
except Exception:
|
||||||
redis_client.set('stats:processing_time_max', elapsed)
|
redis_client.set("stats:processing_time_max", elapsed)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@app.template_filter('datetimeformat')
|
@app.template_filter("datetimeformat")
|
||||||
def datetimeformat_filter(value, format='%Y-%m-%d %H:%M'):
|
def datetimeformat_filter(value, format="%Y-%m-%d %H:%M"):
|
||||||
try:
|
try:
|
||||||
dt = datetime.fromisoformat(value)
|
dt = datetime.fromisoformat(value)
|
||||||
return dt.strftime(format)
|
return dt.strftime(format)
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def ip_restriction(f):
|
def basic_auth_required(realm: str, user: str, password: str):
|
||||||
@wraps(f)
|
def decorator(f):
|
||||||
def decorated(*args, **kwargs):
|
@wraps(f)
|
||||||
client_ip = get_client_ip()
|
def wrapper(*args, **kwargs):
|
||||||
host = request.host.split(':')[0]
|
if not config.STATS_BASIC_AUTH_ENABLED:
|
||||||
|
return f(*args, **kwargs)
|
||||||
allowed_conditions = [
|
auth = request.headers.get("Authorization", "")
|
||||||
client_ip in ALLOWED_IPS,
|
if auth.startswith("Basic "):
|
||||||
host == ALLOWED_DOMAIN,
|
try:
|
||||||
request.headers.get('X-Forwarded-For', '').split(',')[0].strip() in ALLOWED_IPS
|
decoded = base64.b64decode(auth[6:]).decode("utf-8", errors="ignore")
|
||||||
]
|
u, p = decoded.split(":", 1)
|
||||||
|
if u == user and p == password:
|
||||||
if any(allowed_conditions):
|
return f(*args, **kwargs)
|
||||||
return f(*args, **kwargs)
|
except Exception:
|
||||||
redis_client.incr('stats:errors_403')
|
pass
|
||||||
abort(403)
|
resp = Response(status=401)
|
||||||
return decorated
|
resp.headers["WWW-Authenticate"] = f'Basic realm="{realm}"'
|
||||||
|
return resp
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
def cache_key(source_url, ip):
|
def cache_key(source_url, ip):
|
||||||
return f"cache:{source_url}:{ip}"
|
return f"cache:{source_url}:{ip}"
|
||||||
|
|
||||||
def should_ignore_domain(domain):
|
def should_ignore_domain(domain):
|
||||||
"""Sprawdza, czy domena zaczyna się od kropki i powinna być ignorowana."""
|
return domain.startswith(".") or any(ch in domain for ch in ["~", "=", "$", "'", "^", "_", ">", "<", ":"])
|
||||||
return domain.startswith('.') or any(char in domain for char in ['~', '=', '$', "'", "^", "_", ">", "<", ":"])
|
|
||||||
|
|
||||||
def should_ignore_line(line):
|
def should_ignore_line(line):
|
||||||
"""Sprawdza, czy linia zawiera określone znaki i powinna być ignorowana."""
|
return any(sym in line for sym in ["<", ">", "##", "###", "div", "span"])
|
||||||
return any(symbol in line for symbol in ['<', '>', '##', '###', "div", "span"])
|
|
||||||
|
|
||||||
def is_valid_domain(domain):
|
def is_valid_domain(domain):
|
||||||
"""Sprawdza, czy domena ma poprawną składnię."""
|
return bool(re.compile(r"^(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$").match(domain))
|
||||||
domain_regex = re.compile(r'^(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$')
|
|
||||||
return bool(domain_regex.match(domain))
|
|
||||||
|
|
||||||
def convert_hosts(content, target_ip):
|
def convert_host_line(line: str, target_ip: str):
|
||||||
"""Konwersja treści pliku hosts oraz reguł AdGuard DNS."""
|
# szybkie odrzucenia
|
||||||
converted = []
|
if not line:
|
||||||
invalid_lines = []
|
return None
|
||||||
|
line = line.strip()
|
||||||
|
|
||||||
for line in content.splitlines():
|
# komentarze/puste
|
||||||
line = line.strip()
|
if not line or line.startswith(("!", "#", "/", ";")):
|
||||||
|
return None
|
||||||
|
|
||||||
# Pomijanie pustych linii, komentarzy i linii do ignorowania
|
# wytnij komentarz końcowy (# lub ;) – ostrożnie ze 'http://'
|
||||||
if not line or line[0] in ('!', '#', '/') or should_ignore_line(line):
|
# usuwamy wszystko od ' #' lub ' ;' (spacja przed znacznikiem komentarza)
|
||||||
continue
|
for sep in (" #", " ;"):
|
||||||
|
idx = line.find(sep)
|
||||||
|
if idx != -1:
|
||||||
|
line = line[:idx].rstrip()
|
||||||
|
|
||||||
# Obsługa reguł AdGuard DNS
|
if not line:
|
||||||
match = re.match(r'^\|\|([^\^]+)\^.*', line)
|
return None
|
||||||
if match:
|
|
||||||
domain = match.group(1)
|
|
||||||
if should_ignore_domain(domain):
|
|
||||||
continue
|
|
||||||
if not is_valid_domain(domain):
|
|
||||||
invalid_lines.append(line)
|
|
||||||
continue
|
|
||||||
converted.append(f"{target_ip} {domain}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Obsługa klasycznego formatu hosts
|
# 1) AdGuard / uBlock DNS: ||domain^ (opcjonalnie z dodatkami po '^')
|
||||||
parts = line.split()
|
m = re.match(r"^\|\|([a-z0-9.-]+)\^", line, re.IGNORECASE)
|
||||||
if len(parts) > 1:
|
if m:
|
||||||
domain_part = parts[1]
|
domain = m.group(1).strip(".")
|
||||||
if should_ignore_domain(domain_part):
|
if not should_ignore_domain(domain) and is_valid_domain(domain):
|
||||||
continue
|
return f"{target_ip} {domain}"
|
||||||
if not is_valid_domain(domain_part):
|
return None
|
||||||
invalid_lines.append(line)
|
|
||||||
continue
|
|
||||||
converted.append(re.sub(r'^\S+', target_ip, line, count=1))
|
|
||||||
|
|
||||||
if invalid_lines:
|
|
||||||
print("Niepoprawne linie:")
|
|
||||||
for invalid in invalid_lines:
|
|
||||||
print(invalid)
|
|
||||||
|
|
||||||
return '\n'.join(converted)
|
|
||||||
|
|
||||||
|
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]
|
||||||
|
):
|
||||||
|
domain = parts[1].strip().split("#", 1)[0].strip().strip(".")
|
||||||
|
if not should_ignore_domain(domain) and is_valid_domain(domain):
|
||||||
|
return f"{target_ip} {domain}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 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(".")
|
||||||
|
if not should_ignore_domain(domain) and is_valid_domain(domain):
|
||||||
|
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}"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_etag(up_etag: Optional[str], up_lastmod: Optional[str], target_ip: str) -> str:
|
||||||
|
base = (up_etag or up_lastmod or "no-upstream") + f"::{target_ip}::v1"
|
||||||
|
return 'W/"' + hashlib.sha1(base.encode("utf-8")).hexdigest() + '"'
|
||||||
|
|
||||||
|
def cache_headers(etag: str, up_lm: Optional[str]):
|
||||||
|
headers = {
|
||||||
|
"ETag": etag,
|
||||||
|
"Vary": "Accept-Encoding",
|
||||||
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"Content-Disposition": "inline; filename=converted_hosts.txt",
|
||||||
|
}
|
||||||
|
if config.CACHE_ENABLED:
|
||||||
|
headers["Cache-Control"] = f"public, s-maxage={config.CACHE_S_MAXAGE}, max-age={config.CACHE_MAX_AGE}"
|
||||||
|
else:
|
||||||
|
headers["Cache-Control"] = "no-store"
|
||||||
|
if up_lm:
|
||||||
|
headers["Last-Modified"] = up_lm
|
||||||
|
return headers
|
||||||
|
|
||||||
def validate_and_normalize_url(url):
|
def validate_and_normalize_url(url):
|
||||||
"""Walidacja i normalizacja adresu URL"""
|
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
if not parsed.scheme:
|
if not parsed.scheme:
|
||||||
url = f'https://{url}'
|
url = f"https://{url}"
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
if not parsed.netloc:
|
if not parsed.netloc:
|
||||||
raise ValueError("Missing host in URL")
|
raise ValueError("Missing host in URL")
|
||||||
return parsed.geturl()
|
return parsed.geturl()
|
||||||
|
|
||||||
def track_url_request(url):
|
def track_url_request(url):
|
||||||
"""Rejestracja żądania dla określonego URL"""
|
redis_client.incr(f"stats:url_requests:{quote(url, safe='')}")
|
||||||
redis_key = f"stats:url_requests:{quote(url, safe='')}"
|
|
||||||
redis_client.incr(redis_key)
|
|
||||||
|
|
||||||
def add_recent_link(url, target_ip):
|
def add_recent_link(url, target_ip):
|
||||||
"""Dodanie ostatniego linku do historii (ostatnie 10)"""
|
ts = datetime.now().isoformat()
|
||||||
timestamp = datetime.now().isoformat()
|
link_data = f"{ts}|{url}|{target_ip}"
|
||||||
link_data = f"{timestamp}|{url}|{target_ip}"
|
|
||||||
|
|
||||||
with redis_client.pipeline() as pipe:
|
with redis_client.pipeline() as pipe:
|
||||||
pipe.lpush("recent_links", link_data)
|
pipe.lpush("recent_links", link_data)
|
||||||
pipe.ltrim("recent_links", 0, 9)
|
pipe.ltrim("recent_links", 0, 9)
|
||||||
pipe.execute()
|
pipe.execute()
|
||||||
redis_client.incr('stats:recent_links_added')
|
redis_client.incr("stats:recent_links_added")
|
||||||
|
|
||||||
def get_recent_links():
|
def get_recent_links():
|
||||||
"""Pobranie ostatnich 10 linków"""
|
|
||||||
links = redis_client.lrange("recent_links", 0, 9)
|
links = redis_client.lrange("recent_links", 0, 9)
|
||||||
parsed_links = []
|
out = []
|
||||||
for link in links:
|
for link in links:
|
||||||
parts = link.decode().split("|")
|
parts = link.decode().split("|")
|
||||||
if len(parts) >= 3:
|
if len(parts) >= 3:
|
||||||
parsed_links.append((parts[0], parts[1], parts[2]))
|
out.append((parts[0], parts[1], parts[2]))
|
||||||
elif len(parts) == 2:
|
elif len(parts) == 2:
|
||||||
parsed_links.append((parts[0], parts[1], "127.0.0.1"))
|
out.append((parts[0], parts[1], "127.0.0.1"))
|
||||||
return parsed_links
|
return out
|
||||||
|
|
||||||
def get_hostname(ip):
|
def get_hostname(ip):
|
||||||
"""Cache’owanie wyników reverse DNS dla danego IP"""
|
|
||||||
key = f"reverse_dns:{ip}"
|
key = f"reverse_dns:{ip}"
|
||||||
cached = redis_client.get(key)
|
cached = redis_client.get(key)
|
||||||
if cached:
|
if cached:
|
||||||
@@ -206,202 +227,215 @@ def get_hostname(ip):
|
|||||||
hostname = socket.gethostbyaddr(ip)[0]
|
hostname = socket.gethostbyaddr(ip)[0]
|
||||||
except Exception:
|
except Exception:
|
||||||
hostname = ip
|
hostname = ip
|
||||||
# Cache na 1 godzinę
|
|
||||||
redis_client.setex(key, 3600, hostname)
|
redis_client.setex(key, 3600, hostname)
|
||||||
return hostname
|
return hostname
|
||||||
|
|
||||||
# Nowa funkcja do logowania requestów dla endpointu /convert
|
|
||||||
def add_recent_convert():
|
def add_recent_convert():
|
||||||
"""Dodaje dane żądania do listy ostatnich konwersji (/convert)"""
|
|
||||||
ip = get_client_ip()
|
ip = get_client_ip()
|
||||||
hostname = get_hostname(ip)
|
hostname = get_hostname(ip)
|
||||||
user_agent = request.headers.get('User-Agent', 'Unknown')
|
ua = request.headers.get("User-Agent", "Unknown")
|
||||||
time_str = datetime.now().astimezone().isoformat()
|
time_str = datetime.now().astimezone().isoformat()
|
||||||
url = request.full_path # pełna ścieżka wraz z query string
|
url = request.full_path
|
||||||
data = {
|
data = {"url": url, "ip": ip, "hostname": hostname, "time": time_str, "user_agent": ua}
|
||||||
"url": url,
|
redis_client.lpush("recent_converts", json.dumps(data))
|
||||||
"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)
|
redis_client.ltrim("recent_converts", 0, 49)
|
||||||
|
|
||||||
@app.route('/', methods=['GET'])
|
@app.route("/favicon.ico", methods=["GET"])
|
||||||
|
def favicon():
|
||||||
|
return Response(status=204)
|
||||||
|
|
||||||
|
@app.route("/", methods=["GET"])
|
||||||
def index():
|
def index():
|
||||||
"""Strona główna z formularzem"""
|
|
||||||
generated_link = None
|
generated_link = None
|
||||||
recent_links = get_recent_links()
|
recent_links = get_recent_links()
|
||||||
url_param = request.args.get('url')
|
url_param = request.args.get("url", config.DEFAULT_SOURCE_URL)
|
||||||
target_ip = request.args.get('ip', '127.0.0.1')
|
target_ip = request.args.get("ip", "127.0.0.1")
|
||||||
|
|
||||||
client_ip = get_client_ip()
|
|
||||||
user_agent = request.headers.get('User-Agent', 'Unknown')
|
|
||||||
|
|
||||||
if url_param:
|
if url_param:
|
||||||
try:
|
try:
|
||||||
normalized_url = validate_and_normalize_url(unquote(url_param))
|
normalized = validate_and_normalize_url(unquote(url_param))
|
||||||
encoded_url = quote(normalized_url, safe='')
|
encoded = quote(normalized, safe="")
|
||||||
generated_link = urljoin(
|
generated_link = urljoin(request.host_url, f"convert?url={encoded}&ip={target_ip}")
|
||||||
request.host_url,
|
add_recent_link(normalized, target_ip)
|
||||||
f"convert?url={encoded_url}&ip={target_ip}"
|
|
||||||
)
|
|
||||||
add_recent_link(normalized_url, target_ip)
|
|
||||||
recent_links = get_recent_links()
|
recent_links = get_recent_links()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
app.logger.error(f"Error processing URL: {str(e)}")
|
app.logger.error(f"Error processing URL: {str(e)}")
|
||||||
|
|
||||||
return render_template('form.html',
|
|
||||||
generated_link=generated_link,
|
|
||||||
recent_links=recent_links,
|
|
||||||
client_ip=client_ip,
|
|
||||||
user_agent=user_agent)
|
|
||||||
@app.route('/convert')
|
|
||||||
@limiter.limit("100 per minute")
|
|
||||||
async def convert():
|
|
||||||
"""Asynchroniczny endpoint do konwersji z weryfikacją typu zawartości"""
|
|
||||||
try:
|
try:
|
||||||
redis_client.incr('stats:convert_requests')
|
return render_template(
|
||||||
# Logowanie danych dla requestu do /convert
|
"form.html",
|
||||||
|
generated_link=generated_link,
|
||||||
|
recent_links=recent_links,
|
||||||
|
client_ip=get_client_ip(),
|
||||||
|
user_agent=request.headers.get("User-Agent", "Unknown"),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"generated_link": generated_link,
|
||||||
|
"recent_links": recent_links,
|
||||||
|
"client_ip": get_client_ip(),
|
||||||
|
"user_agent": request.headers.get("User-Agent", "Unknown"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/convert")
|
||||||
|
@limiter.limit(config.RATE_LIMIT_CONVERT)
|
||||||
|
def convert():
|
||||||
|
try:
|
||||||
|
redis_client.incr("stats:convert_requests")
|
||||||
add_recent_convert()
|
add_recent_convert()
|
||||||
|
|
||||||
encoded_url = request.args.get('url')
|
encoded_url = request.args.get("url")
|
||||||
if not encoded_url:
|
if not encoded_url:
|
||||||
redis_client.incr('stats:errors_400')
|
redis_client.incr("stats:errors_400")
|
||||||
abort(400, description="Missing URL parameter")
|
abort(400, description="Missing URL parameter")
|
||||||
|
|
||||||
decoded_url = unquote(encoded_url)
|
decoded_url = unquote(encoded_url)
|
||||||
normalized_url = validate_and_normalize_url(decoded_url)
|
normalized_url = validate_and_normalize_url(decoded_url)
|
||||||
target_ip = request.args.get('ip', '127.0.0.1')
|
target_ip = request.args.get("ip", "127.0.0.1")
|
||||||
|
|
||||||
# Rejestracja statystyk dotyczących URL
|
|
||||||
track_url_request(normalized_url)
|
track_url_request(normalized_url)
|
||||||
redis_client.incr(f'stats:target_ips:{target_ip}')
|
redis_client.incr(f"stats:target_ips:{target_ip}")
|
||||||
|
|
||||||
# Sprawdzenie pamięci podręcznej
|
req_headers = {}
|
||||||
cached = redis_client.get(cache_key(normalized_url, target_ip))
|
inm = request.headers.get("If-None-Match")
|
||||||
if cached:
|
ims = request.headers.get("If-Modified-Since")
|
||||||
redis_client.incr('stats:cache_hits')
|
if inm:
|
||||||
return cached.decode('utf-8'), 200, {'Content-Type': 'text/plain'}
|
req_headers["If-None-Match"] = inm
|
||||||
|
if ims:
|
||||||
|
req_headers["If-Modified-Since"] = ims
|
||||||
|
|
||||||
redis_client.incr('stats:cache_misses')
|
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")
|
||||||
|
|
||||||
# Asynchroniczne pobranie zasobu za pomocą aiohttp
|
if r.status_code == 304:
|
||||||
async with aiohttp.ClientSession() as session:
|
etag = build_etag(r.headers.get("ETag"), r.headers.get("Last-Modified"), target_ip)
|
||||||
async with session.get(normalized_url, timeout=60) as response:
|
resp = Response(status=304)
|
||||||
# Sprawdzanie typu zawartości – musi zawierać "text"
|
resp.headers.update(cache_headers(etag, r.headers.get("Last-Modified")))
|
||||||
content_type = response.headers.get("Content-Type", "")
|
resp.direct_passthrough = True
|
||||||
if "text" not in content_type:
|
return resp
|
||||||
abort(415, description="Unsupported Media Type")
|
|
||||||
content = b""
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
chunk = await response.content.read(4096)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
abort(504, description="Timeout reading remote data")
|
|
||||||
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
|
up_etag = r.headers.get("ETag")
|
||||||
content_size = len(content)
|
up_lm = r.headers.get("Last-Modified")
|
||||||
redis_client.incrby('stats:content_size_total', content_size)
|
etag = build_etag(up_etag, up_lm, target_ip)
|
||||||
redis_client.incr('stats:content_size_count')
|
|
||||||
|
|
||||||
converted = convert_hosts(content.decode('utf-8'), target_ip)
|
@stream_with_context
|
||||||
redis_client.setex(cache_key(normalized_url, target_ip), 43200, converted) # 12h cache
|
def body_gen():
|
||||||
redis_client.incr('stats:conversions_success')
|
total = 0
|
||||||
return converted, 200, {'Content-Type': 'text/plain'}
|
# iter_lines pewnie tnie po \n/\r\n i dekoduje do str
|
||||||
|
for line in r.iter_lines(decode_unicode=True, chunk_size=config.READ_CHUNK):
|
||||||
|
if line is None:
|
||||||
|
continue
|
||||||
|
# zabezpieczenie przed megadługimi wierszami
|
||||||
|
if len(line) > config.STREAM_LINE_LIMIT:
|
||||||
|
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)
|
||||||
|
redis_client.incr("stats:content_size_count")
|
||||||
|
|
||||||
except aiohttp.ClientError as e:
|
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
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
app.logger.error(f"Request error: {str(e)}")
|
app.logger.error(f"Request error: {str(e)}")
|
||||||
redis_client.incr('stats:errors_500')
|
redis_client.incr("stats:errors_500")
|
||||||
abort(500)
|
abort(500)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
app.logger.error(f"URL validation error: {str(e)}")
|
app.logger.error(f"URL validation error: {str(e)}")
|
||||||
redis_client.incr('stats:errors_400')
|
redis_client.incr("stats:errors_400")
|
||||||
abort(400)
|
abort(400)
|
||||||
|
|
||||||
|
@app.route("/convert", methods=["HEAD"])
|
||||||
|
def convert_head():
|
||||||
|
encoded_url = request.args.get("url", config.DEFAULT_SOURCE_URL)
|
||||||
|
if not encoded_url:
|
||||||
|
abort(400)
|
||||||
|
decoded_url = unquote(encoded_url)
|
||||||
|
validate_and_normalize_url(decoded_url)
|
||||||
|
target_ip = request.args.get("ip", "127.0.0.1")
|
||||||
|
etag = build_etag(None, None, target_ip)
|
||||||
|
resp = Response(status=200)
|
||||||
|
resp.headers.update(cache_headers(etag, None))
|
||||||
|
resp.direct_passthrough = True
|
||||||
|
return resp
|
||||||
|
|
||||||
@app.route('/stats')
|
@app.route("/stats")
|
||||||
@ip_restriction
|
@basic_auth_required(
|
||||||
|
realm=config.STATS_BASIC_AUTH_REALM,
|
||||||
|
user=config.STATS_BASIC_AUTH_USER,
|
||||||
|
password=config.STATS_BASIC_AUTH_PASS,
|
||||||
|
)
|
||||||
def stats():
|
def stats():
|
||||||
"""Endpoint statystyk"""
|
stats_data, target_ips, url_requests, user_agents, client_ips = {}, {}, {}, {}, {}
|
||||||
stats_data = {}
|
|
||||||
target_ips = {}
|
|
||||||
url_requests = {}
|
|
||||||
user_agents = {}
|
|
||||||
client_ips = {}
|
|
||||||
|
|
||||||
# Agregacja statystyk z Redisa
|
|
||||||
for key in redis_client.scan_iter("stats:*"):
|
for key in redis_client.scan_iter("stats:*"):
|
||||||
key_str = key.decode()
|
key_str = key.decode()
|
||||||
value = redis_client.get(key).decode()
|
value = redis_client.get(key).decode()
|
||||||
|
if key_str.startswith("stats:target_ips:"):
|
||||||
if key_str.startswith('stats:target_ips:'):
|
ip = key_str.split(":", 2)[2]
|
||||||
ip = key_str.split(':', 2)[2]
|
|
||||||
target_ips[ip] = value
|
target_ips[ip] = value
|
||||||
elif key_str.startswith('stats:url_requests:'):
|
elif key_str.startswith("stats:url_requests:"):
|
||||||
url = unquote(key_str.split(':', 2)[2])
|
url = unquote(key_str.split(":", 2)[2])
|
||||||
url_requests[url] = value
|
url_requests[url] = value
|
||||||
elif key_str.startswith('stats:user_agents:'):
|
elif key_str.startswith("stats:user_agents:"):
|
||||||
ua = unquote(key_str.split(':', 2)[2])
|
ua = unquote(key_str.split(":", 2)[2])
|
||||||
user_agents[ua] = value
|
user_agents[ua] = value
|
||||||
elif key_str.startswith('stats:client_ips:'):
|
elif key_str.startswith("stats:client_ips:"):
|
||||||
ip = key_str.split(':', 2)[2]
|
ip = key_str.split(":", 2)[2]
|
||||||
client_ips[ip] = value
|
client_ips[ip] = value
|
||||||
else:
|
else:
|
||||||
stats_data[key_str] = value
|
stats_data[key_str] = value
|
||||||
|
|
||||||
# Pobranie ostatnich 50 requestów dla endpointu /convert
|
|
||||||
recent_converts = []
|
recent_converts = []
|
||||||
convert_entries = redis_client.lrange("recent_converts", 0, 49)
|
for entry in redis_client.lrange("recent_converts", 0, 49):
|
||||||
for entry in convert_entries:
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(entry.decode())
|
recent_converts.append(json.loads(entry.decode()))
|
||||||
recent_converts.append(data)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Obliczenie średniego czasu przetwarzania żądań
|
processing_time_total = float(redis_client.get("stats:processing_time_total") or 0)
|
||||||
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)
|
||||||
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
|
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_total = int(redis_client.get('stats:content_size_total') or 0)
|
content_size_count = int(redis_client.get("stats:content_size_count") 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
|
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 = {
|
detailed_stats = {
|
||||||
"processing_time_total_sec": processing_time_total,
|
"processing_time_total_sec": processing_time_total,
|
||||||
"processing_time_count": processing_time_count,
|
"processing_time_count": processing_time_count,
|
||||||
"processing_time_avg_sec": avg_processing_time,
|
"processing_time_avg_sec": avg_processing_time,
|
||||||
"processing_time_min_sec": float(redis_client.get('stats:processing_time_min') or 0),
|
"processing_time_min_sec": float(redis_client.get("stats:processing_time_min") or 0),
|
||||||
"processing_time_max_sec": float(redis_client.get('stats:processing_time_max') or 0),
|
"processing_time_max_sec": float(redis_client.get("stats:processing_time_max") or 0),
|
||||||
"content_size_total_bytes": content_size_total,
|
"content_size_total_bytes": content_size_total,
|
||||||
"content_size_count": content_size_count,
|
"content_size_count": content_size_count,
|
||||||
"content_size_avg_bytes": avg_content_size
|
"content_size_avg_bytes": avg_content_size,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Struktura odpowiedzi
|
return jsonify(
|
||||||
response_data = {
|
{
|
||||||
**stats_data,
|
**stats_data,
|
||||||
'target_ips': target_ips,
|
"target_ips": target_ips,
|
||||||
'url_requests': url_requests,
|
"url_requests": url_requests,
|
||||||
'user_agents': user_agents,
|
"user_agents": user_agents,
|
||||||
'client_ips': client_ips,
|
"client_ips": client_ips,
|
||||||
'recent_converts': recent_converts,
|
"recent_converts": recent_converts,
|
||||||
'detailed_stats': detailed_stats
|
"detailed_stats": detailed_stats,
|
||||||
}
|
}
|
||||||
|
)
|
||||||
return jsonify(response_data)
|
|
||||||
|
|
||||||
@app.errorhandler(400)
|
@app.errorhandler(400)
|
||||||
@app.errorhandler(403)
|
@app.errorhandler(403)
|
||||||
@@ -410,13 +444,13 @@ def stats():
|
|||||||
@app.errorhandler(415)
|
@app.errorhandler(415)
|
||||||
@app.errorhandler(500)
|
@app.errorhandler(500)
|
||||||
def handle_errors(e):
|
def handle_errors(e):
|
||||||
"""Obsługa błędów"""
|
try:
|
||||||
return render_template('error.html', error=e), e.code
|
return render_template("error.html", error=e), e.code
|
||||||
|
except Exception:
|
||||||
|
return jsonify({"error": getattr(e, "description", str(e)), "code": e.code}), e.code
|
||||||
|
|
||||||
# Jeśli aplikacja jest uruchamiana bezpośrednio, korzystamy z Flask's run
|
if __name__ == "__main__":
|
||||||
if __name__ == '__main__':
|
app.run(host=config.BIND_HOST, port=config.BIND_PORT)
|
||||||
app.run(host='0.0.0.0', port=8283)
|
|
||||||
# W przeciwnym razie (np. przy uruchamianiu przez Gunicorn) opakowujemy aplikację w adapter ASGI
|
|
||||||
else:
|
else:
|
||||||
from asgiref.wsgi import WsgiToAsgi
|
from asgiref.wsgi import WsgiToAsgi
|
||||||
asgi_app = WsgiToAsgi(app)
|
asgi_app = WsgiToAsgi(app)
|
||||||
|
65
config.py
Normal file
65
config.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
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", "change-me")
|
||||||
|
|
||||||
|
# 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")
|
||||||
|
STATS_BASIC_AUTH_PASS = os.getenv("STATS_BASIC_AUTH_PASS", "change-me")
|
||||||
|
|
||||||
|
# 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",
|
||||||
|
""
|
||||||
|
)
|
@@ -1,15 +1,27 @@
|
|||||||
# /etc/systemd/system/listapp.service
|
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=ListApp - Flask application for hosts file conversion
|
Description=ListApp - Flask application for hosts file conversion
|
||||||
After=network.target redis.service
|
After=network-online.target redis.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
User=www-data
|
User=www-data
|
||||||
Group=www-data
|
Group=www-data
|
||||||
WorkingDirectory=/var/www/listapp
|
WorkingDirectory=/var/www/listapp
|
||||||
|
|
||||||
|
# Globalne env + nadpisania (opcjonalne; minus oznacza „jeśli istnieje”)
|
||||||
|
EnvironmentFile=-/var/www/listapp/.env
|
||||||
|
|
||||||
|
# Ścieżka do virtualenv
|
||||||
Environment="PATH=/var/www/listapp/venv/bin"
|
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
|
# Gunicorn + UvicornWorker (ASGI)
|
||||||
|
ExecStart=/var/www/listapp/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
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
10
requirements.txt
Normal file
10
requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
Flask
|
||||||
|
Flask-Compress
|
||||||
|
Flask-Limiter
|
||||||
|
redis
|
||||||
|
requests
|
||||||
|
aiohttp
|
||||||
|
asgiref
|
||||||
|
unicorn
|
||||||
|
gunicorn
|
||||||
|
uvicorn
|
1
start_dev.sh
Normal file
1
start_dev.sh
Normal file
@@ -0,0 +1 @@
|
|||||||
|
venv/bin/gunicorn -k uvicorn.workers.UvicornWorker --workers 4 --bind 127.0.0.1:8283 --keep-alive 30 --timeout 90 app:asgi_app
|
Reference in New Issue
Block a user