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