175 lines
5.3 KiB
Python
175 lines
5.3 KiB
Python
"""Redis-backed counters, recent links and request lifecycle hooks."""
|
|
import redis
|
|
import socket
|
|
import time
|
|
import json
|
|
from datetime import datetime
|
|
from functools import wraps
|
|
from urllib.parse import quote
|
|
from flask import request, Response, g
|
|
import config
|
|
|
|
def build_redis():
|
|
if config.REDIS_URL:
|
|
return redis.Redis.from_url(config.REDIS_URL)
|
|
return redis.Redis(
|
|
host=config.REDIS_HOST, port=config.REDIS_PORT, db=config.REDIS_DB
|
|
)
|
|
|
|
redis_client = build_redis()
|
|
|
|
|
|
def get_client_ip():
|
|
xff = request.headers.get("X-Forwarded-For", "").split(",")
|
|
if xff and xff[0].strip():
|
|
return xff[0].strip()
|
|
return request.remote_addr
|
|
|
|
def track_request_data():
|
|
g.start_time = time.perf_counter()
|
|
redis_client.incr(
|
|
f"stats:user_agents:{quote(request.headers.get('User-Agent', 'Unknown'), safe='')}"
|
|
)
|
|
redis_client.incr(f"stats:client_ips:{get_client_ip()}")
|
|
redis_client.incr(f"stats:methods:{request.method}")
|
|
|
|
def finalize_response(response):
|
|
elapsed = time.perf_counter() - getattr(g, "start_time", time.perf_counter())
|
|
redis_client.incrbyfloat("stats:processing_time_total", elapsed)
|
|
redis_client.incr("stats:processing_time_count")
|
|
try:
|
|
current_min = float(redis_client.get("stats:processing_time_min") or elapsed)
|
|
if elapsed < current_min:
|
|
redis_client.set("stats:processing_time_min", elapsed)
|
|
except Exception:
|
|
redis_client.set("stats:processing_time_min", elapsed)
|
|
try:
|
|
current_max = float(redis_client.get("stats:processing_time_max") or elapsed)
|
|
if elapsed > current_max:
|
|
redis_client.set("stats:processing_time_max", elapsed)
|
|
except Exception:
|
|
redis_client.set("stats:processing_time_max", elapsed)
|
|
|
|
path = request.path or "/"
|
|
|
|
if response.status_code >= 400:
|
|
response.headers["Cache-Control"] = "no-store"
|
|
return response
|
|
|
|
if path.startswith("/static/"):
|
|
response.headers.pop("Content-Disposition", None)
|
|
# Only versioned assets are immutable. Unversioned files must not stay stale after upgrades.
|
|
if request.args.get("v"):
|
|
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
|
else:
|
|
response.headers["Cache-Control"] = "public, max-age=3600"
|
|
return response
|
|
|
|
if path in {"/", "/stats", "/stats.json"}:
|
|
# Only validated /convert outputs are public cache objects.
|
|
response.headers["Cache-Control"] = "private, no-store"
|
|
return response
|
|
|
|
return response
|
|
|
|
def datetimeformat_filter(value, format="%Y-%m-%d %H:%M"):
|
|
try:
|
|
dt = datetime.fromisoformat(value)
|
|
return dt.strftime(format)
|
|
except (ValueError, AttributeError):
|
|
return value
|
|
|
|
def basic_auth_required(realm: str, user: str, password: str):
|
|
def decorator(f):
|
|
@wraps(f)
|
|
def wrapper(*args, **kwargs):
|
|
if not config.STATS_BASIC_AUTH_ENABLED:
|
|
return f(*args, **kwargs)
|
|
|
|
auth = request.authorization
|
|
|
|
if (
|
|
auth
|
|
and auth.type == "basic"
|
|
and auth.username == user
|
|
and auth.password == password
|
|
):
|
|
return f(*args, **kwargs)
|
|
|
|
resp = Response(status=401)
|
|
resp.headers["WWW-Authenticate"] = f'Basic realm="{realm}"'
|
|
return resp
|
|
|
|
return wrapper
|
|
|
|
return decorator
|
|
|
|
def track_url_request(url):
|
|
redis_client.incr(f"stats:url_requests:{quote(url, safe='')}")
|
|
|
|
def add_recent_link(url, target_ip):
|
|
ts = datetime.now().isoformat()
|
|
new_item = f"{ts}|{url}|{target_ip}"
|
|
key = "recent_links"
|
|
|
|
current = redis_client.lrange(key, 0, -1)
|
|
filtered = []
|
|
for raw in current:
|
|
try:
|
|
s = raw.decode()
|
|
parts = s.split("|")
|
|
if len(parts) >= 3 and parts[1] == url and parts[2] == target_ip:
|
|
continue
|
|
except Exception:
|
|
pass
|
|
filtered.append(raw)
|
|
|
|
with redis_client.pipeline() as pipe:
|
|
pipe.delete(key)
|
|
pipe.lpush(key, new_item)
|
|
if filtered:
|
|
pipe.rpush(key, *filtered[:99])
|
|
pipe.ltrim(key, 0, 99)
|
|
pipe.execute()
|
|
|
|
redis_client.incr("stats:recent_links_added")
|
|
|
|
def get_recent_links():
|
|
links = redis_client.lrange("recent_links", 0, 99)
|
|
out = []
|
|
for link in links:
|
|
parts = link.decode().split("|")
|
|
if len(parts) >= 3:
|
|
out.append((parts[0], parts[1], parts[2]))
|
|
elif len(parts) == 2:
|
|
out.append((parts[0], parts[1], "127.0.0.1"))
|
|
return out
|
|
|
|
def get_hostname(ip):
|
|
key = f"reverse_dns:{ip}"
|
|
cached = redis_client.get(key)
|
|
if cached:
|
|
return cached.decode()
|
|
try:
|
|
hostname = socket.gethostbyaddr(ip)[0]
|
|
except Exception:
|
|
hostname = ip
|
|
redis_client.setex(key, 3600, hostname)
|
|
return hostname
|
|
|
|
def add_recent_convert():
|
|
ip = get_client_ip()
|
|
hostname = get_hostname(ip)
|
|
ua = request.headers.get("User-Agent", "Unknown")
|
|
time_str = datetime.now().astimezone().isoformat()
|
|
url = request.full_path
|
|
data = {
|
|
"url": url,
|
|
"ip": ip,
|
|
"hostname": hostname,
|
|
"time": time_str,
|
|
"user_agent": ua,
|
|
}
|
|
redis_client.lpush("recent_converts", json.dumps(data))
|
|
redis_client.ltrim("recent_converts", 0, 99)
|