diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9dfaa25 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# Build stage +FROM golang:1.25.5-alpine AS builder + +RUN apk add --no-cache git ca-certificates +WORKDIR /build + +COPY go.mod go.sum ./ +RUN go mod download +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd/server + +# Runtime stage +FROM alpine:latest + +RUN apk --no-cache add ca-certificates +WORKDIR /app +COPY --from=builder /build/server . + +EXPOSE 3000 + +CMD ["./server"] diff --git a/HaydenHargreaves_Oct01.pdf b/HaydenHargreaves_Oct01.pdf deleted file mode 100644 index 8a51962..0000000 Binary files a/HaydenHargreaves_Oct01.pdf and /dev/null differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e6b04bd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + backend: + build: . + # ports: + # - "3000:3000" + env_file: + - ./.env + + frontend: + build: ./web + ports: + - "3005:80" + depends_on: + - backend diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 0001957..118bc35 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -1,14 +1,37 @@ package api -import "net/http" +import ( + "net" + "net/http" + "slices" + "strings" + "sync" + "time" +) -// CORS allows requests from the Vite dev server during development. -// In production, update the allowed origin to match your deployed frontend domain. +// CORS allows requests from the frontend. +// Supports both development (Vite dev server) and production (nginx/Docker) environments. func CORS(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "http://localhost:5173") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + origin := r.Header.Get("Origin") + + // Allow requests from common development and production origins + allowedOrigins := []string{ + "http://localhost:5173", // Vite dev server + "http://localhost", // Docker nginx + "http://localhost:80", // Docker nginx with port + } + + // Check if the origin is allowed + allowed := slices.Contains(allowedOrigins, origin) + + // Set CORS headers if origin is allowed + if allowed { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Allow-Credentials", "true") + } if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) @@ -18,3 +41,89 @@ func CORS(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } + +// requestHistory stores timestamps of requests for each IP address +type requestHistory struct { + mu sync.Mutex + timestamps map[string][]time.Time +} + +var rateLimiter = &requestHistory{ + timestamps: make(map[string][]time.Time), +} + +// RateLimit restricts requests to 10 per hour per IP address +// Trace: SRD_FuncReq_0014 - Prevent users from grading more than 10 resumes per hour +// Trace: SRD_SecReq_0004 - Implement rate limit of 10 requests per hour +// Trace: SRD_SecReq_0006 - Use device sending IP to impose rate limit +// Trace: SDD_LLD_0011 - Restrict request frequency to 10 calls per hour per unique source IP +// Trace: SDD_HLD_0006 - Function only within call rate limit of 10 calls per hour +func RateLimit(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Extract client IP address + // Trace: SRD_SecReq_0006 - Use device sending IP to impose rate limit + ip := getClientIP(r) + + rateLimiter.mu.Lock() + defer rateLimiter.mu.Unlock() + + now := time.Now() + oneHourAgo := now.Add(-1 * time.Hour) + + // Get request history for this IP + history := rateLimiter.timestamps[ip] + + // Filter out requests older than 1 hour + var recentRequests []time.Time + for _, timestamp := range history { + if timestamp.After(oneHourAgo) { + recentRequests = append(recentRequests, timestamp) + } + } + + // Check if rate limit exceeded + // Trace: SDD_LLD_0011 - Restrict to 10 calls per hour per IP + if len(recentRequests) >= 10 { + // Trace: SRD_UseCase_0005, SRD_UseCase_0006 - Rate limit error handling + // Trace: SRD_QualAssurReq_0006 - Return error when rate limit reached + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + w.Write([]byte(`{"error": "Rate limit exceeded. You can make up to 10 requests per hour. Please try again later."}`)) + return + } + + // Add current request to history + recentRequests = append(recentRequests, now) + rateLimiter.timestamps[ip] = recentRequests + + // Allow request to proceed + next.ServeHTTP(w, r) + }) +} + +// getClientIP extracts the real client IP address from the request +// Trace: SRD_SecReq_0006 - Use device sending IP to impose rate limit +func getClientIP(r *http.Request) string { + // Check X-Forwarded-For header (for requests behind proxies/load balancers) + xff := r.Header.Get("X-Forwarded-For") + if xff != "" { + // X-Forwarded-For can contain multiple IPs, take the first one + ips := strings.Split(xff, ",") + if len(ips) > 0 { + return strings.TrimSpace(ips[0]) + } + } + + // Check X-Real-IP header (alternative proxy header) + xri := r.Header.Get("X-Real-IP") + if xri != "" { + return xri + } + + // Fall back to RemoteAddr (direct connection) + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return ip +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 898f90d..d54ea1c 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -8,10 +8,17 @@ import ( // Mount registers all API routes onto the provided router. // Trace: SDD_LLD_0005 - Provide HTTP handler and endpoint for form data uploads +// Trace: SDD_LLD_0011 - Enforce per-IP rate limit (10/hr) at middleware level func Mount(r chi.Router) { r.Use(CORS) r.Route("/api", func(r chi.Router) { + // Apply rate limiting to all API endpoints + // Trace: SRD_FuncReq_0014 - Prevent more than 10 resume gradings per hour + // Trace: SRD_SecReq_0004 - Implement rate limit of 10 requests per hour + // Trace: SDD_LLD_0011 - Rate limiting implemented at middleware level + r.Use(RateLimit) + // Trace: SDD_LLD_0005 - HTTP endpoint that accepts multipart/form data uploads r.Post("/analyze", handlers.Analyze) }) diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..42e33f2 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,23 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy and install files +COPY package*.json ./ +RUN npm ci +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy built files from builder and project +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/web/nginx.conf b/web/nginx.conf new file mode 100644 index 0000000..73b10d6 --- /dev/null +++ b/web/nginx.conf @@ -0,0 +1,36 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Enable gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript; + + # Serve static files + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Proxy API requests to backend + location /api/ { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/web/src/assets/css/modal.css b/web/src/assets/css/modal.css new file mode 100644 index 0000000..28f84bd --- /dev/null +++ b/web/src/assets/css/modal.css @@ -0,0 +1,148 @@ +/* Modal Styles - Trace: SDD_LLD_0030, SRD_InterfaceReq_0015 */ + +/* Backdrop - darkens background when modal is open */ +.modal-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* Modal Container */ +.modal-container { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: white; + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + z-index: 1001; + max-width: 500px; + width: 90%; + animation: slideUp 0.3s ease; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translate(-50%, -40%); + } + to { + opacity: 1; + transform: translate(-50%, -50%); + } +} + +/* Modal Header */ +.modal-header { + padding: 32px 32px 24px 32px; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + border-bottom: 1px solid #e2e8f0; +} + +.modal-icon { + font-size: 3rem; + line-height: 1; +} + +.modal-title { + font-size: 1.5rem; + font-weight: 700; + color: #1a1a1a; + margin: 0; + text-align: center; +} + +/* Modal Body */ +.modal-body { + padding: 24px 32px; +} + +.modal-message { + font-size: 1rem; + color: #4a5568; + line-height: 1.6; + margin: 0; + text-align: center; +} + +/* Modal Footer */ +.modal-footer { + padding: 16px 32px 32px 32px; + display: flex; + justify-content: center; +} + +.modal-footer .btn { + min-width: 120px; +} + +/* Modal Type Variants */ +.modal-error .modal-header { + border-bottom-color: #feb2b2; +} + +.modal-error .modal-icon { + color: #f56565; +} + +.modal-warning .modal-header { + border-bottom-color: #fbd38d; +} + +.modal-warning .modal-icon { + color: #ed8936; +} + +.modal-info .modal-header { + border-bottom-color: #90cdf4; +} + +.modal-info .modal-icon { + color: #4299e1; +} + +/* Responsive Design */ +@media (max-width: 640px) { + .modal-container { + width: 95%; + max-width: none; + } + + .modal-header { + padding: 24px 20px 16px 20px; + } + + .modal-body { + padding: 20px; + } + + .modal-footer { + padding: 12px 20px 24px 20px; + } + + .modal-title { + font-size: 1.25rem; + } + + .modal-icon { + font-size: 2.5rem; + } +} diff --git a/web/src/components/Modal.tsx b/web/src/components/Modal.tsx new file mode 100644 index 0000000..a38a0cd --- /dev/null +++ b/web/src/components/Modal.tsx @@ -0,0 +1,87 @@ +import { useEffect } from 'react'; +import '../assets/css/modal.css'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + message: string; + type?: 'error' | 'warning' | 'info'; +} + +/** + * Modal component for displaying messages to the user + * Trace: SDD_LLD_0030 - Display persistent error messages + * Trace: SRD_InterfaceReq_0015 - Display errors in persistent manner (not toasts or popups) + * Trace: SRD_FuncReq_0015 - Display any errors that may occur in the process + */ +export default function Modal({ isOpen, onClose, title, message, type = 'error' }: ModalProps) { + // Close modal on Escape key press + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isOpen) { + onClose(); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isOpen, onClose]); + + // Prevent body scroll when modal is open + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = 'unset'; + } + + return () => { + document.body.style.overflow = 'unset'; + }; + }, [isOpen]); + + if (!isOpen) return null; + + const getIcon = () => { + switch (type) { + case 'error': + return '⚠️'; + case 'warning': + return '⚠️'; + case 'info': + return 'ℹ️'; + default: + return '⚠️'; + } + }; + + return ( + <> + {/* Modal Backdrop */} +
+ + {/* Modal Content */} +
+
+
{getIcon()}
+

