Compare commits
3 Commits
138b473418
...
623411b093
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
623411b093 | ||
|
|
45ab058643 | ||
|
|
c7bc0ecb1d |
279
PRODUCTION-DEPLOYMENT.md
Normal file
279
PRODUCTION-DEPLOYMENT.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# Production Deployment Guide for dk0.dev
|
||||
|
||||
This guide will help you deploy the portfolio application to production on dk0.dev.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Server Requirements:**
|
||||
- Ubuntu 20.04+ or similar Linux distribution
|
||||
- Docker and Docker Compose installed
|
||||
- Nginx or Traefik for reverse proxy
|
||||
- SSL certificates (Let's Encrypt recommended)
|
||||
- Domain `dk0.dev` pointing to your server
|
||||
|
||||
2. **Required Environment Variables:**
|
||||
- `MY_EMAIL`: Your contact email
|
||||
- `MY_INFO_EMAIL`: Your info email
|
||||
- `MY_PASSWORD`: Email password
|
||||
- `MY_INFO_PASSWORD`: Info email password
|
||||
- `ADMIN_BASIC_AUTH`: Admin credentials (format: `username:password`)
|
||||
|
||||
## Quick Deployment
|
||||
|
||||
### 1. Clone and Setup
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone <your-repo-url>
|
||||
cd portfolio
|
||||
|
||||
# Make deployment script executable
|
||||
chmod +x scripts/production-deploy.sh
|
||||
```
|
||||
|
||||
### 2. Configure Environment
|
||||
|
||||
Create a `.env` file with your production settings:
|
||||
|
||||
```bash
|
||||
# Copy the example
|
||||
cp env.example .env
|
||||
|
||||
# Edit with your values
|
||||
nano .env
|
||||
```
|
||||
|
||||
Required values:
|
||||
```env
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=https://dk0.dev
|
||||
MY_EMAIL=contact@dk0.dev
|
||||
MY_INFO_EMAIL=info@dk0.dev
|
||||
MY_PASSWORD=your-actual-email-password
|
||||
MY_INFO_PASSWORD=your-actual-info-password
|
||||
ADMIN_BASIC_AUTH=admin:your-secure-password
|
||||
```
|
||||
|
||||
### 3. Deploy
|
||||
|
||||
```bash
|
||||
# Run the production deployment script
|
||||
./scripts/production-deploy.sh
|
||||
```
|
||||
|
||||
### 4. Setup Reverse Proxy
|
||||
|
||||
#### Option A: Nginx (Recommended)
|
||||
|
||||
1. Install Nginx:
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install nginx
|
||||
```
|
||||
|
||||
2. Copy the production nginx config:
|
||||
```bash
|
||||
sudo cp nginx.production.conf /etc/nginx/nginx.conf
|
||||
```
|
||||
|
||||
3. Setup SSL certificates:
|
||||
```bash
|
||||
# Install Certbot
|
||||
sudo apt install certbot python3-certbot-nginx
|
||||
|
||||
# Get SSL certificate
|
||||
sudo certbot --nginx -d dk0.dev -d www.dk0.dev
|
||||
```
|
||||
|
||||
4. Restart Nginx:
|
||||
```bash
|
||||
sudo systemctl restart nginx
|
||||
sudo systemctl enable nginx
|
||||
```
|
||||
|
||||
#### Option B: Traefik
|
||||
|
||||
If using Traefik, ensure your Docker Compose file includes Traefik labels:
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.portfolio.rule=Host(`dk0.dev`)"
|
||||
- "traefik.http.routers.portfolio.tls=true"
|
||||
- "traefik.http.routers.portfolio.tls.certresolver=letsencrypt"
|
||||
```
|
||||
|
||||
## Manual Deployment Steps
|
||||
|
||||
If you prefer manual deployment:
|
||||
|
||||
### 1. Create Proxy Network
|
||||
|
||||
```bash
|
||||
docker network create proxy
|
||||
```
|
||||
|
||||
### 2. Build and Start Services
|
||||
|
||||
```bash
|
||||
# Build the application
|
||||
docker build -t portfolio-app:latest .
|
||||
|
||||
# Start services
|
||||
docker-compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
### 3. Run Database Migrations
|
||||
|
||||
```bash
|
||||
# Wait for services to be healthy
|
||||
sleep 30
|
||||
|
||||
# Run migrations
|
||||
docker exec portfolio-app npx prisma db push
|
||||
```
|
||||
|
||||
### 4. Verify Deployment
|
||||
|
||||
```bash
|
||||
# Check health
|
||||
curl http://localhost:3000/api/health
|
||||
|
||||
# Check admin panel
|
||||
curl http://localhost:3000/manage
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### 1. Update Default Passwords
|
||||
|
||||
**CRITICAL:** Change these default values:
|
||||
|
||||
```env
|
||||
# Change the admin password
|
||||
ADMIN_BASIC_AUTH=admin:your-very-secure-password-here
|
||||
|
||||
# Use strong email passwords
|
||||
MY_PASSWORD=your-strong-email-password
|
||||
MY_INFO_PASSWORD=your-strong-info-password
|
||||
```
|
||||
|
||||
### 2. Firewall Configuration
|
||||
|
||||
```bash
|
||||
# Allow only necessary ports
|
||||
sudo ufw allow 22 # SSH
|
||||
sudo ufw allow 80 # HTTP
|
||||
sudo ufw allow 443 # HTTPS
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
### 3. SSL/TLS Configuration
|
||||
|
||||
Ensure you have valid SSL certificates. The nginx configuration expects:
|
||||
- `/etc/nginx/ssl/cert.pem` (SSL certificate)
|
||||
- `/etc/nginx/ssl/key.pem` (SSL private key)
|
||||
|
||||
## Monitoring and Maintenance
|
||||
|
||||
### 1. Health Checks
|
||||
|
||||
```bash
|
||||
# Check application health
|
||||
curl https://dk0.dev/api/health
|
||||
|
||||
# Check container status
|
||||
docker-compose ps
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
### 2. Backup Database
|
||||
|
||||
```bash
|
||||
# Create backup
|
||||
docker exec portfolio-postgres pg_dump -U portfolio_user portfolio_db > backup.sql
|
||||
|
||||
# Restore backup
|
||||
docker exec -i portfolio-postgres psql -U portfolio_user portfolio_db < backup.sql
|
||||
```
|
||||
|
||||
### 3. Update Application
|
||||
|
||||
```bash
|
||||
# Pull latest changes
|
||||
git pull origin main
|
||||
|
||||
# Rebuild and restart
|
||||
docker-compose down
|
||||
docker build -t portfolio-app:latest .
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Port 3000 not accessible:**
|
||||
- Check if the container is running: `docker ps`
|
||||
- Check logs: `docker-compose logs portfolio`
|
||||
|
||||
2. **Database connection issues:**
|
||||
- Ensure PostgreSQL is healthy: `docker-compose ps`
|
||||
- Check database logs: `docker-compose logs postgres`
|
||||
|
||||
3. **SSL certificate issues:**
|
||||
- Verify certificate files exist and are readable
|
||||
- Check nginx configuration: `nginx -t`
|
||||
|
||||
4. **Rate limiting issues:**
|
||||
- Check nginx rate limiting configuration
|
||||
- Adjust limits in `nginx.production.conf`
|
||||
|
||||
### Logs and Debugging
|
||||
|
||||
```bash
|
||||
# Application logs
|
||||
docker-compose logs -f portfolio
|
||||
|
||||
# Database logs
|
||||
docker-compose logs -f postgres
|
||||
|
||||
# Nginx logs
|
||||
sudo tail -f /var/log/nginx/access.log
|
||||
sudo tail -f /var/log/nginx/error.log
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### 1. Resource Limits
|
||||
|
||||
The production Docker Compose file includes resource limits:
|
||||
- Portfolio app: 1GB RAM, 1 CPU
|
||||
- PostgreSQL: 512MB RAM, 0.5 CPU
|
||||
- Redis: 256MB RAM, 0.25 CPU
|
||||
|
||||
### 2. Caching
|
||||
|
||||
- Static assets are cached for 1 year
|
||||
- API responses are cached for 10 minutes
|
||||
- Admin routes are not cached for security
|
||||
|
||||
### 3. Rate Limiting
|
||||
|
||||
- API routes: 20 requests/second
|
||||
- Login routes: 10 requests/minute
|
||||
- Admin routes: 5 requests/minute
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter issues:
|
||||
|
||||
1. Check the logs first
|
||||
2. Verify all environment variables are set
|
||||
3. Ensure all services are healthy
|
||||
4. Check network connectivity
|
||||
5. Verify SSL certificates are valid
|
||||
|
||||
For additional help, check the application logs and ensure all prerequisites are met.
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { projectService } from '@/lib/prisma';
|
||||
import { analyticsCache } from '@/lib/redis';
|
||||
import { requireAdminAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
|
||||
import { requireSessionAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -24,7 +24,7 @@ export async function GET(request: NextRequest) {
|
||||
// The middleware has already verified the admin session for /manage routes
|
||||
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
|
||||
if (!isAdminRequest) {
|
||||
const authError = requireAdminAuth(request);
|
||||
const authError = requireSessionAuth(request);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
import { requireSessionAuth } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Check admin authentication - for admin dashboard requests, we trust the session
|
||||
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
|
||||
if (!isAdminRequest) {
|
||||
const authError = requireAdminAuth(request);
|
||||
const authError = requireSessionAuth(request);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { analyticsCache } from '@/lib/redis';
|
||||
import { requireAdminAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
|
||||
import { requireSessionAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -23,7 +23,7 @@ export async function POST(request: NextRequest) {
|
||||
// Check admin authentication
|
||||
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
|
||||
if (!isAdminRequest) {
|
||||
const authError = requireAdminAuth(request);
|
||||
const authError = requireSessionAuth(request);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
@@ -5,14 +5,14 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
||||
if (!checkRateLimit(ip, 5, 60000)) { // 5 login attempts per minute
|
||||
if (!checkRateLimit(ip, 20, 60000)) { // 20 login attempts per minute
|
||||
return new NextResponse(
|
||||
JSON.stringify({ error: 'Rate limit exceeded' }),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getRateLimitHeaders(ip, 5, 60000)
|
||||
...getRateLimitHeaders(ip, 20, 60000)
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
25
app/api/auth/logout/route.ts
Normal file
25
app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
// Simple logout - just return success
|
||||
// The client will handle clearing the session storage
|
||||
return new NextResponse(
|
||||
JSON.stringify({ success: true, message: 'Logged out successfully' }),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0'
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
return new NextResponse(
|
||||
JSON.stringify({ error: 'Logout failed' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { apiCache } from '@/lib/cache';
|
||||
import { requireAdminAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
|
||||
import { requireSessionAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -20,10 +20,10 @@ export async function GET(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Check admin authentication for admin endpoints
|
||||
// Check session authentication for admin endpoints
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname.includes('/manage') || request.headers.get('x-admin-request') === 'true') {
|
||||
const authError = requireAdminAuth(request);
|
||||
const authError = requireSessionAuth(request);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Lock, Loader2 } from 'lucide-react';
|
||||
import ModernAdminDashboard from '@/components/ModernAdminDashboard';
|
||||
|
||||
// Security constants
|
||||
const MAX_ATTEMPTS = 3;
|
||||
// Constants
|
||||
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
|
||||
const RATE_LIMIT_DELAY = 1000; // 1 second base delay
|
||||
|
||||
// Rate limiting with exponential backoff
|
||||
const getRateLimitDelay = (attempts: number): number => {
|
||||
return RATE_LIMIT_DELAY * Math.pow(2, attempts);
|
||||
};
|
||||
@@ -93,63 +83,56 @@ const AdminPage = () => {
|
||||
|
||||
// Check session validity via API
|
||||
const checkSession = useCallback(async () => {
|
||||
const authStatus = sessionStorage.getItem('admin_authenticated');
|
||||
try {
|
||||
const sessionToken = sessionStorage.getItem('admin_session_token');
|
||||
const csrfToken = authState.csrfToken;
|
||||
|
||||
// If no session data, show login immediately
|
||||
if (!authStatus || !sessionToken || !csrfToken) {
|
||||
if (!sessionToken) {
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
showLogin: true
|
||||
showLogin: true,
|
||||
isLoading: false
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/validate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrfToken
|
||||
'X-CSRF-Token': authState.csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sessionToken,
|
||||
csrfToken
|
||||
csrfToken: authState.csrfToken
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.valid) {
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
showLogin: false
|
||||
showLogin: false,
|
||||
isLoading: false
|
||||
}));
|
||||
return;
|
||||
sessionStorage.setItem('admin_authenticated', 'true');
|
||||
} else {
|
||||
// Clear invalid session
|
||||
sessionStorage.removeItem('admin_authenticated');
|
||||
sessionStorage.removeItem('admin_session_token');
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
showLogin: true
|
||||
showLogin: true,
|
||||
isLoading: false
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Session validation error:', error);
|
||||
// Clear session on error
|
||||
sessionStorage.removeItem('admin_authenticated');
|
||||
sessionStorage.removeItem('admin_session_token');
|
||||
} catch {
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
showLogin: true
|
||||
showLogin: true,
|
||||
isLoading: false
|
||||
}));
|
||||
}
|
||||
}, [authState.csrfToken]);
|
||||
@@ -172,20 +155,8 @@ const AdminPage = () => {
|
||||
if (authState.csrfToken && !authState.isLocked) {
|
||||
checkSession();
|
||||
}
|
||||
}, [authState.csrfToken, authState.isLocked]);
|
||||
}, [authState.csrfToken, authState.isLocked, checkSession]);
|
||||
|
||||
// Handle logout
|
||||
const handleLogout = useCallback(() => {
|
||||
sessionStorage.removeItem('admin_authenticated');
|
||||
sessionStorage.removeItem('admin_session_token');
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
isAuthenticated: false,
|
||||
showLogin: true,
|
||||
password: '',
|
||||
error: ''
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Handle login form submission
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
@@ -215,95 +186,55 @@ const AdminPage = () => {
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
// Store session
|
||||
sessionStorage.setItem('admin_authenticated', 'true');
|
||||
sessionStorage.setItem('admin_session_token', data.sessionToken);
|
||||
|
||||
// Clear lockout data
|
||||
localStorage.removeItem('admin_lockout');
|
||||
|
||||
// Update state
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
isAuthenticated: true,
|
||||
showLogin: false,
|
||||
isLoading: false,
|
||||
password: '',
|
||||
error: '',
|
||||
attempts: 0,
|
||||
error: ''
|
||||
isLoading: false
|
||||
}));
|
||||
localStorage.removeItem('admin_lockout');
|
||||
} else {
|
||||
// Failed login
|
||||
const newAttempts = authState.attempts + 1;
|
||||
const newLastAttempt = Date.now();
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
error: data.error || 'Login failed',
|
||||
attempts: newAttempts,
|
||||
isLoading: false
|
||||
}));
|
||||
|
||||
if (newAttempts >= MAX_ATTEMPTS) {
|
||||
// Lock user out
|
||||
if (newAttempts >= 5) {
|
||||
localStorage.setItem('admin_lockout', JSON.stringify({
|
||||
timestamp: newLastAttempt,
|
||||
timestamp: Date.now(),
|
||||
attempts: newAttempts
|
||||
}));
|
||||
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
isLocked: true,
|
||||
attempts: newAttempts,
|
||||
lastAttempt: newLastAttempt,
|
||||
isLoading: false,
|
||||
error: `Too many failed attempts. Access locked for ${Math.ceil(LOCKOUT_DURATION / 60000)} minutes.`
|
||||
}));
|
||||
} else {
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
attempts: newAttempts,
|
||||
lastAttempt: newLastAttempt,
|
||||
isLoading: false,
|
||||
error: data.error || `Wrong password. ${MAX_ATTEMPTS - newAttempts} attempts remaining.`,
|
||||
password: ''
|
||||
error: 'Too many failed attempts. Please try again in 15 minutes.'
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: 'An error occurred. Please try again.'
|
||||
error: 'Network error. Please try again.',
|
||||
isLoading: false
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Get remaining lockout time
|
||||
const getRemainingTime = () => {
|
||||
const lockoutData = localStorage.getItem('admin_lockout');
|
||||
if (lockoutData) {
|
||||
try {
|
||||
const { timestamp } = JSON.parse(lockoutData);
|
||||
const remaining = Math.ceil((LOCKOUT_DURATION - (Date.now() - timestamp)) / 1000 / 60);
|
||||
return Math.max(0, remaining);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (authState.isLoading && !authState.showLogin) {
|
||||
if (authState.isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="fixed inset-0 animated-bg"></div>
|
||||
<div className="relative z-10 min-h-screen flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-center admin-glass-card p-8 rounded-2xl"
|
||||
>
|
||||
<div className="w-16 h-16 bg-gradient-to-r from-blue-500 to-purple-500 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg">
|
||||
<Loader2 className="w-8 h-8 text-white animate-spin" />
|
||||
</div>
|
||||
<p className="text-white text-xl font-semibold">Verifying Access...</p>
|
||||
<p className="text-white/60 text-sm mt-2">Please wait while we authenticate your session</p>
|
||||
</motion.div>
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-4 text-blue-500" />
|
||||
<p className="text-white">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -312,44 +243,20 @@ const AdminPage = () => {
|
||||
// Lockout state
|
||||
if (authState.isLocked) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="fixed inset-0 animated-bg"></div>
|
||||
<div className="relative z-10 min-h-screen flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="admin-glass-card border-red-500/40 p-8 lg:p-12 rounded-2xl max-w-md w-full text-center shadow-2xl"
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Lock className="w-16 h-16 mx-auto mb-4 text-red-500" />
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Account Locked</h2>
|
||||
<p className="text-white/60">Too many failed attempts. Please try again in 15 minutes.</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.removeItem('admin_lockout');
|
||||
window.location.reload();
|
||||
}}
|
||||
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
<div className="mb-8">
|
||||
<div className="w-16 h-16 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg">
|
||||
<Shield className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-white mb-3">Access Locked</h1>
|
||||
<p className="text-white/80 text-lg">
|
||||
Too many failed authentication attempts
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-glass-light border border-red-500/40 rounded-xl p-6 mb-8">
|
||||
<AlertTriangle className="w-8 h-8 text-red-400 mx-auto mb-4" />
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-white/60 mb-1">Attempts</p>
|
||||
<p className="text-red-300 font-bold text-lg">{authState.attempts}/{MAX_ATTEMPTS}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/60 mb-1">Time Left</p>
|
||||
<p className="text-orange-300 font-bold text-lg">{getRemainingTime()}m</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-glass-light border border-blue-500/30 rounded-xl p-4">
|
||||
<p className="text-white/70 text-sm">
|
||||
Access will be automatically restored in {Math.ceil(LOCKOUT_DURATION / 60000)} minutes
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -358,100 +265,43 @@ const AdminPage = () => {
|
||||
// Login form
|
||||
if (authState.showLogin || !authState.isAuthenticated) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Animated Background - same as admin dashboard */}
|
||||
<div className="fixed inset-0 animated-bg"></div>
|
||||
|
||||
<div className="relative z-10 min-h-screen flex items-center justify-center p-4">
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="admin-glass-card p-8 lg:p-12 rounded-2xl max-w-md w-full shadow-2xl"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="w-full max-w-md p-8"
|
||||
>
|
||||
<div className="bg-white/10 backdrop-blur-lg rounded-2xl p-8 border border-white/20">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-gradient-to-r from-blue-500 to-purple-500 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg">
|
||||
<Shield className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-white mb-3">Admin Panel</h1>
|
||||
<p className="text-white/80 text-lg">Secure access to dashboard</p>
|
||||
<div className="flex items-center justify-center space-x-2 mt-4">
|
||||
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
||||
<span className="text-white/60 text-sm font-medium">System Online</span>
|
||||
<Lock className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Admin Access</h1>
|
||||
<p className="text-white/60">Enter your password to continue</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-white/80 mb-3">
|
||||
Admin Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={authState.showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
value={authState.password}
|
||||
onChange={(e) => setAuthState(prev => ({ ...prev, password: e.target.value }))}
|
||||
className="w-full px-4 py-4 admin-glass-light border border-white/30 rounded-xl text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500/50 transition-all text-lg pr-12"
|
||||
placeholder="Enter admin password"
|
||||
required
|
||||
placeholder="Enter password"
|
||||
className="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
disabled={authState.isLoading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAuthState(prev => ({ ...prev, showPassword: !prev.showPassword }))}
|
||||
className="absolute right-4 top-1/2 transform -translate-y-1/2 text-white/60 hover:text-white transition-colors p-1"
|
||||
disabled={authState.isLoading}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-white/50 hover:text-white"
|
||||
>
|
||||
{authState.showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
{authState.showPassword ? '👁️' : '👁️🗨️'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{authState.error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="admin-glass-light border border-red-500/40 rounded-xl p-4 flex items-center space-x-3"
|
||||
>
|
||||
<XCircle className="w-5 h-5 text-red-400 flex-shrink-0" />
|
||||
<p className="text-red-300 text-sm font-medium">{authState.error}</p>
|
||||
</motion.div>
|
||||
<p className="mt-2 text-red-400 text-sm">{authState.error}</p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Security info */}
|
||||
<div className="admin-glass-light border border-blue-500/30 rounded-xl p-6">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<Shield className="w-5 h-5 text-blue-400" />
|
||||
<h3 className="text-blue-300 font-semibold">Security Information</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/60">Max Attempts:</span>
|
||||
<span className="text-white font-medium">{MAX_ATTEMPTS}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/60">Lockout:</span>
|
||||
<span className="text-white font-medium">{Math.ceil(LOCKOUT_DURATION / 60000)}m</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/60">Session:</span>
|
||||
<span className="text-white font-medium">2h</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-white/60">Attempts:</span>
|
||||
<span className={`font-medium ${authState.attempts > 0 ? 'text-orange-400' : 'text-green-400'}`}>
|
||||
{authState.attempts}/{MAX_ATTEMPTS}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -472,20 +322,9 @@ const AdminPage = () => {
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Debug: Clear Session Button */}
|
||||
<div className="mt-6 pt-6 border-t border-white/20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="w-full text-white/60 hover:text-white/80 text-sm py-2 px-4 rounded-lg border border-white/20 hover:border-white/40 transition-all"
|
||||
>
|
||||
Clear Session & Reload
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -226,15 +226,7 @@ export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps)
|
||||
return colors[index % colors.length];
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="admin-glass-card p-8 rounded-xl text-center">
|
||||
<BarChart3 className="w-16 h-16 text-white/40 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold text-white mb-2">Authentication Required</h3>
|
||||
<p className="text-white/60">Please log in to view analytics data</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Authentication disabled - show analytics directly
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
|
||||
@@ -62,13 +62,13 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
|
||||
const [systemStats, setSystemStats] = useState<Record<string, unknown> | null>(null);
|
||||
|
||||
const loadProjects = useCallback(async () => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const sessionToken = sessionStorage.getItem('admin_session_token');
|
||||
const response = await fetch('/api/projects', {
|
||||
headers: {
|
||||
'x-admin-request': 'true'
|
||||
'x-admin-request': 'true',
|
||||
'x-session-token': sessionToken || ''
|
||||
}
|
||||
});
|
||||
|
||||
@@ -85,15 +85,15 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
}, []);
|
||||
|
||||
const loadAnalytics = useCallback(async () => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
try {
|
||||
const sessionToken = sessionStorage.getItem('admin_session_token');
|
||||
const response = await fetch('/api/analytics/dashboard', {
|
||||
headers: {
|
||||
'x-admin-request': 'true'
|
||||
'x-admin-request': 'true',
|
||||
'x-session-token': sessionToken || ''
|
||||
}
|
||||
});
|
||||
|
||||
@@ -104,15 +104,15 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
|
||||
} catch (error) {
|
||||
console.error('Error loading analytics:', error);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
}, []);
|
||||
|
||||
const loadEmails = useCallback(async () => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
try {
|
||||
const sessionToken = sessionStorage.getItem('admin_session_token');
|
||||
const response = await fetch('/api/contacts', {
|
||||
headers: {
|
||||
'x-admin-request': 'true'
|
||||
'x-admin-request': 'true',
|
||||
'x-session-token': sessionToken || ''
|
||||
}
|
||||
});
|
||||
|
||||
@@ -123,15 +123,15 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
|
||||
} catch (error) {
|
||||
console.error('Error loading emails:', error);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
}, []);
|
||||
|
||||
const loadSystemStats = useCallback(async () => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
try {
|
||||
const sessionToken = sessionStorage.getItem('admin_session_token');
|
||||
const response = await fetch('/api/health', {
|
||||
headers: {
|
||||
'x-admin-request': 'true'
|
||||
'x-admin-request': 'true',
|
||||
'x-session-token': sessionToken || ''
|
||||
}
|
||||
});
|
||||
|
||||
@@ -142,7 +142,7 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
|
||||
} catch (error) {
|
||||
console.error('Error loading system stats:', error);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
}, []);
|
||||
|
||||
const loadAllData = useCallback(async () => {
|
||||
await Promise.all([
|
||||
@@ -168,11 +168,9 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Load all data if authenticated
|
||||
if (isAuthenticated) {
|
||||
// Load all data (authentication disabled)
|
||||
loadAllData();
|
||||
}
|
||||
}, [isAuthenticated, loadAllData]);
|
||||
}, [loadAllData]);
|
||||
|
||||
const navigation = [
|
||||
{ id: 'overview', label: 'Dashboard', icon: Home, color: 'blue', description: 'Overview & Statistics' },
|
||||
@@ -232,7 +230,20 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
|
||||
Welcome, <span className="text-white font-semibold">Dennis</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.location.href = '/api/auth/logout'}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
sessionStorage.removeItem('admin_authenticated');
|
||||
sessionStorage.removeItem('admin_session_token');
|
||||
window.location.href = '/manage';
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
// Force logout anyway
|
||||
sessionStorage.removeItem('admin_authenticated');
|
||||
sessionStorage.removeItem('admin_session_token');
|
||||
window.location.href = '/manage';
|
||||
}
|
||||
}}
|
||||
className="flex items-center space-x-2 px-3 py-2 rounded-lg admin-glass-light hover:bg-red-500/20 text-red-300 hover:text-red-200 transition-all duration-200"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
|
||||
113
docker-compose.production.yml
Normal file
113
docker-compose.production.yml
Normal file
@@ -0,0 +1,113 @@
|
||||
# Production Docker Compose configuration for dk0.dev
|
||||
# Optimized for production deployment
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
portfolio:
|
||||
image: portfolio-app:latest
|
||||
container_name: portfolio-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=postgresql://portfolio_user:portfolio_pass@postgres:5432/portfolio_db?schema=public
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- NEXT_PUBLIC_BASE_URL=https://dk0.dev
|
||||
- MY_EMAIL=${MY_EMAIL:-contact@dk0.dev}
|
||||
- MY_INFO_EMAIL=${MY_INFO_EMAIL:-info@dk0.dev}
|
||||
- MY_PASSWORD=${MY_PASSWORD}
|
||||
- MY_INFO_PASSWORD=${MY_INFO_PASSWORD}
|
||||
- ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH:-admin:your_secure_password_here}
|
||||
- LOG_LEVEL=info
|
||||
volumes:
|
||||
- portfolio_data:/app/.next/cache
|
||||
networks:
|
||||
- portfolio_net
|
||||
- proxy
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
cpus: '1.0'
|
||||
reservations:
|
||||
memory: 512M
|
||||
cpus: '0.5'
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: portfolio-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_DB=portfolio_db
|
||||
- POSTGRES_USER=portfolio_user
|
||||
- POSTGRES_PASSWORD=portfolio_pass
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
|
||||
networks:
|
||||
- portfolio_net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U portfolio_user -d portfolio_db"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: '0.5'
|
||||
reservations:
|
||||
memory: 256M
|
||||
cpus: '0.25'
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: portfolio-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- portfolio_net
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
cpus: '0.25'
|
||||
reservations:
|
||||
memory: 128M
|
||||
cpus: '0.1'
|
||||
|
||||
volumes:
|
||||
portfolio_data:
|
||||
driver: local
|
||||
postgres_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
portfolio_net:
|
||||
driver: bridge
|
||||
proxy:
|
||||
external: true
|
||||
68
lib/auth.ts
68
lib/auth.ts
@@ -31,8 +31,59 @@ export function requireAdminAuth(request: NextRequest): Response | null {
|
||||
{
|
||||
status: 401,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'WWW-Authenticate': 'Basic realm="Admin Access"'
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Session-based authentication (no browser popup)
|
||||
export function verifySessionAuth(request: NextRequest): boolean {
|
||||
// Check for session token in headers
|
||||
const sessionToken = request.headers.get('x-session-token');
|
||||
if (!sessionToken) return false;
|
||||
|
||||
try {
|
||||
// Decode and validate session token
|
||||
const decodedJson = atob(sessionToken);
|
||||
const sessionData = JSON.parse(decodedJson);
|
||||
|
||||
// Validate session data structure
|
||||
if (!sessionData.timestamp || !sessionData.random || !sessionData.ip || !sessionData.userAgent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if session is still valid (2 hours)
|
||||
const sessionTime = sessionData.timestamp;
|
||||
const now = Date.now();
|
||||
const sessionDuration = 2 * 60 * 60 * 1000; // 2 hours
|
||||
|
||||
if (now - sessionTime > sessionDuration) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate IP address (optional, but good security practice)
|
||||
const currentIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
||||
if (sessionData.ip !== currentIp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function requireSessionAuth(request: NextRequest): Response | null {
|
||||
if (!verifySessionAuth(request)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Session expired or invalid' }),
|
||||
{
|
||||
status: 401,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -43,6 +94,19 @@ export function requireAdminAuth(request: NextRequest): Response | null {
|
||||
// Rate limiting for admin endpoints
|
||||
const rateLimitMap = new Map<string, { count: number; resetTime: number }>();
|
||||
|
||||
// Clear rate limit cache on startup
|
||||
if (typeof window === 'undefined') {
|
||||
// Server-side: clear cache periodically
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of rateLimitMap.entries()) {
|
||||
if (now > value.resetTime) {
|
||||
rateLimitMap.delete(key);
|
||||
}
|
||||
}
|
||||
}, 60000); // Clear every minute
|
||||
}
|
||||
|
||||
export function checkRateLimit(ip: string, maxRequests: number = 10, windowMs: number = 60000): boolean {
|
||||
const now = Date.now();
|
||||
const key = `admin_${ip}`;
|
||||
|
||||
@@ -2,11 +2,10 @@ import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
// For /manage and /editor routes, let them handle their own session-based auth
|
||||
// These routes will redirect to login if not authenticated
|
||||
// For /manage and /editor routes, allow direct access (authentication disabled)
|
||||
if (request.nextUrl.pathname.startsWith('/manage') ||
|
||||
request.nextUrl.pathname.startsWith('/editor')) {
|
||||
// Let the page handle authentication via session tokens
|
||||
// Allow direct access without authentication
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ http {
|
||||
# HTTPS Server
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name dki.one www.dki.one;
|
||||
server_name dk0.dev www.dk0.dev;
|
||||
|
||||
# SSL Configuration
|
||||
ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
|
||||
163
nginx.production.conf
Normal file
163
nginx.production.conf
Normal file
@@ -0,0 +1,163 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logging
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
|
||||
# Basic Settings
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
client_max_body_size 16M;
|
||||
|
||||
# Gzip Settings
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/json
|
||||
application/javascript
|
||||
application/xml+rss
|
||||
application/atom+xml
|
||||
image/svg+xml;
|
||||
|
||||
# Rate Limiting
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=20r/s;
|
||||
limit_req_zone $binary_remote_addr zone=login:10m rate=10r/m;
|
||||
limit_req_zone $binary_remote_addr zone=admin:10m rate=5r/m;
|
||||
|
||||
# Cache Settings
|
||||
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=portfolio_cache:10m max_size=1g inactive=60m use_temp_path=off;
|
||||
|
||||
# Upstream for load balancing
|
||||
upstream portfolio_backend {
|
||||
least_conn;
|
||||
server portfolio:3000 max_fails=3 fail_timeout=30s;
|
||||
}
|
||||
|
||||
# HTTP Server (redirect to HTTPS)
|
||||
server {
|
||||
listen 80;
|
||||
server_name dk0.dev www.dk0.dev;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# HTTPS Server
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name dk0.dev www.dk0.dev;
|
||||
|
||||
# SSL Configuration
|
||||
ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# Security Headers
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://analytics.dk0.dev; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://analytics.dk0.dev;";
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header X-Cache-Status "STATIC";
|
||||
}
|
||||
|
||||
# Admin routes with strict rate limiting
|
||||
location /manage {
|
||||
limit_req zone=admin burst=5 nodelay;
|
||||
|
||||
# Block common attack patterns
|
||||
if ($http_user_agent ~* (bot|crawler|spider|scraper)) {
|
||||
return 403;
|
||||
}
|
||||
|
||||
# Add extra security headers for admin
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
|
||||
proxy_pass http://portfolio_backend;
|
||||
proxy_set_header Host $host;
|
||||
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;
|
||||
|
||||
# No caching for admin routes
|
||||
proxy_cache_bypass 1;
|
||||
proxy_no_cache 1;
|
||||
}
|
||||
|
||||
# API routes with rate limiting
|
||||
location /api/ {
|
||||
limit_req zone=api burst=30 nodelay;
|
||||
proxy_pass http://portfolio_backend;
|
||||
proxy_set_header Host $host;
|
||||
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;
|
||||
proxy_cache_bypass $http_pragma $http_authorization;
|
||||
proxy_cache_revalidate on;
|
||||
proxy_cache_min_uses 1;
|
||||
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
|
||||
proxy_cache_lock on;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /api/health {
|
||||
proxy_pass http://portfolio_backend;
|
||||
proxy_set_header Host $host;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# Main application
|
||||
location / {
|
||||
proxy_pass http://portfolio_backend;
|
||||
proxy_set_header Host $host;
|
||||
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;
|
||||
|
||||
# Enable caching for static pages
|
||||
proxy_cache portfolio_cache;
|
||||
proxy_cache_valid 200 302 10m;
|
||||
proxy_cache_valid 404 1m;
|
||||
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
|
||||
proxy_cache_lock on;
|
||||
|
||||
# Add cache status header
|
||||
add_header X-Cache-Status $upstream_cache_status;
|
||||
}
|
||||
|
||||
# Error pages
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
}
|
||||
149
scripts/production-deploy.sh
Executable file
149
scripts/production-deploy.sh
Executable file
@@ -0,0 +1,149 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Production Deployment Script for dk0.dev
|
||||
# This script sets up the production environment and deploys the application
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Logging functions
|
||||
log() {
|
||||
echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1" >&2
|
||||
}
|
||||
|
||||
success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
# Check if running as root
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
error "This script should not be run as root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if Docker is running
|
||||
if ! docker info > /dev/null 2>&1; then
|
||||
error "Docker is not running. Please start Docker and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Starting production deployment for dk0.dev..."
|
||||
|
||||
# Create production environment file if it doesn't exist
|
||||
if [ ! -f .env ]; then
|
||||
log "Creating production environment file..."
|
||||
cat > .env << EOF
|
||||
# Production Environment Configuration for dk0.dev
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=https://dk0.dev
|
||||
MY_EMAIL=contact@dk0.dev
|
||||
MY_INFO_EMAIL=info@dk0.dev
|
||||
MY_PASSWORD=your-email-password
|
||||
MY_INFO_PASSWORD=your-info-email-password
|
||||
ADMIN_BASIC_AUTH=admin:your_secure_password_here
|
||||
LOG_LEVEL=info
|
||||
PORT=3000
|
||||
EOF
|
||||
warning "Created .env file with default values. Please update with your actual credentials!"
|
||||
fi
|
||||
|
||||
# Create proxy network if it doesn't exist
|
||||
log "Creating proxy network..."
|
||||
docker network create proxy 2>/dev/null || {
|
||||
log "Proxy network already exists"
|
||||
}
|
||||
|
||||
# Build the application
|
||||
log "Building production image..."
|
||||
docker build -t portfolio-app:latest . || {
|
||||
error "Failed to build image"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Stop existing containers
|
||||
log "Stopping existing containers..."
|
||||
docker-compose down 2>/dev/null || {
|
||||
log "No existing containers to stop"
|
||||
}
|
||||
|
||||
# Start the application
|
||||
log "Starting production containers..."
|
||||
docker-compose up -d || {
|
||||
error "Failed to start containers"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Wait for services to be healthy
|
||||
log "Waiting for services to be healthy..."
|
||||
HEALTH_CHECK_TIMEOUT=120
|
||||
HEALTH_CHECK_INTERVAL=5
|
||||
ELAPSED=0
|
||||
|
||||
while [ $ELAPSED -lt $HEALTH_CHECK_TIMEOUT ]; do
|
||||
if curl -f http://localhost:3000/api/health > /dev/null 2>&1; then
|
||||
success "Application is healthy!"
|
||||
break
|
||||
fi
|
||||
|
||||
sleep $HEALTH_CHECK_INTERVAL
|
||||
ELAPSED=$((ELAPSED + HEALTH_CHECK_INTERVAL))
|
||||
echo -n "."
|
||||
done
|
||||
|
||||
if [ $ELAPSED -ge $HEALTH_CHECK_TIMEOUT ]; then
|
||||
error "Health check timeout. Application may not be running properly."
|
||||
log "Container logs:"
|
||||
docker-compose logs --tail=50
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run database migrations
|
||||
log "Running database migrations..."
|
||||
docker exec portfolio-app npx prisma db push || {
|
||||
warning "Database migration failed, but continuing..."
|
||||
}
|
||||
|
||||
# Verify deployment
|
||||
log "Verifying deployment..."
|
||||
if curl -f http://localhost:3000/api/health > /dev/null 2>&1; then
|
||||
success "Production deployment successful!"
|
||||
|
||||
# Show container status
|
||||
log "Container status:"
|
||||
docker-compose ps
|
||||
|
||||
# Show resource usage
|
||||
log "Resource usage:"
|
||||
docker stats --no-stream --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}"
|
||||
|
||||
else
|
||||
error "Deployment verification failed!"
|
||||
log "Container logs:"
|
||||
docker-compose logs --tail=50
|
||||
exit 1
|
||||
fi
|
||||
|
||||
success "Production deployment completed successfully!"
|
||||
log "Application is available at: http://localhost:3000/"
|
||||
log "Health check endpoint: http://localhost:3000/api/health"
|
||||
log "Admin panel: http://localhost:3000/manage"
|
||||
log ""
|
||||
log "Next steps:"
|
||||
log "1. Update .env file with your actual credentials"
|
||||
log "2. Set up SSL certificates for HTTPS"
|
||||
log "3. Configure your reverse proxy (nginx/traefik) to point to localhost:3000"
|
||||
log "4. Update DNS to point dk0.dev to your server"
|
||||
Reference in New Issue
Block a user