From f1629c8e92ac751082ec8d2ad15c556e9153d96c Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Wed, 17 Jun 2026 13:19:46 +0530 Subject: [PATCH] Optimise url shortner --- .../app/api/routes/url_shortener/api.py | 14 ++++ .../app/api/routes/url_shortener/schema.py | 2 +- .../app/api/routes/url_shortener/services.py | 81 ++++++++++--------- apps/backend/app/core/indexes.py | 2 + apps/web/src/app/s/[code]/route.ts | 2 +- .../url-shortener/analytics-dialog.tsx | 7 +- .../components/url-shortener/daily-chart.tsx | 21 ++++- .../components/url-shortener/qr-dialog.tsx | 12 ++- .../components/url-shortener/shorten-form.tsx | 21 +++-- .../url-shortener/url-shortener-tool.tsx | 15 ++-- apps/web/src/lib/url-shortener-api.ts | 2 +- 11 files changed, 120 insertions(+), 59 deletions(-) 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 */} -
+
+ {loading && data && ( +
+ +
+ )} {loading && !data ? (
diff --git a/apps/web/src/components/url-shortener/daily-chart.tsx b/apps/web/src/components/url-shortener/daily-chart.tsx index 5d5bf684..f5bae28f 100644 --- a/apps/web/src/components/url-shortener/daily-chart.tsx +++ b/apps/web/src/components/url-shortener/daily-chart.tsx @@ -1,28 +1,41 @@ 'use client' +import { useState } from 'react' + interface DailyChartProps { daily: { date: string; clicks: number }[] } export function DailyChart({ daily }: DailyChartProps) { + const [activeIdx, setActiveIdx] = useState(null) + if (daily.length === 0) return (
No data for this period
) const max = Math.max(...daily.map(d => d.clicks), 1) return (
- {daily.map((d) => { + {daily.map((d, i) => { const heightPct = Math.max((d.clicks / max) * 100, 4) + const isActive = activeIdx === i return (
setActiveIdx(isActive ? null : i)} + onMouseEnter={() => setActiveIdx(i)} + onMouseLeave={() => setActiveIdx(null)} > + {isActive && ( +
+
{d.clicks} click{d.clicks !== 1 ? 's' : ''}
+
{d.date}
+
+ )}
) diff --git a/apps/web/src/components/url-shortener/qr-dialog.tsx b/apps/web/src/components/url-shortener/qr-dialog.tsx index 0b7c9b64..4a213c64 100644 --- a/apps/web/src/components/url-shortener/qr-dialog.tsx +++ b/apps/web/src/components/url-shortener/qr-dialog.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Check, Copy, @@ -24,16 +24,24 @@ interface QrDialogProps { export function QrDialog({ url, open, onClose }: QrDialogProps) { const [dataUrl, setDataUrl] = useState(null) + const cachedUrl = useRef(null) const { copied, copy } = useCopy() useEffect(() => { if (!open) return + if (cachedUrl.current) { + setDataUrl(cachedUrl.current) + return + } QRCode.toDataURL(url, { width: 280, margin: 2, color: { dark: '#000000', light: '#ffffff' }, errorCorrectionLevel: 'M', - }).then(setDataUrl).catch(() => {}) + }).then((result) => { + cachedUrl.current = result + setDataUrl(result) + }).catch(() => {}) }, [open, url]) const download = () => { diff --git a/apps/web/src/components/url-shortener/shorten-form.tsx b/apps/web/src/components/url-shortener/shorten-form.tsx index 60baca11..782882b6 100644 --- a/apps/web/src/components/url-shortener/shorten-form.tsx +++ b/apps/web/src/components/url-shortener/shorten-form.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { toast } from 'sonner' import { Check, @@ -10,6 +10,7 @@ import { Plus, Zap, } from 'lucide-react' +import Link from 'next/link' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -19,9 +20,10 @@ import { useCopy } from './hooks/use-copy' interface ShortenFormProps { isAuthenticated: boolean onCreated: (link: ShortLink) => void + shortBase: string } -export function ShortenForm({ isAuthenticated, onCreated }: ShortenFormProps) { +export function ShortenForm({ isAuthenticated, onCreated, shortBase }: ShortenFormProps) { const [url, setUrl] = useState('') const [title, setTitle] = useState('') const [customCode, setCustomCode] = useState('') @@ -31,11 +33,20 @@ export function ShortenForm({ isAuthenticated, onCreated }: ShortenFormProps) { const { copied, copy } = useCopy() - const shortBase = typeof window !== 'undefined' ? window.location.origin : '' const justCreatedUrl = justCreated ? `${shortBase}/s/${justCreated.code}` : '' + useEffect(() => { + if (!justCreated) return + const t = setTimeout(() => setJustCreated(null), 8000) + return () => clearTimeout(t) + }, [justCreated]) + const handleCreate = async (e: React.FormEvent) => { e.preventDefault() + if (!isAuthenticated) { + toast.error('Sign in to create short links') + return + } if (!url.trim()) return setCreating(true) try { @@ -89,7 +100,7 @@ export function ShortenForm({ isAuthenticated, onCreated }: ShortenFormProps) {
diff --git a/apps/web/src/components/url-shortener/url-shortener-tool.tsx b/apps/web/src/components/url-shortener/url-shortener-tool.tsx index b0cb503a..c96ef7d1 100644 --- a/apps/web/src/components/url-shortener/url-shortener-tool.tsx +++ b/apps/web/src/components/url-shortener/url-shortener-tool.tsx @@ -112,14 +112,17 @@ export function UrlShortenerTool() { - {/* Stats strip */} - + {/* Stats strip — only shown when no filter/search is active */} + {!search && statusFilter === 'all' && ( + + )} {/* Search / Filter / Sort toolbar */} return request('POST', BASE, body) } -export async function listShortLinks(skip = 0, limit = 100): Promise { +export async function listShortLinks(skip = 0, limit = 500): Promise { return request('GET', `${BASE}?skip=${skip}&limit=${limit}`) }