diff --git a/backend/.env.example b/backend/.env.example index dbc2873..dc8f3b1 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -86,3 +86,11 @@ SENTRY_DSN= # Environment identifier ENVIRONMENT=development + +# ----------------------------------------------------------------------------- +# Discord Webhooks - Feedback & Waitlist +# ----------------------------------------------------------------------------- + +# Discord webhook for receiving user feedback and waitlist signups +# Create at: Discord Server → Channel Settings → Integrations → Webhooks +DISCORD_FEEDBACK_WEBHOOK=https://discord.com/api/webhooks/your_webhook_id/your_webhook_token diff --git a/backend/Dockerfile b/backend/Dockerfile index 49160ad..34ea822 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -29,5 +29,6 @@ EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health').read()" || exit 1 -# Run with uvicorn -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +# Run with uvicorn (proxy-headers enabled for rate limiting behind reverse proxy) +# Set FORWARDED_ALLOW_IPS env var in production to your proxy's IP range +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"] diff --git a/backend/main.py b/backend/main.py index 9001f1f..87f8cb9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -28,6 +28,7 @@ from routes.users import router as users_router from routes.search_v2 import router as search_v2_router from routes.github import router as github_router +from routes.feedback import router as feedback_router from routes.ws_playground import websocket_playground_index from routes.ws_repos import websocket_repo_indexing @@ -94,6 +95,7 @@ async def dispatch(self, request: Request, call_next): app.include_router(users_router, prefix=API_PREFIX) app.include_router(search_v2_router, prefix=API_PREFIX) app.include_router(github_router, prefix=API_PREFIX) +app.include_router(feedback_router, prefix=API_PREFIX) # WebSocket endpoints (versioned) app.add_api_websocket_route(f"{API_PREFIX}/ws/index/{{repo_id}}", websocket_index) diff --git a/backend/routes/feedback.py b/backend/routes/feedback.py new file mode 100644 index 0000000..62fa178 --- /dev/null +++ b/backend/routes/feedback.py @@ -0,0 +1,121 @@ +""" +Feedback routes - handles user feedback and waitlist signups +Posts to Discord webhook server-side to keep webhook URL secret +""" +import os +import httpx +from datetime import datetime +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, EmailStr +from typing import Optional +from services.rate_limiter import rate_limit + +router = APIRouter(prefix="/feedback", tags=["feedback"]) + +DISCORD_WEBHOOK_URL = os.getenv("DISCORD_FEEDBACK_WEBHOOK") + +MOOD_CONFIG = { + "frustrated": {"emoji": "😠", "color": 0xEF4444, "label": "Frustrated"}, + "meh": {"emoji": "😐", "color": 0xEAB308, "label": "Meh"}, + "good": {"emoji": "😊", "color": 0x22C55E, "label": "Good"}, + "love": {"emoji": "🤩", "color": 0x8B5CF6, "label": "Love it!"}, +} + + +class FeedbackRequest(BaseModel): + mood: str + message: Optional[str] = None + email: Optional[EmailStr] = None + + +class WaitlistRequest(BaseModel): + email: EmailStr + plan: str # "pro" or "enterprise" + + +async def post_to_discord(embed: dict) -> bool: + """Post an embed to Discord webhook.""" + if not DISCORD_WEBHOOK_URL: + return False + + try: + async with httpx.AsyncClient() as client: + response = await client.post( + DISCORD_WEBHOOK_URL, + json={"embeds": [embed]}, + timeout=10.0 + ) + return response.status_code == 204 + except Exception: + return False + + +@router.post("") +@rate_limit(requests_per_minute=5) +async def submit_feedback(request: Request, body: FeedbackRequest): + """Submit user feedback - posts to Discord.""" + if not DISCORD_WEBHOOK_URL: + raise HTTPException(status_code=503, detail="Feedback service unavailable") + + mood_info = MOOD_CONFIG.get(body.mood, MOOD_CONFIG["good"]) + + embed = { + "title": "💬 New Feedback", + "color": mood_info["color"], + "fields": [ + {"name": "Mood", "value": f"{mood_info['emoji']} {mood_info['label']}", "inline": True}, + ], + "footer": {"text": "OpenCodeIntel Feedback"}, + "timestamp": datetime.utcnow().isoformat(), + } + + if body.email: + embed["fields"].append({"name": "User", "value": body.email, "inline": True}) + + if body.message: + embed["fields"].append({"name": "Message", "value": body.message[:1000], "inline": False}) + + success = await post_to_discord(embed) + if not success: + raise HTTPException(status_code=500, detail="Failed to submit feedback") + + return {"success": True} + + +@router.post("/waitlist") +@rate_limit(requests_per_minute=3) +async def join_waitlist(request: Request, body: WaitlistRequest): + """Join waitlist for Pro or Enterprise plan.""" + if not DISCORD_WEBHOOK_URL: + raise HTTPException(status_code=503, detail="Waitlist service unavailable") + + is_enterprise = body.plan.lower() == "enterprise" + + if is_enterprise: + embed = { + "title": "🏢 Enterprise Inquiry", + "color": 0x8B5CF6, + "fields": [ + {"name": "Email", "value": body.email, "inline": True}, + {"name": "Plan", "value": "Enterprise (Custom)", "inline": True}, + ], + "footer": {"text": "OpenCodeIntel Enterprise"}, + "timestamp": datetime.utcnow().isoformat(), + } + else: + embed = { + "title": "🚀 New Waitlist Signup", + "color": 0x3B82F6, + "fields": [ + {"name": "Email", "value": body.email, "inline": True}, + {"name": "Plan Interest", "value": "Pro ($19/month)", "inline": True}, + ], + "footer": {"text": "OpenCodeIntel Waitlist"}, + "timestamp": datetime.utcnow().isoformat(), + } + + success = await post_to_discord(embed) + if not success: + raise HTTPException(status_code=500, detail="Failed to join waitlist") + + return {"success": True} diff --git a/backend/services/rate_limiter.py b/backend/services/rate_limiter.py index 849c04d..55d060f 100644 --- a/backend/services/rate_limiter.py +++ b/backend/services/rate_limiter.py @@ -3,11 +3,84 @@ Prevents abuse and manages request quotas """ import time -from typing import Optional, Dict +from typing import Optional, Dict, Callable from datetime import datetime, timedelta +from functools import wraps import hashlib import secrets from dataclasses import dataclass +from fastapi import HTTPException, Request + + +# In-memory rate limit storage (per-process, resets on restart) +_rate_limit_store: Dict[str, list] = {} +_last_cleanup: float = 0.0 +_CLEANUP_INTERVAL_SEC = 60 + + +def rate_limit(requests_per_minute: int = 60): + """ + Simple rate limit decorator for FastAPI routes. + Uses in-memory storage - suitable for single-instance deployments. + For production, use Redis-backed RateLimiter class instead. + + IMPORTANT: Routes using this decorator MUST include `request: Request` as + a parameter. For correct client IP detection behind a reverse proxy, + configure Uvicorn with: + --proxy-headers --forwarded-allow-ips="" + This ensures request.client.host reflects the real client IP from + X-Forwarded-For header, not the proxy's IP. + """ + def decorator(func: Callable): + @wraps(func) + async def wrapper(*args, **kwargs): + # Get Request from kwargs (FastAPI injects it by parameter name) + request = kwargs.get('request') + if not request: + for arg in args: + if isinstance(arg, Request): + request = arg + break + + if not request or not isinstance(request, Request): + raise HTTPException( + status_code=500, + detail="Rate limiting requires Request parameter in route" + ) + + # Get client IP (relies on Uvicorn proxy-headers config for real IP) + client_id = request.client.host if request.client else "unknown" + + key = f"{func.__name__}:{client_id}" + now = time.time() + window_start = now - 60 + + # Periodic cleanup of stale keys to prevent unbounded memory growth + global _last_cleanup + if now - _last_cleanup > _CLEANUP_INTERVAL_SEC: + for k in list(_rate_limit_store.keys()): + timestamps = _rate_limit_store.get(k, []) + if not timestamps or timestamps[-1] <= window_start: + _rate_limit_store.pop(k, None) + _last_cleanup = now + + # Clean old entries and get current count + if key not in _rate_limit_store: + _rate_limit_store[key] = [] + + _rate_limit_store[key] = [t for t in _rate_limit_store[key] if t > window_start] + + if len(_rate_limit_store[key]) >= requests_per_minute: + raise HTTPException( + status_code=429, + detail=f"Rate limit exceeded. Max {requests_per_minute} requests per minute." + ) + + _rate_limit_store[key].append(now) + return await func(*args, **kwargs) + + return wrapper + return decorator @dataclass diff --git a/docker-compose.yml b/docker-compose.yml index 4b40e61..c7b4fe2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,8 @@ services: - SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY} - API_KEY=${API_KEY} - BACKEND_API_URL=http://backend:8000 + - DISCORD_FEEDBACK_WEBHOOK=${DISCORD_FEEDBACK_WEBHOOK} + - FORWARDED_ALLOW_IPS=* volumes: - ./backend/repos:/app/repos - ./backend:/app diff --git a/frontend/src/components/FeedbackWidget.tsx b/frontend/src/components/FeedbackWidget.tsx new file mode 100644 index 0000000..15b4975 --- /dev/null +++ b/frontend/src/components/FeedbackWidget.tsx @@ -0,0 +1,223 @@ +import { useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { MessageSquarePlus, X, Send, Loader2, CheckCircle2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useAuth } from '@/contexts/AuthContext' + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' + +type Mood = 'frustrated' | 'meh' | 'good' | 'love' | null + +const moods: { value: Mood; emoji: string; label: string; color: string }[] = [ + { value: 'frustrated', emoji: '😠', label: 'Frustrated', color: 'hover:bg-red-500/20' }, + { value: 'meh', emoji: '😐', label: 'Meh', color: 'hover:bg-yellow-500/20' }, + { value: 'good', emoji: '😊', label: 'Good', color: 'hover:bg-green-500/20' }, + { value: 'love', emoji: '🤩', label: 'Love it!', color: 'hover:bg-purple-500/20' }, +] + +export function FeedbackWidget() { + const { session } = useAuth() + const [isOpen, setIsOpen] = useState(false) + const [mood, setMood] = useState(null) + const [message, setMessage] = useState('') + const [email, setEmail] = useState('') + const [sending, setSending] = useState(false) + const [sent, setSent] = useState(false) + const [error, setError] = useState(null) + + const userEmail = session?.user?.email || '' + + const handleSubmit = async () => { + if (!mood && !message.trim()) return + + setSending(true) + setError(null) + + try { + const response = await fetch(`${API_URL}/api/v1/feedback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + mood: mood || 'good', + message: message.trim() || undefined, + email: userEmail || email || undefined, + }), + }) + + if (!response.ok) { + throw new Error('Failed to submit feedback') + } + + setSent(true) + setTimeout(() => { + setIsOpen(false) + setSent(false) + setMood(null) + setMessage('') + setEmail('') + setError(null) + }, 2000) + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to send feedback' + setError(message) + } finally { + setSending(false) + } + } + + const canSubmit = mood || message.trim() + + return ( + <> + {/* Floating Button */} + { setIsOpen(true); setError(null) }} + className="fixed bottom-6 right-6 z-40 flex items-center gap-2 px-4 py-3 bg-primary text-primary-foreground rounded-full shadow-lg hover:shadow-xl transition-shadow" + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: 1 }} + > + + Feedback + + + {/* Modal */} + + {isOpen && ( + !sending && setIsOpen(false)} + > + e.stopPropagation()} + className="bg-card border border-border rounded-2xl shadow-2xl w-full max-w-md overflow-hidden" + > + {/* Header */} +
+
+

Send Feedback

+

Help us make OpenCodeIntel better

+
+ +
+ + {/* Content */} +
+ {sent ? ( + + +

Thank you!

+

Your feedback means a lot to us

+
+ ) : ( + <> + {/* Mood Selector */} +
+ +
+ {moods.map(m => ( + + ))} +
+ {mood && ( + + {moods.find(m => m.value === mood)?.label} + + )} +
+ + {/* Message */} +
+ +