Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/backend/app/api/routes/url_shortener/api.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/app/api/routes/url_shortener/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
81 changes: 43 additions & 38 deletions apps/backend/app/api/routes/url_shortener/services.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import re
import secrets
import string
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)),
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/app/core/indexes.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
S3_CONNECTIONS,
SQL_CONNECTIONS,
TASKS,
URL_CLICK_EVENTS,
USER_PREFERENCES,
USERS,
)
Expand Down Expand Up @@ -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)])
2 changes: 1 addition & 1 deletion apps/web/src/app/s/[code]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,12 @@ export function AnalyticsDialog({ link, open, onClose }: AnalyticsDialogProps) {
</div>

{/* Body */}
<div className="overflow-y-auto flex-1 px-5 py-4 space-y-6">
<div className="overflow-y-auto flex-1 px-5 py-4 space-y-6 relative">
{loading && data && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/60 backdrop-blur-[1px]">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{loading && !data ? (
<div className="flex h-40 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
Expand Down
21 changes: 17 additions & 4 deletions apps/web/src/components/url-shortener/daily-chart.tsx
Original file line number Diff line number Diff line change
@@ -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<number | null>(null)

if (daily.length === 0) return (
<div className="flex h-32 items-center justify-center text-xs text-muted-foreground">No data for this period</div>
)
const max = Math.max(...daily.map(d => d.clicks), 1)
return (
<div className="flex h-32 items-end gap-0.5">
{daily.map((d) => {
{daily.map((d, i) => {
const heightPct = Math.max((d.clicks / max) * 100, 4)
const isActive = activeIdx === i
return (
<div
key={d.date}
className="group relative flex-1 min-w-0"
className="group relative flex-1 min-w-0 cursor-pointer"
style={{ height: '100%', display: 'flex', alignItems: 'flex-end' }}
onClick={() => setActiveIdx(isActive ? null : i)}
onMouseEnter={() => setActiveIdx(i)}
onMouseLeave={() => setActiveIdx(null)}
>
{isActive && (
<div className="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 z-10 pointer-events-none whitespace-nowrap rounded-md bg-popover border border-border/50 px-2 py-1 text-[10px] font-medium text-foreground shadow-md">
<div className="font-semibold">{d.clicks} click{d.clicks !== 1 ? 's' : ''}</div>
<div className="text-muted-foreground">{d.date}</div>
</div>
)}
<div
className="w-full rounded-sm bg-primary/70 transition-all duration-300 group-hover:bg-primary"
className={`w-full rounded-sm transition-all duration-150 ${isActive ? 'bg-primary' : 'bg-primary/70 hover:bg-primary'}`}
style={{ height: `${heightPct}%` }}
title={`${d.date}: ${d.clicks} click${d.clicks !== 1 ? 's' : ''}`}
/>
</div>
)
Expand Down
12 changes: 10 additions & 2 deletions apps/web/src/components/url-shortener/qr-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import {
Check,
Copy,
Expand All @@ -24,16 +24,24 @@ interface QrDialogProps {

export function QrDialog({ url, open, onClose }: QrDialogProps) {
const [dataUrl, setDataUrl] = useState<string | null>(null)
const cachedUrl = useRef<string | null>(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 = () => {
Expand Down
21 changes: 16 additions & 5 deletions apps/web/src/components/url-shortener/shorten-form.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { useState } from 'react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import {
Check,
Expand All @@ -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'
Expand All @@ -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('')
Expand All @@ -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 {
Expand Down Expand Up @@ -89,7 +100,7 @@ export function ShortenForm({ isAuthenticated, onCreated }: ShortenFormProps) {
</div>
<Button
type="submit"
disabled={creating || !isAuthenticated}
disabled={creating}
className="h-11 gap-2 px-5 bg-gradient-to-r from-primary to-violet-600 text-primary-foreground shadow-md shadow-primary/20 hover:shadow-primary/30 hover:opacity-90 transition-all"
>
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
Expand Down Expand Up @@ -133,7 +144,7 @@ export function ShortenForm({ isAuthenticated, onCreated }: ShortenFormProps) {

{!isAuthenticated && (
<p className="text-xs text-amber-600 dark:text-amber-400">
Sign in to create and manage your short links.
<Link href="/login" className="underline underline-offset-2 hover:opacity-80">Sign in</Link> to create and manage your short links.
</p>
)}
</div>
Expand Down
15 changes: 9 additions & 6 deletions apps/web/src/components/url-shortener/url-shortener-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,14 +112,17 @@ export function UrlShortenerTool() {
<ShortenForm
isAuthenticated={!!user}
onCreated={handleCreated}
shortBase={shortBase}
/>

{/* Stats strip */}
<StatsStrip
totalLinks={links.length}
activeCount={activeCount}
totalClicks={totalClicks}
/>
{/* Stats strip — only shown when no filter/search is active */}
{!search && statusFilter === 'all' && (
<StatsStrip
totalLinks={links.length}
activeCount={activeCount}
totalClicks={totalClicks}
/>
)}

{/* Search / Filter / Sort toolbar */}
<SearchFilterBar
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/lib/url-shortener-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export async function createShortLink(body: ShortLinkCreate): Promise<ShortLink>
return request<ShortLink>('POST', BASE, body)
}

export async function listShortLinks(skip = 0, limit = 100): Promise<ShortLink[]> {
export async function listShortLinks(skip = 0, limit = 500): Promise<ShortLink[]> {
return request<ShortLink[]>('GET', `${BASE}?skip=${skip}&limit=${limit}`)
}

Expand Down