""" hit-counter — minimal per-slug visit counter, with basic abuse protection and a Prometheus /metrics endpoint for Grafana. Throttling (in-memory, fine at this scale): 1. Per (ip, slug) cooldown — same visitor re-hitting the same article within COOLDOWN_SECONDS doesn't increment the count again. 2. Per-ip global rate limit — caps total requests/minute from one IP. State resets on container restart — acceptable for this use case. """ import time from collections import defaultdict from pathlib import Path import sqlite3 from fastapi import FastAPI, Request, HTTPException, Response from fastapi.middleware.cors import CORSMiddleware from prometheus_client import Gauge, generate_latest, CONTENT_TYPE_LATEST DB_PATH = Path("/data/hits.db") DB_PATH.parent.mkdir(parents=True, exist_ok=True) COOLDOWN_SECONDS = 30 * 60 # one counted hit per (ip, slug) per 30 min RATE_LIMIT_WINDOW = 60 # seconds RATE_LIMIT_MAX_REQUESTS = 20 # requests per ip per window, across all endpoints app = FastAPI() app.add_middleware( CORSMiddleware, allow_origins=["https://carloselugo.com"], allow_methods=["GET", "POST"], allow_headers=["*"], ) # (ip, slug) -> last counted timestamp _last_hit: dict[tuple[str, str], float] = {} # ip -> list of recent request timestamps _request_log: dict[str, list[float]] = defaultdict(list) # Prometheus gauge, one series per article slug. # Gauge (not Counter) because we set it directly from the SQLite total # on every /metrics scrape — Prometheus itself builds the time series # from repeated scrapes, so we don't need to track history ourselves. article_hits_gauge = Gauge("article_hits_total", "Total counted views per article", ["slug"]) def get_client_ip(request: Request) -> str: # Caddy sets X-Forwarded-For when reverse-proxying. forwarded = request.headers.get("x-forwarded-for") if forwarded: return forwarded.split(",")[0].strip() return request.client.host if request.client else "unknown" def enforce_rate_limit(ip: str): now = time.time() log = _request_log[ip] while log and now - log[0] > RATE_LIMIT_WINDOW: log.pop(0) if len(log) >= RATE_LIMIT_MAX_REQUESTS: raise HTTPException(status_code=429, detail="Too many requests") log.append(now) def get_db(): conn = sqlite3.connect(DB_PATH) conn.execute( "CREATE TABLE IF NOT EXISTS hits (slug TEXT PRIMARY KEY, count INTEGER NOT NULL DEFAULT 0)" ) return conn @app.post("/hit/{slug}") def register_hit(slug: str, request: Request): ip = get_client_ip(request) enforce_rate_limit(ip) now = time.time() key = (ip, slug) should_count = now - _last_hit.get(key, 0) > COOLDOWN_SECONDS conn = get_db() if should_count: conn.execute( "INSERT INTO hits (slug, count) VALUES (?, 1) " "ON CONFLICT(slug) DO UPDATE SET count = count + 1", (slug,), ) conn.commit() _last_hit[key] = now total = conn.execute("SELECT count FROM hits WHERE slug = ?", (slug,)).fetchone() conn.close() count = total[0] if total else 0 article_hits_gauge.labels(slug=slug).set(count) return {"slug": slug, "count": count, "counted": should_count} @app.get("/hits/{slug}") def get_hits(slug: str, request: Request): enforce_rate_limit(get_client_ip(request)) conn = get_db() row = conn.execute("SELECT count FROM hits WHERE slug = ?", (slug,)).fetchone() conn.close() return {"slug": slug, "count": row[0] if row else 0} @app.get("/metrics") def metrics(): # Refresh every gauge from the source of truth (SQLite) before # Prometheus scrapes, so /metrics is correct even right after a restart. conn = get_db() rows = conn.execute("SELECT slug, count FROM hits").fetchall() conn.close() for slug, count in rows: article_hits_gauge.labels(slug=slug).set(count) return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)