diff --git a/apps/backend/app/api/routes/url_shortener/api.py b/apps/backend/app/api/routes/url_shortener/api.py index 87b585f7..12c52e93 100644 --- a/apps/backend/app/api/routes/url_shortener/api.py +++ b/apps/backend/app/api/routes/url_shortener/api.py @@ -1,3 +1,6 @@ +import time +from collections import defaultdict + from fastapi import APIRouter, Depends, Query, Request from app.api.routes.auth.services import get_current_uid @@ -12,6 +15,10 @@ router = APIRouter(prefix="/url-shortener", tags=["url-shortener"]) +_click_rate: dict[str, list[float]] = defaultdict(list) +_CLICK_WINDOW = 60.0 # seconds +_CLICK_MAX = 5 # per IP per code per window + @router.post("", response_model=ShortLinkOut, summary="Create a short link") async def create_link( @@ -37,6 +44,13 @@ async def resolve_link(code: str) -> ShortLinkResolve: @router.post("/{code}/click", status_code=204, summary="Record a click (public)") async def record_click(code: str, request: Request) -> None: + ip = request.headers.get("x-forwarded-for", request.client.host if request.client else "unknown").split(",")[0].strip() + key = f"{ip}:{code}" + now = time.time() + _click_rate[key] = [t for t in _click_rate[key] if now - t < _CLICK_WINDOW] + if len(_click_rate[key]) >= _CLICK_MAX: + return # silently ignore, don't error + _click_rate[key].append(now) ua = request.headers.get("user-agent", "") referrer = request.headers.get("referer", "") await svc.record_click(code, ua=ua, referrer=referrer) diff --git a/apps/backend/app/api/routes/url_shortener/schema.py b/apps/backend/app/api/routes/url_shortener/schema.py index 3fd4ea61..fbd8c789 100644 --- a/apps/backend/app/api/routes/url_shortener/schema.py +++ b/apps/backend/app/api/routes/url_shortener/schema.py @@ -10,7 +10,7 @@ class ShortLinkCreate(BaseModel): original_url: str = Field(min_length=1, max_length=2048) title: Optional[str] = Field(default=None, max_length=200) - custom_code: Optional[str] = Field(default=None, min_length=3, max_length=20) + custom_code: Optional[str] = Field(default=None, min_length=3, max_length=20, pattern=r'^[a-zA-Z0-9][a-zA-Z0-9\-_]*$') class ShortLinkUpdate(BaseModel): diff --git a/apps/backend/app/api/routes/url_shortener/services.py b/apps/backend/app/api/routes/url_shortener/services.py index f0a8a74c..6dc14738 100644 --- a/apps/backend/app/api/routes/url_shortener/services.py +++ b/apps/backend/app/api/routes/url_shortener/services.py @@ -1,3 +1,4 @@ +import asyncio import re import secrets import string @@ -96,9 +97,9 @@ def _parse_ua(ua: str) -> tuple[str, str, str]: """Returns (device, os_name, browser) from a User-Agent string.""" u = ua.lower() - if "ipad" in u or "tablet" in u: + if "ipad" in u or ("tablet" in u) or ("android" in u and "mobile" not in u): device = "Tablet" - elif "mobile" in u or ("android" in u and "mobile" in u): + elif "mobile" in u: device = "Mobile" else: device = "Desktop" @@ -246,42 +247,46 @@ async def get_analytics(uid: str, code: str, days: int = 30) -> LinkAnalytics: base_match: dict[str, Any] = {"code": code, "ts": {"$gte": since}} - # Daily clicks grouped by UTC date string - daily_raw = await events_col.aggregate([ - {"$match": base_match}, - {"$group": { - "_id": {"$dateToString": {"format": "%Y-%m-%d", "date": {"$toDate": "$ts"}}}, - "clicks": {"$sum": 1}, - }}, - {"$sort": {"_id": 1}}, - ]).to_list(None) - - referrers_raw = await events_col.aggregate([ - {"$match": base_match}, - {"$group": {"_id": "$referrer", "clicks": {"$sum": 1}}}, - {"$sort": {"clicks": -1}}, - {"$limit": 10}, - ]).to_list(None) - - devices_raw = await events_col.aggregate([ - {"$match": base_match}, - {"$group": {"_id": "$device", "clicks": {"$sum": 1}}}, - {"$sort": {"clicks": -1}}, - ]).to_list(None) - - os_raw = await events_col.aggregate([ - {"$match": base_match}, - {"$group": {"_id": "$os", "clicks": {"$sum": 1}}}, - {"$sort": {"clicks": -1}}, - {"$limit": 8}, - ]).to_list(None) - - browsers_raw = await events_col.aggregate([ - {"$match": base_match}, - {"$group": {"_id": "$browser", "clicks": {"$sum": 1}}}, - {"$sort": {"clicks": -1}}, - {"$limit": 8}, - ]).to_list(None) + # Daily clicks grouped by UTC date string — all 5 queries run concurrently + ( + daily_raw, + referrers_raw, + devices_raw, + os_raw, + browsers_raw, + ) = await asyncio.gather( + events_col.aggregate([ + {"$match": base_match}, + {"$group": { + "_id": {"$dateToString": {"format": "%Y-%m-%d", "date": {"$toDate": "$ts"}}}, + "clicks": {"$sum": 1}, + }}, + {"$sort": {"_id": 1}}, + ]).to_list(None), + events_col.aggregate([ + {"$match": base_match}, + {"$group": {"_id": "$referrer", "clicks": {"$sum": 1}}}, + {"$sort": {"clicks": -1}}, + {"$limit": 10}, + ]).to_list(None), + events_col.aggregate([ + {"$match": base_match}, + {"$group": {"_id": "$device", "clicks": {"$sum": 1}}}, + {"$sort": {"clicks": -1}}, + ]).to_list(None), + events_col.aggregate([ + {"$match": base_match}, + {"$group": {"_id": "$os", "clicks": {"$sum": 1}}}, + {"$sort": {"clicks": -1}}, + {"$limit": 8}, + ]).to_list(None), + events_col.aggregate([ + {"$match": base_match}, + {"$group": {"_id": "$browser", "clicks": {"$sum": 1}}}, + {"$sort": {"clicks": -1}}, + {"$limit": 8}, + ]).to_list(None), + ) return LinkAnalytics( total_clicks=int(doc.get("clicks", 0)), diff --git a/apps/backend/app/core/indexes.py b/apps/backend/app/core/indexes.py index 5ce879a0..45195398 100644 --- a/apps/backend/app/core/indexes.py +++ b/apps/backend/app/core/indexes.py @@ -19,6 +19,7 @@ S3_CONNECTIONS, SQL_CONNECTIONS, TASKS, + URL_CLICK_EVENTS, USER_PREFERENCES, USERS, ) @@ -50,3 +51,4 @@ async def ensure_indexes() -> None: await db_manager.create_index(REDIS_CONNECTIONS, [("created_by", 1), ("updatedAt", -1)]) await db_manager.create_index(GAME_SCORES, [("created_by", 1), ("updatedAt", -1)]) await db_manager.create_index(FEEDBACK, [("created_by", 1), ("createdAt", -1)]) + await db_manager.create_index(URL_CLICK_EVENTS, [("code", 1), ("ts", 1)]) diff --git a/apps/web/src/app/s/[code]/route.ts b/apps/web/src/app/s/[code]/route.ts index d75624d9..bb0dc964 100644 --- a/apps/web/src/app/s/[code]/route.ts +++ b/apps/web/src/app/s/[code]/route.ts @@ -47,6 +47,6 @@ export async function GET( return NextResponse.redirect(original_url, { status: 302 }) } catch { - return NextResponse.redirect(`${origin}/`, { status: 302 }) + return NextResponse.redirect(`${origin}/not-found`, { status: 302 }) } } diff --git a/apps/web/src/components/url-shortener/analytics-dialog.tsx b/apps/web/src/components/url-shortener/analytics-dialog.tsx index cc19f2e0..26604ca7 100644 --- a/apps/web/src/components/url-shortener/analytics-dialog.tsx +++ b/apps/web/src/components/url-shortener/analytics-dialog.tsx @@ -114,7 +114,12 @@ export function AnalyticsDialog({ link, open, onClose }: AnalyticsDialogProps) { {/* Body */} -