Add rate limiting and extend JWT expiry
- Wire in Fastify rate-limit (backend) and add dependency - Update JWT expiresIn to 24h in config - Remove legacy auth middleware - Add healthcheck to deployment (deploy.sh) - Minor frontend tweaks: include average time per round in logs - Remove Dashboard link from dashboard.html
This commit is contained in:
@@ -1,234 +0,0 @@
|
||||
# Production Release Checklist
|
||||
|
||||
Use this checklist before deploying to production. Ensure all items are completed and verified.
|
||||
|
||||
## Pre-Deployment (1-2 weeks before)
|
||||
|
||||
### Code Quality & Testing
|
||||
- [ ] All tests pass locally (`npm test` or equivalent)
|
||||
- [ ] Code review completed for all changes
|
||||
- [ ] No console warnings or errors in development
|
||||
- [ ] Dependencies are up-to-date and security scanning passed
|
||||
- [ ] Linting passes (`npm run lint`)
|
||||
|
||||
### Documentation
|
||||
- [ ] README.md is updated with latest features
|
||||
- [ ] DEPLOYMENT.md is complete and accurate
|
||||
- [ ] API documentation is current
|
||||
- [ ] Configuration options are documented
|
||||
|
||||
### Security Review
|
||||
- [ ] No hardcoded secrets in codebase
|
||||
- [ ] All API endpoints validate input properly
|
||||
- [ ] Database queries use parameterized statements (no SQL injection)
|
||||
- [ ] CORS configuration is restrictive (specific domains)
|
||||
- [ ] Password hashing is using bcryptjs with 12+ rounds
|
||||
- [ ] JWT tokens have proper expiration (15 minutes)
|
||||
- [ ] Rate limiting is configured (enabled in docker-compose.prod.yml)
|
||||
|
||||
### Database
|
||||
- [ ] Database schema is final and tested
|
||||
- [ ] All migrations are tested and reversible
|
||||
- [ ] Database views are optimized
|
||||
- [ ] Indexes are in place for frequently queried columns
|
||||
- [ ] Backup and restore procedures are documented and tested
|
||||
|
||||
### Performance
|
||||
- [ ] Bundle size is reasonable (< 5MB for frontend)
|
||||
- [ ] Database queries are optimized
|
||||
- [ ] API response times are acceptable (< 500ms for 95th percentile)
|
||||
- [ ] Static assets have caching enabled
|
||||
- [ ] Gzip compression is enabled
|
||||
|
||||
## Deployment Week
|
||||
|
||||
### Image Build & Push
|
||||
- [ ] Version number is incremented (semantic versioning)
|
||||
- [ ] Git tag is created: `git tag v1.0.0`
|
||||
- [ ] Deployment script runs successfully: `./deploy.sh 1.0.0`
|
||||
- [ ] Images are pushed to GitHub Container Registry
|
||||
- [ ] Image sizes are reasonable (backend < 200MB, frontend < 100MB)
|
||||
- [ ] Image scanning shows no critical vulnerabilities
|
||||
|
||||
### Server Preparation
|
||||
- [ ] Server has Docker and Docker Compose installed
|
||||
- [ ] Firewall rules allow necessary ports (80, 443)
|
||||
- [ ] SSL certificates are ready (if using HTTPS)
|
||||
- [ ] Domain DNS is configured and resolving
|
||||
- [ ] Disk space is sufficient (>10GB recommended)
|
||||
- [ ] Server has adequate resources (2GB RAM minimum, 1 CPU)
|
||||
|
||||
### Configuration
|
||||
- [ ] `.env` file created with all required variables
|
||||
- [ ] `CORS_ORIGIN` set to correct domain
|
||||
- [ ] `ALLOW_REGISTRATION` set appropriately (false by default)
|
||||
- [ ] JWT_SECRET is securely generated and stored
|
||||
- [ ] Log level is set to 'warn' in production
|
||||
- [ ] Database path points to persistent volume
|
||||
|
||||
### Secrets Management
|
||||
- [ ] Docker secret 'jwt_secret' is created
|
||||
- [ ] Secret file is securely stored and deleted after import
|
||||
- [ ] Docker secret command tested: `docker secret ls`
|
||||
- [ ] Backup of jwt_secret stored securely (offsite)
|
||||
|
||||
## Day Before Deployment
|
||||
|
||||
### Final Verification
|
||||
- [ ] Run latest images locally with `docker-compose up -d`
|
||||
- [ ] Test all major features work correctly
|
||||
- [ ] Check database is created and migrations run
|
||||
- [ ] Verify API endpoints respond correctly
|
||||
- [ ] Test authentication (login, registration if enabled)
|
||||
- [ ] Test game logging and statistics
|
||||
- [ ] Verify frontend loads and is responsive
|
||||
|
||||
### Backup Everything
|
||||
- [ ] Current database backed up (if migrating existing data)
|
||||
- [ ] Configuration files backed up
|
||||
- [ ] DNS settings noted and ready to switch
|
||||
- [ ] Rollback plan documented and tested
|
||||
|
||||
### Team Communication
|
||||
- [ ] Team notified of deployment schedule
|
||||
- [ ] Maintenance window communicated to users
|
||||
- [ ] Rollback contact information shared
|
||||
- [ ] Deployment plan reviewed with team
|
||||
|
||||
## Deployment Day
|
||||
|
||||
### Pre-Deployment (1 hour before)
|
||||
|
||||
- [ ] All services currently running and stable
|
||||
- [ ] Database integrity verified
|
||||
- [ ] Recent backups completed
|
||||
- [ ] Monitoring tools configured and running
|
||||
- [ ] Team members available for issues
|
||||
|
||||
### Deployment Steps
|
||||
|
||||
1. [ ] Pull latest images from GHCR
|
||||
```bash
|
||||
docker pull ghcr.io/YOUR_USER/edh-stats-backend:1.0.0
|
||||
docker pull ghcr.io/YOUR_USER/edh-stats-frontend:1.0.0
|
||||
```
|
||||
|
||||
2. [ ] Stop current services (if upgrading)
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
3. [ ] Update docker-compose.yml with new versions
|
||||
|
||||
4. [ ] Start new services
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
5. [ ] Wait for services to become healthy (check health checks)
|
||||
```bash
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
### Post-Deployment Verification (immediate)
|
||||
|
||||
- [ ] All containers are running: `docker-compose ps`
|
||||
- [ ] Backend health check passes: `curl http://localhost:3000/api/health`
|
||||
- [ ] Frontend loads: `curl http://localhost/`
|
||||
- [ ] No error logs: `docker-compose logs | grep ERROR`
|
||||
- [ ] Database is accessible and has data
|
||||
- [ ] API endpoints respond (test authentication)
|
||||
- [ ] UI loads correctly in browser
|
||||
- [ ] Forms work (try logging a game if applicable)
|
||||
|
||||
### Testing (15-30 minutes after)
|
||||
|
||||
- [ ] Test user login functionality
|
||||
- [ ] Test game logging (if enabled)
|
||||
- [ ] Test viewing statistics
|
||||
- [ ] Test editing/deleting records
|
||||
- [ ] Check browser console for JavaScript errors
|
||||
- [ ] Verify HTTPS/SSL (if configured)
|
||||
- [ ] Test on mobile device
|
||||
|
||||
### Monitoring (first 24 hours)
|
||||
|
||||
- [ ] Monitor error logs every 30 minutes
|
||||
- [ ] Monitor resource usage (CPU, memory)
|
||||
- [ ] Check database size and integrity
|
||||
- [ ] Monitor API response times
|
||||
- [ ] Review user feedback/issues reported
|
||||
- [ ] Ensure backups are being created
|
||||
|
||||
## If Issues Occur
|
||||
|
||||
### Quick Diagnostics
|
||||
```bash
|
||||
# Check service status
|
||||
docker-compose ps
|
||||
|
||||
# View recent logs
|
||||
docker-compose logs --tail 50 backend
|
||||
docker-compose logs --tail 50 frontend
|
||||
|
||||
# Check resource usage
|
||||
docker stats
|
||||
|
||||
# Test backend connectivity
|
||||
curl -v http://localhost:3000/api/health
|
||||
|
||||
# Test database
|
||||
docker-compose exec backend sqlite3 /app/database/data/edh-stats.db "SELECT COUNT(*) FROM users;"
|
||||
```
|
||||
|
||||
### Rollback Procedure
|
||||
1. [ ] Stop current version: `docker-compose down`
|
||||
2. [ ] Update docker-compose.yml to previous version
|
||||
3. [ ] Restore database backup if needed
|
||||
4. [ ] Restart with previous version: `docker-compose up -d`
|
||||
5. [ ] Verify service health
|
||||
6. [ ] Notify team and users
|
||||
|
||||
## Post-Deployment (Next 24-48 hours)
|
||||
|
||||
### Stability Monitoring
|
||||
- [ ] No error spikes in logs
|
||||
- [ ] Database size is stable
|
||||
- [ ] API response times are acceptable
|
||||
- [ ] No memory leaks or increasing CPU usage
|
||||
- [ ] User authentication working smoothly
|
||||
- [ ] No reported critical issues
|
||||
|
||||
### Documentation & Communication
|
||||
- [ ] Update version number in documentation
|
||||
- [ ] Post release notes (what was changed)
|
||||
- [ ] Thank team for their efforts
|
||||
- [ ] Respond to any user questions/feedback
|
||||
|
||||
### Metrics & Learning
|
||||
- [ ] Deployment time was within expectations
|
||||
- [ ] Zero downtime achieved (if applicable)
|
||||
- [ ] Document any unexpected issues and resolutions
|
||||
- [ ] Identify improvements for next deployment
|
||||
- [ ] Update deployment checklist based on learnings
|
||||
|
||||
## Success Criteria
|
||||
|
||||
Deployment is successful when:
|
||||
|
||||
✅ All services are running and healthy
|
||||
✅ All endpoints respond correctly
|
||||
✅ Database has all data and is accessible
|
||||
✅ Users can login and use the application
|
||||
✅ No critical errors in logs
|
||||
✅ Performance is acceptable (< 500ms response time)
|
||||
✅ SSL/HTTPS working (if configured)
|
||||
✅ Backups are being created regularly
|
||||
✅ Team is confident in stability
|
||||
✅ Users are satisfied with functionality
|
||||
|
||||
---
|
||||
|
||||
**Version**: 1.0.0
|
||||
**Last Updated**: 2024-01-15
|
||||
**Maintained by**: [Your Team Name]
|
||||
216
QUICK_FIX.md
216
QUICK_FIX.md
@@ -1,216 +0,0 @@
|
||||
# Quick Fix for EDH Stats Deployment Issues
|
||||
|
||||
## Issue 1: Backend - "unable to open database file"
|
||||
## Issue 2: Frontend - "host not found in upstream 'backend'"
|
||||
|
||||
This guide fixes both issues immediately.
|
||||
|
||||
## Step 1: Stop Services
|
||||
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
## Step 2: Update nginx.prod.conf
|
||||
|
||||
The nginx config needs to dynamically resolve the backend hostname. On your server, edit the nginx config:
|
||||
|
||||
Replace the HTTP/HTTPS server blocks with this simpler version:
|
||||
|
||||
```nginx
|
||||
# Use resolver to dynamically resolve backend hostname
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $backend_backend "backend:3000";
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Rate limited API proxy
|
||||
location /api/ {
|
||||
limit_req zone=api burst=20 nodelay;
|
||||
proxy_pass http://$backend_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
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_upgrade;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# Rate limited static files
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
limit_req zone=static burst=50 nodelay;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Main application routes
|
||||
location / {
|
||||
limit_req zone=static burst=10 nodelay;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Error pages
|
||||
error_page 404 /index.html;
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 3: Update docker-compose.yml - Frontend Ports
|
||||
|
||||
If other containers are using ports 80 and 443, update the frontend service to use alternate ports:
|
||||
|
||||
```yaml
|
||||
frontend:
|
||||
image: ghcr.io/zlorfi/edh-stats-frontend:v1.0.5
|
||||
ports:
|
||||
- '38080:80' # Changed from 80:80
|
||||
- '30443:443' # Changed from 443:443
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- edh-stats-network
|
||||
```
|
||||
|
||||
Then access the app at:
|
||||
- HTTP: `http://your-server:38080`
|
||||
- HTTPS: `https://your-server:30443` (when SSL configured)
|
||||
|
||||
## Step 4: Update docker-compose.yml - Init Service
|
||||
|
||||
Update the `init-db` service to use more permissive permissions:
|
||||
|
||||
```yaml
|
||||
init-db:
|
||||
image: alpine:latest
|
||||
volumes:
|
||||
- sqlite_data:/app/database/data
|
||||
- app_logs:/app/logs
|
||||
command: sh -c "mkdir -p /app/database/data /app/logs && chmod 777 /app/database/data /app/logs && touch /app/database/data/.initialized && echo 'Database directories initialized'"
|
||||
networks:
|
||||
- edh-stats-network
|
||||
restart: "no"
|
||||
```
|
||||
|
||||
Key changes:
|
||||
- Changed `chmod 755` to `chmod 777` for full read/write permissions
|
||||
- Added `touch /app/database/data/.initialized` to create a test file
|
||||
- Added `restart: "no"` so the init service doesn't restart
|
||||
|
||||
## Step 5: Clear Existing Volumes (Fresh Start)
|
||||
|
||||
⚠️ **WARNING: This will DELETE your database if you have data!**
|
||||
|
||||
If you want to start fresh:
|
||||
|
||||
```bash
|
||||
docker volume rm edh-stats_sqlite_data edh-stats_app_logs 2>/dev/null || true
|
||||
```
|
||||
|
||||
## Step 6: Start Services
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Step 7: Monitor Startup
|
||||
|
||||
```bash
|
||||
# Watch the init-db service create directories
|
||||
docker-compose logs -f init-db
|
||||
|
||||
# Then watch the backend startup
|
||||
docker-compose logs -f backend
|
||||
|
||||
# Check if all services are running
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
## Expected Output
|
||||
|
||||
After init-db runs and exits, you should see:
|
||||
|
||||
```
|
||||
backend-1 | Database initialized successfully
|
||||
backend-1 | Server started on port 3000
|
||||
```
|
||||
|
||||
And:
|
||||
|
||||
```
|
||||
frontend-1 | ... ready for start up
|
||||
```
|
||||
|
||||
## Verify Everything Works
|
||||
|
||||
```bash
|
||||
# Check API is responding (backend runs on port 3000 internally)
|
||||
curl http://localhost:3000/api/health
|
||||
|
||||
# Check frontend is responding (now on port 38080)
|
||||
curl http://localhost:38080/
|
||||
|
||||
# All containers running?
|
||||
docker-compose ps
|
||||
# Should show: backend (running), frontend (running), init-db (exited)
|
||||
|
||||
# Access the app in your browser
|
||||
# HTTP: http://your-server-ip:38080
|
||||
# HTTPS: https://your-server-ip:30443 (when SSL is configured)
|
||||
```
|
||||
|
||||
## What Changed and Why
|
||||
|
||||
### Nginx Config Issue
|
||||
The old config tried to resolve `backend` hostname at startup time before the backend container was ready. By using `resolver` and `set $backend_backend`, nginx defers resolution until request time, when the backend is running.
|
||||
|
||||
### Database Permissions Issue
|
||||
Docker volumes sometimes have restricted permissions. By changing to `chmod 777`, the container has full permission to create and write the database file. The `touch` command creates a test file to verify permissions work.
|
||||
|
||||
### Init Service
|
||||
The `init-db` service runs BEFORE the backend and ensures:
|
||||
1. Directories exist
|
||||
2. Directories are writable
|
||||
3. Backend can then create the database successfully
|
||||
|
||||
## Still Having Issues?
|
||||
|
||||
### Check volume exists:
|
||||
```bash
|
||||
docker volume ls | grep edh-stats
|
||||
```
|
||||
|
||||
### Check volume path is writable:
|
||||
```bash
|
||||
VOLUME_PATH=$(docker volume inspect edh-stats_sqlite_data | grep -o '"Mountpoint": "[^"]*' | cut -d'"' -f4)
|
||||
echo "Volume at: $VOLUME_PATH"
|
||||
ls -la "$VOLUME_PATH"
|
||||
```
|
||||
|
||||
### Remove and recreate volumes:
|
||||
```bash
|
||||
docker-compose down
|
||||
docker volume rm edh-stats_sqlite_data edh-stats_app_logs
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Check logs for specific errors:
|
||||
```bash
|
||||
docker-compose logs --tail 100 backend
|
||||
docker-compose logs --tail 100 frontend
|
||||
```
|
||||
17
TODO.md
Normal file
17
TODO.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# ToDos/Bugs to check and adjust
|
||||
|
||||
## games.html
|
||||
|
||||
[ ] export logs to JSON via a button in the top right corner, the file should have the current date in the name i.e: edh_games_16_01_2026.json
|
||||
|
||||
## round-counter.html
|
||||
|
||||
[ ] check if every function in round-counter.js is being used
|
||||
[ ] instead of Stop Game -> Pause game and do not reset the time counter
|
||||
[ ] End Game & Log Results -> reset the counter
|
||||
[ ] Rest button -> has issues to display on mobile
|
||||
|
||||
## login.html
|
||||
|
||||
[ ] check the cookie for remembering the login credentials, how many seconds does it work?
|
||||
[ ] prolong the cookie to stay loged in, when using the Remember me checkbox
|
||||
38
backend/package-lock.json
generated
38
backend/package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.4.0",
|
||||
"@fastify/jwt": "^7.2.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^9.2.2",
|
||||
"close-with-grace": "^1.2.0",
|
||||
@@ -184,6 +185,43 @@
|
||||
"fast-deep-equal": "^3.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/rate-limit": {
|
||||
"version": "10.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz",
|
||||
"integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lukeed/ms": "^2.0.2",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"toad-cache": "^3.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/rate-limit/node_modules/fastify-plugin": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
|
||||
"integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.4.0",
|
||||
"@fastify/jwt": "^7.2.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^9.2.2",
|
||||
"close-with-grace": "^1.2.0",
|
||||
|
||||
@@ -12,7 +12,7 @@ config({ path: resolve(rootDir, '.env') })
|
||||
export const jwtConfig = {
|
||||
secret: process.env.JWT_SECRET || 'fallback-secret-for-development',
|
||||
algorithm: 'HS512',
|
||||
expiresIn: '15m',
|
||||
expiresIn: '24h',
|
||||
refreshExpiresIn: '7d',
|
||||
issuer: 'edh-stats',
|
||||
audience: 'edh-stats-users'
|
||||
@@ -23,12 +23,6 @@ export const corsConfig = {
|
||||
credentials: true
|
||||
}
|
||||
|
||||
export const rateLimitConfig = {
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX) || 100,
|
||||
timeWindow: parseInt(process.env.RATE_LIMIT_WINDOW) || 15 * 60 * 1000, // 15 minutes
|
||||
skipOnError: false
|
||||
}
|
||||
|
||||
export const serverConfig = {
|
||||
port: parseInt(process.env.PORT) || 3000,
|
||||
host: process.env.HOST || '0.0.0.0',
|
||||
@@ -37,14 +31,6 @@ export const serverConfig = {
|
||||
}
|
||||
}
|
||||
|
||||
export const securityConfig = {
|
||||
bcryptSaltRounds: 12,
|
||||
passwordMinLength: 8,
|
||||
usernameMinLength: 3,
|
||||
commanderNameMinLength: 2,
|
||||
maxNotesLength: 1000
|
||||
}
|
||||
|
||||
export const registrationConfig = {
|
||||
allowRegistration: process.env.ALLOW_REGISTRATION !== 'false'
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
// Verify JWT token
|
||||
export const verifyJWT = async (request, reply) => {
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
} catch (err) {
|
||||
reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid or expired token'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Optional JWT verification (doesn't fail if no token)
|
||||
export const optionalJWT = async (request, reply) => {
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
} catch (err) {
|
||||
// Token is optional, so we don't fail
|
||||
request.user = null
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user exists and is active
|
||||
export const validateUser = async (request, reply) => {
|
||||
try {
|
||||
const user = request.user
|
||||
if (!user) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User not authenticated'
|
||||
})
|
||||
}
|
||||
|
||||
// You could add additional user validation here
|
||||
// e.g., check if user is active, banned, etc.
|
||||
} catch (err) {
|
||||
reply.code(500).send({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Failed to validate user'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Extract user ID from token and validate resource ownership
|
||||
export const validateOwnership = (
|
||||
resourceParam = 'id',
|
||||
resourceTable = 'commanders'
|
||||
) => {
|
||||
return async (request, reply) => {
|
||||
try {
|
||||
const user = request.user
|
||||
if (!user) {
|
||||
return reply.code(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'User not authenticated'
|
||||
})
|
||||
}
|
||||
|
||||
const resourceId = request.params[resourceParam]
|
||||
if (!resourceId) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Resource ID not provided'
|
||||
})
|
||||
}
|
||||
|
||||
const db = await import('../config/database.js').then((m) => m.default)
|
||||
const database = await db.initialize()
|
||||
|
||||
// Check if user owns the resource
|
||||
const query = `SELECT user_id FROM ${resourceTable} WHERE id = ?`
|
||||
const resource = database.prepare(query).get([resourceId])
|
||||
|
||||
if (!resource) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
message: 'Resource not found'
|
||||
})
|
||||
}
|
||||
|
||||
if (resource.user_id !== user.id) {
|
||||
return reply.code(403).send({
|
||||
error: 'Forbidden',
|
||||
message: 'Access denied to this resource'
|
||||
})
|
||||
}
|
||||
|
||||
// Add resource to request object for later use
|
||||
request.resourceId = resourceId
|
||||
} catch (err) {
|
||||
reply.code(500).send({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Failed to validate ownership'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiting middleware for sensitive endpoints
|
||||
export const rateLimitAuth = {
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 5, // 5 requests
|
||||
timeWindow: '1 minute', // per minute
|
||||
skipOnError: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiting for general API endpoints
|
||||
export const rateLimitGeneral = {
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 100, // 100 requests
|
||||
timeWindow: '1 minute', // per minute
|
||||
skipOnError: false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import fastify from 'fastify'
|
||||
import rateLimit from '@fastify/rate-limit'
|
||||
import cors from '@fastify/cors'
|
||||
import jwt from '@fastify/jwt'
|
||||
import closeWithGrace from 'close-with-grace'
|
||||
@@ -23,6 +24,10 @@ export default async function build(opts = {}) {
|
||||
secret: jwtConfig.secret
|
||||
})
|
||||
|
||||
await app.register(rateLimit, {
|
||||
global: false
|
||||
})
|
||||
|
||||
// Authentication decorator
|
||||
app.decorate('authenticate', async (request, reply) => {
|
||||
try {
|
||||
|
||||
@@ -321,6 +321,14 @@ services:
|
||||
- '38080:80'
|
||||
- '30443:443'
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- wget
|
||||
- http://localhost:80/health
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- edh-stats-network
|
||||
|
||||
|
||||
@@ -87,4 +87,4 @@ http {
|
||||
internal;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,11 +20,6 @@
|
||||
<div class="flex items-center space-x-4">
|
||||
<h1 class="text-2xl font-bold font-mtg">EDH Stats</h1>
|
||||
<div class="hidden md:flex space-x-6">
|
||||
<a
|
||||
href="/dashboard.html"
|
||||
class="text-white hover:text-edh-accent transition-colors"
|
||||
>Dashboard</a
|
||||
>
|
||||
<a
|
||||
href="/commanders.html"
|
||||
class="text-white hover:text-edh-accent transition-colors"
|
||||
@@ -318,12 +313,7 @@
|
||||
x-show="topCommanders.length === 0"
|
||||
class="text-center py-8 text-gray-500"
|
||||
>
|
||||
<p>No commanders added yet.</p>
|
||||
<a
|
||||
href="/commanders.html"
|
||||
class="text-edh-accent hover:text-edh-primary"
|
||||
>Add your first commander</a
|
||||
>
|
||||
<p>No stats yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,8 @@ function gameManager() {
|
||||
data.date || new Date().toISOString().split('T')[0]
|
||||
this.newGame.rounds = data.rounds || 8
|
||||
this.newGame.notes =
|
||||
`Ended after ${data.rounds} rounds in ${data.duration}` || ''
|
||||
`Ended after ${data.rounds} rounds in ${data.duration}.\nAverage time/round: ${data.avgTimePerRound}` ||
|
||||
''
|
||||
|
||||
// Show the form automatically
|
||||
this.showLogForm = true
|
||||
@@ -166,29 +167,29 @@ function gameManager() {
|
||||
}
|
||||
},
|
||||
|
||||
async handleCreateGame() {
|
||||
this.submitting = true
|
||||
async handleCreateGame() {
|
||||
this.submitting = true
|
||||
|
||||
try {
|
||||
// Ensure boolean values are actual booleans, not strings
|
||||
const payload = {
|
||||
date: this.newGame.date,
|
||||
commanderId: parseInt(this.newGame.commanderId),
|
||||
playerCount: parseInt(this.newGame.playerCount),
|
||||
rounds: parseInt(this.newGame.rounds),
|
||||
won: this.newGame.won === true || this.newGame.won === 'true',
|
||||
startingPlayerWon:
|
||||
this.newGame.startingPlayerWon === true ||
|
||||
this.newGame.startingPlayerWon === 'true',
|
||||
solRingTurnOneWon:
|
||||
this.newGame.solRingTurnOneWon === true ||
|
||||
this.newGame.solRingTurnOneWon === 'true'
|
||||
}
|
||||
|
||||
// Only include notes if it's not empty
|
||||
if (this.newGame.notes && this.newGame.notes.trim()) {
|
||||
payload.notes = this.newGame.notes
|
||||
}
|
||||
try {
|
||||
// Ensure boolean values are actual booleans, not strings
|
||||
const payload = {
|
||||
date: this.newGame.date,
|
||||
commanderId: parseInt(this.newGame.commanderId),
|
||||
playerCount: parseInt(this.newGame.playerCount),
|
||||
rounds: parseInt(this.newGame.rounds),
|
||||
won: this.newGame.won === true || this.newGame.won === 'true',
|
||||
startingPlayerWon:
|
||||
this.newGame.startingPlayerWon === true ||
|
||||
this.newGame.startingPlayerWon === 'true',
|
||||
solRingTurnOneWon:
|
||||
this.newGame.solRingTurnOneWon === true ||
|
||||
this.newGame.solRingTurnOneWon === 'true'
|
||||
}
|
||||
|
||||
// Only include notes if it's not empty
|
||||
if (this.newGame.notes && this.newGame.notes.trim()) {
|
||||
payload.notes = this.newGame.notes
|
||||
}
|
||||
|
||||
const response = await fetch('/api/games', {
|
||||
method: 'POST',
|
||||
@@ -217,28 +218,28 @@ function gameManager() {
|
||||
}
|
||||
},
|
||||
|
||||
async handleUpdateGame() {
|
||||
this.editSubmitting = true
|
||||
async handleUpdateGame() {
|
||||
this.editSubmitting = true
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
date: this.editingGame.date,
|
||||
commanderId: parseInt(this.editingGame.commanderId),
|
||||
playerCount: parseInt(this.editingGame.playerCount),
|
||||
rounds: parseInt(this.editingGame.rounds),
|
||||
won: this.editingGame.won === true || this.editingGame.won === 'true',
|
||||
startingPlayerWon:
|
||||
this.editingGame.startingPlayerWon === true ||
|
||||
this.editingGame.startingPlayerWon === 'true',
|
||||
solRingTurnOneWon:
|
||||
this.editingGame.solRingTurnOneWon === true ||
|
||||
this.editingGame.solRingTurnOneWon === 'true'
|
||||
}
|
||||
|
||||
// Only include notes if it's not empty
|
||||
if (this.editingGame.notes && this.editingGame.notes.trim()) {
|
||||
payload.notes = this.editingGame.notes
|
||||
}
|
||||
try {
|
||||
const payload = {
|
||||
date: this.editingGame.date,
|
||||
commanderId: parseInt(this.editingGame.commanderId),
|
||||
playerCount: parseInt(this.editingGame.playerCount),
|
||||
rounds: parseInt(this.editingGame.rounds),
|
||||
won: this.editingGame.won === true || this.editingGame.won === 'true',
|
||||
startingPlayerWon:
|
||||
this.editingGame.startingPlayerWon === true ||
|
||||
this.editingGame.startingPlayerWon === 'true',
|
||||
solRingTurnOneWon:
|
||||
this.editingGame.solRingTurnOneWon === true ||
|
||||
this.editingGame.solRingTurnOneWon === 'true'
|
||||
}
|
||||
|
||||
// Only include notes if it's not empty
|
||||
if (this.editingGame.notes && this.editingGame.notes.trim()) {
|
||||
payload.notes = this.editingGame.notes
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/games/${this.editingGame.id}`, {
|
||||
method: 'PUT',
|
||||
|
||||
@@ -51,7 +51,7 @@ function roundCounterApp() {
|
||||
|
||||
confirmReset() {
|
||||
this.resetConfirm.resetting = true
|
||||
|
||||
|
||||
// Small delay to simulate work and show loading state
|
||||
setTimeout(() => {
|
||||
this.counterActive = false
|
||||
@@ -78,11 +78,6 @@ function roundCounterApp() {
|
||||
}
|
||||
},
|
||||
|
||||
jumpToRound(round) {
|
||||
this.currentRound = round
|
||||
this.saveCounter()
|
||||
},
|
||||
|
||||
startTimer() {
|
||||
this.timerInterval = setInterval(() => {
|
||||
if (this.counterActive && this.startTime) {
|
||||
@@ -159,18 +154,6 @@ function roundCounterApp() {
|
||||
}
|
||||
},
|
||||
|
||||
toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen().catch((err) => {
|
||||
console.log(`Error attempting to enable fullscreen: ${err.message}`)
|
||||
})
|
||||
} else {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
saveAndGoToGameLog() {
|
||||
// Save the complete game data to localStorage for the game log page
|
||||
const now = new Date()
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
content="Live round counter for Magic: The Gathering EDH/Commander games"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body class="h-full flex flex-col" x-data="roundCounterApp()">
|
||||
@@ -19,28 +19,6 @@
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center space-x-4">
|
||||
<h1 class="text-2xl font-bold font-mtg">EDH Stats</h1>
|
||||
<div class="hidden md:flex space-x-6">
|
||||
<a
|
||||
href="/dashboard.html"
|
||||
class="text-white hover:text-edh-accent transition-colors"
|
||||
>Dashboard</a
|
||||
>
|
||||
<a
|
||||
href="/commanders.html"
|
||||
class="text-white hover:text-edh-accent transition-colors"
|
||||
>Commanders</a
|
||||
>
|
||||
<a
|
||||
href="/games.html"
|
||||
class="text-white hover:text-edh-accent transition-colors"
|
||||
>Log Game</a
|
||||
>
|
||||
<a
|
||||
href="/stats.html"
|
||||
class="text-white hover:text-edh-accent transition-colors"
|
||||
>Statistics</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
@@ -141,37 +119,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="card mb-8">
|
||||
<h3 class="text-xl font-semibold mb-4">Quick Actions</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<button
|
||||
@click="jumpToRound(5)"
|
||||
class="p-3 bg-gray-100 hover:bg-gray-200 rounded-lg text-center font-semibold transition-colors"
|
||||
>
|
||||
Jump to Round 5
|
||||
</button>
|
||||
<button
|
||||
@click="jumpToRound(7)"
|
||||
class="p-3 bg-gray-100 hover:bg-gray-200 rounded-lg text-center font-semibold transition-colors"
|
||||
>
|
||||
Jump to Round 7
|
||||
</button>
|
||||
<button
|
||||
@click="jumpToRound(10)"
|
||||
class="p-3 bg-gray-100 hover:bg-gray-200 rounded-lg text-center font-semibold transition-colors"
|
||||
>
|
||||
Jump to Round 10
|
||||
</button>
|
||||
<button
|
||||
@click="toggleFullscreen()"
|
||||
class="p-3 bg-gray-100 hover:bg-gray-200 rounded-lg text-center font-semibold transition-colors"
|
||||
>
|
||||
Fullscreen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Game Stats Card -->
|
||||
<div class="card">
|
||||
<h3 class="text-xl font-semibold mb-4">Game Statistics</h3>
|
||||
@@ -209,49 +156,53 @@
|
||||
End Game & Log Results
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Reset Confirmation Modal -->
|
||||
<div
|
||||
x-show="resetConfirm.show"
|
||||
x-cloak
|
||||
x-transition
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
@click.self="resetConfirm.show = false"
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-lg max-w-sm w-full">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Reset Counter</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Are you sure you want to reset the counter? This will lose all progress.
|
||||
</p>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button
|
||||
@click="resetConfirm.show = false"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="confirmReset()"
|
||||
:disabled="resetConfirm.resetting"
|
||||
class="btn btn-primary bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
<span x-show="!resetConfirm.resetting">Reset Counter</span>
|
||||
<span x-show="resetConfirm.resetting">Resetting...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Reset Confirmation Modal -->
|
||||
<div
|
||||
x-show="resetConfirm.show"
|
||||
x-cloak
|
||||
x-transition
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
@click.self="resetConfirm.show = false"
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-lg max-w-sm w-full">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">
|
||||
Reset Counter
|
||||
</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Are you sure you want to reset the counter? This will lose all
|
||||
progress.
|
||||
</p>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button
|
||||
@click="resetConfirm.show = false"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="confirmReset()"
|
||||
:disabled="resetConfirm.resetting"
|
||||
class="btn btn-primary bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
<span x-show="!resetConfirm.resetting">Reset Counter</span>
|
||||
<span x-show="resetConfirm.resetting">Resetting...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts --> <script
|
||||
defer
|
||||
src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"
|
||||
></script>
|
||||
<script src="/js/auth-guard.js"></script>
|
||||
<script src="/js/round-counter.js"></script>
|
||||
<script src="/js/footer-loader.js"></script>
|
||||
<!-- Scripts -->
|
||||
<script
|
||||
defer
|
||||
src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"
|
||||
></script>
|
||||
<script src="/js/auth-guard.js"></script>
|
||||
<script src="/js/round-counter.js"></script>
|
||||
<script src="/js/footer-loader.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user