{title}

+
+ +
+

{message}

+
+ +
+ +
+
+ + ); +} diff --git a/web/src/config/api.ts b/web/src/config/api.ts new file mode 100644 index 0000000..f8c5f59 --- /dev/null +++ b/web/src/config/api.ts @@ -0,0 +1,15 @@ +/** + * API configuration + * Uses environment variable or defaults based on environment + */ + +// In production (Docker), use relative path so nginx can proxy +// In development, use full URL to reach the Go backend directly +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || + (import.meta.env.MODE === 'production' ? '' : 'http://localhost:3000'); + +export const API_ENDPOINTS = { + analyze: `${API_BASE_URL}/api/analyze`, +} as const; + +export default API_BASE_URL; diff --git a/web/src/pages/upload.tsx b/web/src/pages/upload.tsx index cb41c26..bfff5c3 100644 --- a/web/src/pages/upload.tsx +++ b/web/src/pages/upload.tsx @@ -1,7 +1,8 @@ import { useState, useRef } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import type { ResumeAnalysisResult } from '../types/resumeAnalysis'; -import { mockAnalysisResult } from '../data/mockAnalysis'; +import Modal from '../components/Modal'; +import { API_ENDPOINTS } from '../config/api'; import '../assets/css/upload.css'; /** @@ -19,9 +20,29 @@ export default function UploadPage() { const [jobDescription, setJobDescription] = useState(''); const [isDragging, setIsDragging] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false); + const [modalState, setModalState] = useState<{ + isOpen: boolean; + title: string; + message: string; + type: 'error' | 'warning' | 'info'; + }>({ + isOpen: false, + title: '', + message: '', + type: 'error' + }); const fileInputRef = useRef(null); const navigate = useNavigate(); + // Helper to show modal + const showModal = (title: string, message: string, type: 'error' | 'warning' | 'info' = 'error') => { + setModalState({ isOpen: true, title, message, type }); + }; + + const closeModal = () => { + setModalState(prev => ({ ...prev, isOpen: false })); + }; + // Trace: SDD_LLD_0001 - Accept only PDF uploads (validation) // Trace: SDD_HLD_0001 - Accept only PDF formatting for input resumes const handleFileSelect = (file: File) => { @@ -43,7 +64,12 @@ export default function UploadPage() { } } else { // Trace: SDD_LLD_0002 - Reject non-PDF uploads - alert('Please upload a PDF or image file'); + // Trace: SRD_InterfaceReq_0015 - Display errors in persistent manner + showModal( + 'Invalid File Type', + 'Please upload a PDF or image file. Other file formats are not supported.', + 'warning' + ); } }; @@ -86,11 +112,19 @@ export default function UploadPage() { // Trace: SDD_LLD_0027 - Show loading spinner during active API calls const handleAnalyze = async () => { if (!resumeFile) { - alert('Please upload a resume first'); + showModal( + 'Resume Required', + 'Please upload a resume before analyzing.', + 'warning' + ); return; } if (!jobDescription.trim()) { - alert('Please enter a job description'); + showModal( + 'Job Description Required', + 'Please enter a job description to compare your resume against.', + 'warning' + ); return; } @@ -104,7 +138,22 @@ export default function UploadPage() { formData.append('job_description', jobDescription); // Trace: SDD_HLD_0005 - Provide structured inputs to AI grader - const response = await fetch('http://localhost:3000/api/analyze', { method: 'POST', body: formData }); + const response = await fetch(API_ENDPOINTS.analyze, { method: 'POST', body: formData }); + + // Trace: SRD_UseCase_0006 - Display error message when rate limit reached + // Trace: SRD_QualAssurReq_0006 - Handle rate limit error appropriately + // Trace: SRD_InterfaceReq_0015 - Display errors in persistent manner + if (response.status === 429) { + const errorData = await response.json(); + showModal( + 'Rate Limit Exceeded', + errorData.error || 'You can make up to 10 requests per hour. Please try again later.', + 'warning' + ); + setIsAnalyzing(false); + return; + } + if (!response.ok) throw new Error(`Server error: ${response.status}`); // Trace: SDD_LLD_0019 - Receive JSON output from backend @@ -114,8 +163,13 @@ export default function UploadPage() { navigate('/results', { state: analysisData }); } catch (error) { // Trace: SDD_LLD_0030 - Display persistent error messages + // Trace: SRD_InterfaceReq_0015 - Display errors in persistent manner console.error('Error analyzing resume:', error); - alert('Failed to analyze resume. Please try again.'); + showModal( + 'Analysis Failed', + 'Failed to analyze resume. Please check your connection and try again.', + 'error' + ); setIsAnalyzing(false); } }; @@ -261,6 +315,17 @@ export default function UploadPage() {
)} + + {/* Error/Warning Modal */} + {/* Trace: SDD_LLD_0030 - Display persistent error messages */} + {/* Trace: SRD_InterfaceReq_0015 - Display errors in persistent manner (not toasts or popups) */} + ); }