feat: docker and nginx

Working on deployments
This commit is contained in:
Hayden Hargreaves 2026-03-31 12:40:57 -07:00
parent 6fad123dec
commit 5500449334
11 changed files with 538 additions and 12 deletions

22
Dockerfile Normal file
View File

@ -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"]

Binary file not shown.

14
docker-compose.yml Normal file
View File

@ -0,0 +1,14 @@
services:
backend:
build: .
# ports:
# - "3000:3000"
env_file:
- ./.env
frontend:
build: ./web
ports:
- "3005:80"
depends_on:
- backend

View File

@ -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
}

View File

@ -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)
})

23
web/Dockerfile Normal file
View File

@ -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;"]

36
web/nginx.conf Normal file
View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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 */}
<div className="modal-backdrop" onClick={onClose} />
{/* Modal Content */}
<div className={`modal-container modal-${type}`}>
<div className="modal-header">
<div className="modal-icon">{getIcon()}</div>
<h2 className="modal-title">{title}</h2>
</div>
<div className="modal-body">
<p className="modal-message">{message}</p>
</div>
<div className="modal-footer">
<button
onClick={onClose}
className="btn btn-primary"
autoFocus
>
OK
</button>
</div>
</div>
</>
);
}

15
web/src/config/api.ts Normal file
View File

@ -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;

View File

@ -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<HTMLInputElement>(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() {
</div>
</div>
)}
{/* Error/Warning Modal */}
{/* Trace: SDD_LLD_0030 - Display persistent error messages */}
{/* Trace: SRD_InterfaceReq_0015 - Display errors in persistent manner (not toasts or popups) */}
<Modal
isOpen={modalState.isOpen}
onClose={closeModal}
title={modalState.title}
message={modalState.message}
type={modalState.type}
/>
</div>
);
}