diff --git a/feedback-api/database.py b/feedback-api/database.py new file mode 100644 index 0000000..7f4136b --- /dev/null +++ b/feedback-api/database.py @@ -0,0 +1,57 @@ +import aiosqlite + +DB_PATH = "feedback.db" + + +async def init_db(): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute(""" + CREATE TABLE IF NOT EXISTS feedbacks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL, + choice TEXT NOT NULL CHECK(choice IN ('yes', 'no')), + timestamp TEXT NOT NULL, + ip_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """) + await db.execute(""" + CREATE INDEX IF NOT EXISTS idx_feedbacks_ip_hash + ON feedbacks(ip_hash, created_at) + """) + await db.commit() + + +async def add_feedback(ip_hash: str, url: str, choice: str, timestamp: str): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + "INSERT INTO feedbacks (url, choice, timestamp, ip_hash) VALUES (?, ?, ?, ?)", + (url, choice, timestamp, ip_hash), + ) + await db.commit() + + +async def check_rate_limit(ip_hash: str, limit: int = 5, window_seconds: int = 3600) -> bool: + async with aiosqlite.connect(DB_PATH) as db: + cursor = await db.execute( + "SELECT COUNT(*) FROM feedbacks WHERE ip_hash = ? AND created_at > datetime('now', ?)", + (ip_hash, f"-{window_seconds} seconds"), + ) + row = await cursor.fetchone() + return row[0] >= limit + + +async def get_stats() -> dict: + async with aiosqlite.connect(DB_PATH) as db: + cursor = await db.execute("SELECT COUNT(*) FROM feedbacks") + total = (await cursor.fetchone())[0] + + cursor = await db.execute("SELECT choice, COUNT(*) FROM feedbacks GROUP BY choice") + rows = await cursor.fetchall() + counts = {row[0]: row[1] for row in rows} + + return { + "total": total, + "yes": counts.get("yes", 0), + "no": counts.get("no", 0), + } diff --git a/feedback-api/main.py b/feedback-api/main.py new file mode 100644 index 0000000..13f4371 --- /dev/null +++ b/feedback-api/main.py @@ -0,0 +1,70 @@ +import hashlib +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +from database import add_feedback, check_rate_limit, get_stats, init_db + + +class FeedbackIn(BaseModel): + url: str + choice: str + timestamp: str + + +RATE_LIMIT = 5 +RATE_WINDOW = 3600 + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + yield + + +app = FastAPI(lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + + +def get_client_ip(request: Request) -> str: + cf_ip = request.headers.get("cf-connecting-ip") + if cf_ip: + return cf_ip + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host or "unknown" + + +@app.post("/api/feedback") +async def receive_feedback(body: FeedbackIn, request: Request): + ip = get_client_ip(request) + ip_hash = hashlib.sha256(ip.encode()).hexdigest() + + if not await check_rate_limit(ip_hash, RATE_LIMIT, RATE_WINDOW): + await add_feedback(ip_hash, body.url, body.choice, body.timestamp) + + return {"ok": True} + + +@app.get("/api/feedback/stats") +async def stats(): + return await get_stats() + + +@app.get("/api/feedback/health") +async def health(): + return {"status": "ok"} + + +if __name__ == "__main__": + import uvicorn + uvicorn.run("main:app", host="0.0.0.0", port=8005) diff --git a/feedback-api/requirements.txt b/feedback-api/requirements.txt new file mode 100644 index 0000000..9d523cc --- /dev/null +++ b/feedback-api/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +aiosqlite>=0.20.0