Compare commits
11 Commits
3a55b95598
...
ce16549821
| Author | SHA1 | Date | |
|---|---|---|---|
| ce16549821 | |||
| f812921ff5 | |||
| db431553b9 | |||
| 0bfb8b2074 | |||
| da9d08c149 | |||
| 744488fb5b | |||
| a9428fee94 | |||
| ef4ab9e800 | |||
| fbed816204 | |||
| 685b43fbb7 | |||
| 44ed137551 |
64
.env
64
.env
@@ -1,46 +1,30 @@
|
||||
# Docker Environment Configuration für Rezepte Klaus
|
||||
# Development Environment - Linux Server
|
||||
# Generated on Mon Sep 22 05:45:27 PM UTC 2025
|
||||
|
||||
# Database Configuration
|
||||
DB_HOST=mysql
|
||||
DB_PORT=3306
|
||||
DB_USER=recipes_user
|
||||
DB_PASSWORD=recipes_password_2024
|
||||
DB_NAME=rezepte_klaus
|
||||
DB_ROOT_PASSWORD=root_password_2024
|
||||
# Server Configuration
|
||||
HOST_IP=192.168.178.94
|
||||
DEVELOPMENT_MODE=true
|
||||
|
||||
# Database URL für Prisma
|
||||
DATABASE_URL=mysql://recipes_user:recipes_password_2024@mysql:3306/rezepte_klaus
|
||||
# Database Configuration (local development DB)
|
||||
MYSQL_PASSWORD=dev_password_123
|
||||
MYSQL_ROOT_PASSWORD=dev_root_password_123
|
||||
|
||||
# Backend Configuration
|
||||
BACKEND_PORT=3001
|
||||
NODE_ENV=production
|
||||
# CORS Configuration for remote access
|
||||
CORS_ORIGIN=http://esprimo:3000,http://localhost:3000,http://localhost:5173
|
||||
|
||||
# Upload Configuration
|
||||
UPLOAD_DIR=/app/uploads
|
||||
MAX_FILE_SIZE=10485760
|
||||
ALLOWED_EXTENSIONS=jpg,jpeg,png,webp
|
||||
# Development URLs:
|
||||
# - Frontend: http://192.168.178.94:3000
|
||||
# - Backend API: http://192.168.178.94:3001/api
|
||||
# - phpMyAdmin: http://192.168.178.94:8080
|
||||
# - MySQL: 192.168.178.94:3307
|
||||
|
||||
# Frontend Configuration
|
||||
FRONTEND_PORT=80
|
||||
VITE_API_URL=http://localhost:3001
|
||||
# Registry Configuration (for image pulls)
|
||||
DOCKER_REGISTRY=docker.citysensor.de
|
||||
# DOCKER_USERNAME=your_username
|
||||
# DOCKER_PASSWORD=your_password
|
||||
|
||||
# phpMyAdmin Configuration (optional)
|
||||
PMA_HOST=mysql
|
||||
PMA_PORT=3306
|
||||
PHPMYADMIN_PORT=8080
|
||||
|
||||
# Legacy PHP Configuration (optional)
|
||||
LEGACY_PHP_PORT=8090
|
||||
LEGACY_MYSQL_HOST=mysql
|
||||
LEGACY_MYSQL_DATABASE=rezepte_klaus
|
||||
LEGACY_MYSQL_USER=recipes_user
|
||||
LEGACY_MYSQL_PASSWORD=recipes_password_2024
|
||||
|
||||
# Security
|
||||
JWT_SECRET=your_jwt_secret_here_change_in_production
|
||||
CORS_ORIGIN=*
|
||||
|
||||
# Volume Paths
|
||||
MYSQL_DATA_PATH=./docker-data/mysql
|
||||
UPLOADS_PATH=./docker-data/uploads
|
||||
LEGACY_UPLOADS_PATH=./upload
|
||||
# Development Notes:
|
||||
# - Use this for testing Linux-specific behavior
|
||||
# - Access from Mac via: http://192.168.178.94:3000
|
||||
# - SSH tunnel for secure access: ssh -L 3000:192.168.178.94:3000 user@server
|
||||
ALLOW_INSECURE_CORS=0
|
||||
|
||||
@@ -19,5 +19,5 @@ DOCKER_NAMESPACE=
|
||||
IMAGE_TAG=latest
|
||||
|
||||
# Generated Image Names (automatically set by build script)
|
||||
# BACKEND_IMAGE=docker.citysensor.de/rezepte-klaus-backend:latest
|
||||
# FRONTEND_IMAGE=docker.citysensor.de/rezepte-klaus-frontend:latest
|
||||
# BACKEND_IMAGE=docker.citysensor.de/rezepte-backend:latest
|
||||
# FRONTEND_IMAGE=docker.citysensor.de/rezepte-frontend:latest
|
||||
@@ -1,15 +1,15 @@
|
||||
# Docker Environment Configuration für Rezepte Klaus
|
||||
# Docker Environment Configuration für Rezepte
|
||||
|
||||
# Database Configuration
|
||||
DB_HOST=mysql
|
||||
DB_PORT=3306
|
||||
DB_USER=recipes_user
|
||||
DB_PASSWORD=recipes_password_2024
|
||||
DB_NAME=rezepte_klaus
|
||||
DB_NAME=rezepte
|
||||
DB_ROOT_PASSWORD=root_password_2024
|
||||
|
||||
# Database URL für Prisma
|
||||
DATABASE_URL=mysql://recipes_user:recipes_password_2024@mysql:3306/rezepte_klaus
|
||||
DATABASE_URL=mysql://recipes_user:recipes_password_2024@mysql:3306/rezepte
|
||||
|
||||
# Backend Configuration
|
||||
BACKEND_PORT=3001
|
||||
@@ -32,7 +32,7 @@ PHPMYADMIN_PORT=8080
|
||||
# Legacy PHP Configuration (optional)
|
||||
LEGACY_PHP_PORT=8090
|
||||
LEGACY_MYSQL_HOST=mysql
|
||||
LEGACY_MYSQL_DATABASE=rezepte_klaus
|
||||
LEGACY_MYSQL_DATABASE=rezepte
|
||||
LEGACY_MYSQL_USER=recipes_user
|
||||
LEGACY_MYSQL_PASSWORD=recipes_password_2024
|
||||
|
||||
|
||||
33
.env.example
Normal file
33
.env.example
Normal file
@@ -0,0 +1,33 @@
|
||||
# Rezepte Environment Example
|
||||
|
||||
# --- Domain & Routing ---
|
||||
DOMAIN=example.com
|
||||
ACME_EMAIL=admin@example.com
|
||||
|
||||
# --- Database ---
|
||||
MYSQL_PASSWORD=change_this_password
|
||||
MYSQL_ROOT_PASSWORD=change_this_root_password
|
||||
MYSQL_DATABASE=rezepte
|
||||
MYSQL_USER=rezepte_user
|
||||
MYSQL_PORT=3307
|
||||
|
||||
# --- Auth / Security ---
|
||||
JWT_SECRET=please_change_to_secure_32_char_min
|
||||
CORS_ORIGIN=https://rezepte.${DOMAIN}
|
||||
ALLOW_INSECURE_CORS=0
|
||||
BACKEND_PORT=3001
|
||||
FRONTEND_PORT=3000
|
||||
MAX_FILE_SIZE=5242880
|
||||
UPLOAD_PATH=/app/uploads
|
||||
|
||||
# --- Images from Registry (override if pushing to GHCR or custom registry) ---
|
||||
BACKEND_IMAGE=docker.citysensor.de/rezepte-backend:latest
|
||||
FRONTEND_IMAGE=docker.citysensor.de/rezepte-frontend:latest
|
||||
|
||||
# --- Frontend API URL (if building locally outside compose) ---
|
||||
PRODUCTION_API_URL=https://rezepte.${DOMAIN}/api
|
||||
DATABASE_URL=mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@mysql:3306/${MYSQL_DATABASE}
|
||||
VITE_API_URL=https://rezepte.${DOMAIN}/api
|
||||
|
||||
# --- Optional HOST_IP for local network override builds ---
|
||||
HOST_IP=192.168.1.100
|
||||
@@ -29,12 +29,12 @@ DOCKER_USERNAME=your_username_here
|
||||
DOCKER_PASSWORD=your_password_here
|
||||
|
||||
# Docker Registry Images (CitySensor)
|
||||
BACKEND_IMAGE=docker.citysensor.de/rezepte-klaus-backend:latest
|
||||
FRONTEND_IMAGE=docker.citysensor.de/rezepte-klaus-frontend:latest
|
||||
BACKEND_IMAGE=docker.citysensor.de/rezepte-backend:latest
|
||||
FRONTEND_IMAGE=docker.citysensor.de/rezepte-frontend:latest
|
||||
|
||||
# Setup Instructions:
|
||||
# 1. Find your Gitea MySQL container name: docker ps | grep mysql
|
||||
# 2. Find your Gitea network: docker network ls | grep gitea
|
||||
# 3. Update MYSQL_HOST with the correct container name
|
||||
# 4. Update EXTERNAL_MYSQL_NETWORK with the correct network name
|
||||
# 5. Create rezepte_klaus database and user (see setup script)
|
||||
# 5. Create rezepte database and user (see setup script)
|
||||
18
.env.production
Normal file
18
.env.production
Normal file
@@ -0,0 +1,18 @@
|
||||
# Production Environment - rezepte.fuerst-stuttgart.de
|
||||
# Generated on September 27, 2025
|
||||
|
||||
# Database Configuration
|
||||
MYSQL_PASSWORD=RezepteProd2025!Secure
|
||||
MYSQL_ROOT_PASSWORD=RezepteRootProd2025!SuperSecure
|
||||
|
||||
# CORS Origin - Production domain
|
||||
CORS_ORIGIN=https://rezepte.fuerst-stuttgart.de
|
||||
|
||||
# API Base URL for frontend
|
||||
API_BASE_URL=https://rezepte.fuerst-stuttgart.de/api
|
||||
|
||||
# JWT Secret for future authentication features
|
||||
JWT_SECRET=RezepteJwtSecret2025SuperSecure32Plus
|
||||
|
||||
# Production settings
|
||||
NODE_ENV=production
|
||||
@@ -15,4 +15,4 @@ API_BASE_URL=https://yourdomain.com/api
|
||||
# JWT_SECRET=your_super_secure_jwt_secret_minimum_32_characters_long
|
||||
|
||||
# Optional: Database URL override
|
||||
# DATABASE_URL=mysql://rezepte_user:password@mysql:3306/rezepte_klaus
|
||||
# DATABASE_URL=mysql://rezepte_user:password@mysql:3306/rezepte
|
||||
@@ -14,17 +14,17 @@ DOCKER_USERNAME=your_username_here
|
||||
DOCKER_PASSWORD=your_password_here
|
||||
|
||||
# Docker Registry Images
|
||||
BACKEND_IMAGE=docker.citysensor.de/rezepte-klaus-backend:latest
|
||||
FRONTEND_IMAGE=docker.citysensor.de/rezepte-klaus-frontend:latest
|
||||
BACKEND_IMAGE=docker.citysensor.de/rezepte-backend:latest
|
||||
FRONTEND_IMAGE=docker.citysensor.de/rezepte-frontend:latest
|
||||
|
||||
# Alternative: Docker Hub
|
||||
# BACKEND_IMAGE=your-username/rezepte-klaus-backend:latest
|
||||
# FRONTEND_IMAGE=your-username/rezepte-klaus-frontend:latest
|
||||
# BACKEND_IMAGE=your-username/rezepte-backend:latest
|
||||
# FRONTEND_IMAGE=your-username/rezepte-frontend:latest
|
||||
|
||||
# Alternative: AWS ECR
|
||||
# BACKEND_IMAGE=123456789.dkr.ecr.eu-central-1.amazonaws.com/rezepte-klaus-backend:latest
|
||||
# FRONTEND_IMAGE=123456789.dkr.ecr.eu-central-1.amazonaws.com/rezepte-klaus-frontend:latest
|
||||
# BACKEND_IMAGE=123456789.dkr.ecr.eu-central-1.amazonaws.com/rezepte-backend:latest
|
||||
# FRONTEND_IMAGE=123456789.dkr.ecr.eu-central-1.amazonaws.com/rezepte-frontend:latest
|
||||
|
||||
# Alternative: Azure Container Registry
|
||||
# BACKEND_IMAGE=yourregistry.azurecr.io/rezepte-klaus-backend:latest
|
||||
# FRONTEND_IMAGE=yourregistry.azurecr.io/rezepte-klaus-frontend:latest
|
||||
# BACKEND_IMAGE=yourregistry.azurecr.io/rezepte-backend:latest
|
||||
# FRONTEND_IMAGE=yourregistry.azurecr.io/rezepte-frontend:latest
|
||||
25
.env.traefik
Normal file
25
.env.traefik
Normal file
@@ -0,0 +1,25 @@
|
||||
# Traefik Production Environment - rezepte.fuerst-stuttgart.de
|
||||
# Generated on September 27, 2025
|
||||
|
||||
# Domain Configuration
|
||||
DOMAIN=fuerst-stuttgart.de
|
||||
|
||||
# Let's Encrypt Configuration
|
||||
ACME_EMAIL=admin@fuerst-stuttgart.de
|
||||
|
||||
# Database Configuration
|
||||
MYSQL_PASSWORD=RezepteProd2025!Secure
|
||||
MYSQL_ROOT_PASSWORD=RezepteRootProd2025!SuperSecure
|
||||
|
||||
# CORS Origin - Production domain
|
||||
CORS_ORIGIN=https://rezepte.fuerst-stuttgart.de
|
||||
|
||||
# JWT Secret
|
||||
JWT_SECRET=RezepteJwtSecret2025SuperSecure32Plus
|
||||
|
||||
# Production settings
|
||||
NODE_ENV=production
|
||||
|
||||
# Docker Images (optional - will build locally if not specified)
|
||||
# FRONTEND_IMAGE=ghcr.io/your-username/rezepte-frontend:latest
|
||||
# BACKEND_IMAGE=ghcr.io/your-username/rezepte-backend:latest
|
||||
@@ -21,8 +21,8 @@ DOCKER_USERNAME=your_username_here
|
||||
DOCKER_PASSWORD=your_password_here
|
||||
|
||||
# Docker Registry Images (CitySensor)
|
||||
BACKEND_IMAGE=docker.citysensor.de/rezepte-klaus-backend:latest
|
||||
FRONTEND_IMAGE=docker.citysensor.de/rezepte-klaus-frontend:latest
|
||||
BACKEND_IMAGE=docker.citysensor.de/rezepte-backend:latest
|
||||
FRONTEND_IMAGE=docker.citysensor.de/rezepte-frontend:latest
|
||||
|
||||
# Optional: JWT Secret (currently not used, but prepared for future authentication)
|
||||
# JWT_SECRET=your_super_secure_jwt_secret_minimum_32_characters_long
|
||||
46
.github/workflows/docker-build.yml
vendored
46
.github/workflows/docker-build.yml
vendored
@@ -9,8 +9,10 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY: ${{ vars.DOCKER_REGISTRY || 'docker.citysensor.de' }}
|
||||
IMAGE_NAME_BACKEND: rezepte-klaus-backend
|
||||
IMAGE_NAME_FRONTEND: rezepte-klaus-frontend
|
||||
IMAGE_NAME_BACKEND: rezepte-backend
|
||||
IMAGE_NAME_FRONTEND: rezepte-frontend
|
||||
DATE_TAG: ${{ github.run_id }}-${{ github.run_number }}
|
||||
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
@@ -23,6 +25,12 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js (22.12.0) for auxiliary scripts
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.12.0'
|
||||
check-latest: true
|
||||
|
||||
- name: Log in to CitySensor Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -41,6 +49,8 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha
|
||||
type=raw,value=${{ env.DATE_TAG }}
|
||||
|
||||
- name: Extract metadata for frontend
|
||||
id: meta-frontend
|
||||
@@ -53,44 +63,50 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha
|
||||
type=raw,value=${{ env.DATE_TAG }}
|
||||
|
||||
- name: Build and push backend image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./nodejs-version/backend
|
||||
context: ./backend
|
||||
push: true
|
||||
tags: ${{ steps.meta-backend.outputs.tags }}
|
||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push frontend image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./nodejs-version/frontend
|
||||
context: ./frontend
|
||||
build-args: |
|
||||
VITE_API_BASE_URL=${{ secrets.PRODUCTION_API_URL || 'https://yourdomain.com/api' }}
|
||||
VITE_API_URL=${{ secrets.PRODUCTION_API_URL || 'https://yourdomain.com/api' }}
|
||||
push: true
|
||||
tags: ${{ steps.meta-frontend.outputs.tags }}
|
||||
labels: ${{ steps.meta-frontend.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Create deployment summary
|
||||
run: |
|
||||
echo "## 🚀 Deployment Images Built" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Backend Image" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME_BACKEND }}:latest" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Backend Image Tags" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "${{ steps.meta-backend.outputs.tags }}" | tr ' ' '\n' >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Frontend Image" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME_FRONTEND }}:latest" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Frontend Image Tags" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "${{ steps.meta-frontend.outputs.tags }}" | tr ' ' '\n' >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 📋 Server Deployment" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Update your server's \`.env.production\` with:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`env" >> $GITHUB_STEP_SUMMARY
|
||||
echo "BACKEND_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME_BACKEND }}:latest" >> $GITHUB_STEP_SUMMARY
|
||||
echo "FRONTEND_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME_FRONTEND }}:latest" >> $GITHUB_STEP_SUMMARY
|
||||
echo "BACKEND_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME_BACKEND }}:@sha||latest" >> $GITHUB_STEP_SUMMARY
|
||||
echo "FRONTEND_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME_FRONTEND }}:@sha||latest" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Then run: \`./deploy-registry.sh\`" >> $GITHUB_STEP_SUMMARY
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,3 +1,3 @@
|
||||
JS/node_modules/
|
||||
nodejs-version/backend/node_modules
|
||||
nodejs-version/frontend/node_modules
|
||||
backend/node_modules
|
||||
frontend/node_modules
|
||||
|
||||
1
.tool-versions
Normal file
1
.tool-versions
Normal file
@@ -0,0 +1 @@
|
||||
node 22.12.0
|
||||
@@ -59,8 +59,8 @@ MYSQL_ROOT_PASSWORD=super_secure_root_password
|
||||
DOCKER_REGISTRY=docker.citysensor.de
|
||||
DOCKER_USERNAME=your_username
|
||||
DOCKER_PASSWORD=your_password
|
||||
BACKEND_IMAGE=docker.citysensor.de/rezepte-klaus-backend:latest
|
||||
FRONTEND_IMAGE=docker.citysensor.de/rezepte-klaus-frontend:latest
|
||||
BACKEND_IMAGE=docker.citysensor.de/rezepte-backend:latest
|
||||
FRONTEND_IMAGE=docker.citysensor.de/rezepte-frontend:latest
|
||||
```
|
||||
|
||||
## 🔧 Registry-Authentifizierung
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
## 1. Repository auf Server klonen
|
||||
```bash
|
||||
git clone <your-repository-url> /opt/rezepte-klaus
|
||||
cd /opt/rezepte-klaus
|
||||
git clone <your-repository-url> /opt/rezepte
|
||||
cd /opt/rezepte
|
||||
```
|
||||
|
||||
## 2. Produktions-Umgebung konfigurieren
|
||||
@@ -22,7 +22,7 @@ cp .env.example .env.production
|
||||
### .env.production anpassen:
|
||||
```env
|
||||
# Database
|
||||
DATABASE_URL="mysql://rezepte_user:secure_password_here@mysql:3306/rezepte_klaus"
|
||||
DATABASE_URL="mysql://rezepte_user:secure_password_here@mysql:3306/rezepte"
|
||||
|
||||
# Security
|
||||
JWT_SECRET="your-super-secure-jwt-secret-min-32-chars"
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
container_name: rezepte-mysql-prod
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_DATABASE: rezepte_klaus
|
||||
MYSQL_DATABASE: rezepte
|
||||
MYSQL_USER: rezepte_user
|
||||
MYSQL_PASSWORD: secure_password_here
|
||||
MYSQL_ROOT_PASSWORD: super_secure_root_password
|
||||
@@ -72,13 +72,13 @@ services:
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./nodejs-version/backend
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: rezepte-backend-prod
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=mysql://rezepte_user:secure_password_here@mysql:3306/rezepte_klaus
|
||||
- DATABASE_URL=mysql://rezepte_user:secure_password_here@mysql:3306/rezepte
|
||||
- JWT_SECRET=your-super-secure-jwt-secret-min-32-chars
|
||||
- CORS_ORIGIN=https://yourdomain.com
|
||||
- PORT=3001
|
||||
@@ -98,7 +98,7 @@ services:
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./nodejs-version/frontend
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- VITE_API_BASE_URL=https://yourdomain.com/api
|
||||
@@ -148,7 +148,7 @@ sudo cp /etc/letsencrypt/live/yourdomain.com/privkey.pem ./ssl/
|
||||
|
||||
### Option B: Reverse Proxy (empfohlen)
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/rezepte-klaus
|
||||
# /etc/nginx/sites-available/rezepte
|
||||
server {
|
||||
listen 80;
|
||||
server_name yourdomain.com;
|
||||
@@ -193,7 +193,7 @@ server {
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🚀 Deploying Rezepte Klaus..."
|
||||
echo "🚀 Deploying Rezepte..."
|
||||
|
||||
# Git pull latest changes
|
||||
git pull origin main
|
||||
@@ -221,12 +221,12 @@ fi
|
||||
```bash
|
||||
#!/bin/bash
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_DIR="/opt/backups/rezepte-klaus"
|
||||
BACKUP_DIR="/opt/backups/rezepte"
|
||||
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
# Database backup
|
||||
docker exec rezepte-mysql-prod mysqldump -u root -psuper_secure_root_password rezepte_klaus > $BACKUP_DIR/database_$DATE.sql
|
||||
docker exec rezepte-mysql-prod mysqldump -u root -psuper_secure_root_password rezepte > $BACKUP_DIR/database_$DATE.sql
|
||||
|
||||
# Uploads backup
|
||||
docker cp rezepte-backend-prod:/app/uploads $BACKUP_DIR/uploads_$DATE
|
||||
@@ -262,7 +262,7 @@ docker-compose -f docker-compose.production.yml ps
|
||||
### Crontab für automatische Backups:
|
||||
```bash
|
||||
# Täglich um 2 Uhr
|
||||
0 2 * * * /opt/rezepte-klaus/backup.sh
|
||||
0 2 * * * /opt/rezepte/backup.sh
|
||||
|
||||
# Wöchentlich SSL-Zertifikat erneuern
|
||||
0 3 * * 0 certbot renew --quiet && systemctl reload nginx
|
||||
@@ -290,7 +290,7 @@ docker exec -it rezepte-mysql-prod mysql -u root -p
|
||||
|
||||
# Database-Status prüfen
|
||||
SHOW DATABASES;
|
||||
USE rezepte_klaus;
|
||||
USE rezepte;
|
||||
SHOW TABLES;
|
||||
```
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin
|
||||
|
||||
# Images taggen und pushen
|
||||
docker build -t ghcr.io/YOUR_USERNAME/rezepte-klaus-backend:latest ./nodejs-version/backend
|
||||
docker build -t ghcr.io/YOUR_USERNAME/rezepte-klaus-frontend:latest ./nodejs-version/frontend
|
||||
docker build -t ghcr.io/YOUR_USERNAME/rezepte-backend:latest ./backend
|
||||
docker build -t ghcr.io/YOUR_USERNAME/rezepte-frontend:latest ./frontend
|
||||
|
||||
docker push ghcr.io/YOUR_USERNAME/rezepte-klaus-backend:latest
|
||||
docker push ghcr.io/YOUR_USERNAME/rezepte-klaus-frontend:latest
|
||||
docker push ghcr.io/YOUR_USERNAME/rezepte-backend:latest
|
||||
docker push ghcr.io/YOUR_USERNAME/rezepte-frontend:latest
|
||||
```
|
||||
|
||||
#### Docker Hub
|
||||
@@ -23,11 +23,11 @@ docker push ghcr.io/YOUR_USERNAME/rezepte-klaus-frontend:latest
|
||||
docker login
|
||||
|
||||
# Images taggen und pushen
|
||||
docker build -t YOUR_USERNAME/rezepte-klaus-backend:latest ./nodejs-version/backend
|
||||
docker build -t YOUR_USERNAME/rezepte-klaus-frontend:latest ./nodejs-version/frontend
|
||||
docker build -t YOUR_USERNAME/rezepte-backend:latest ./backend
|
||||
docker build -t YOUR_USERNAME/rezepte-frontend:latest ./frontend
|
||||
|
||||
docker push YOUR_USERNAME/rezepte-klaus-backend:latest
|
||||
docker push YOUR_USERNAME/rezepte-klaus-frontend:latest
|
||||
docker push YOUR_USERNAME/rezepte-backend:latest
|
||||
docker push YOUR_USERNAME/rezepte-frontend:latest
|
||||
```
|
||||
|
||||
#### Private Registry (AWS ECR, Azure ACR, etc.)
|
||||
@@ -36,11 +36,11 @@ docker push YOUR_USERNAME/rezepte-klaus-frontend:latest
|
||||
aws ecr get-login-password --region eu-central-1 | docker login --username AWS --password-stdin YOUR_ACCOUNT.dkr.ecr.eu-central-1.amazonaws.com
|
||||
|
||||
# Images taggen und pushen
|
||||
docker build -t YOUR_ACCOUNT.dkr.ecr.eu-central-1.amazonaws.com/rezepte-klaus-backend:latest ./nodejs-version/backend
|
||||
docker build -t YOUR_ACCOUNT.dkr.ecr.eu-central-1.amazonaws.com/rezepte-klaus-frontend:latest ./nodejs-version/frontend
|
||||
docker build -t YOUR_ACCOUNT.dkr.ecr.eu-central-1.amazonaws.com/rezepte-backend:latest ./backend
|
||||
docker build -t YOUR_ACCOUNT.dkr.ecr.eu-central-1.amazonaws.com/rezepte-frontend:latest ./frontend
|
||||
|
||||
docker push YOUR_ACCOUNT.dkr.ecr.eu-central-1.amazonaws.com/rezepte-klaus-backend:latest
|
||||
docker push YOUR_ACCOUNT.dkr.ecr.eu-central-1.amazonaws.com/rezepte-klaus-frontend:latest
|
||||
docker push YOUR_ACCOUNT.dkr.ecr.eu-central-1.amazonaws.com/rezepte-backend:latest
|
||||
docker push YOUR_ACCOUNT.dkr.ecr.eu-central-1.amazonaws.com/rezepte-frontend:latest
|
||||
```
|
||||
|
||||
### 2. Server-Deployment (nur Docker Compose)
|
||||
@@ -53,14 +53,14 @@ Auf dem Server benötigen Sie nur diese Dateien:
|
||||
|
||||
```bash
|
||||
# Minimales Setup auf Server
|
||||
mkdir -p /opt/rezepte-klaus
|
||||
cd /opt/rezepte-klaus
|
||||
mkdir -p /opt/rezepte
|
||||
cd /opt/rezepte
|
||||
|
||||
# Nur diese Dateien kopieren:
|
||||
scp docker-compose.registry.yml user@server:/opt/rezepte-klaus/
|
||||
scp .env.production user@server:/opt/rezepte-klaus/
|
||||
scp *.sql user@server:/opt/rezepte-klaus/
|
||||
scp deploy-registry.sh user@server:/opt/rezepte-klaus/
|
||||
scp docker-compose.registry.yml user@server:/opt/rezepte/
|
||||
scp .env.production user@server:/opt/rezepte/
|
||||
scp *.sql user@server:/opt/rezepte/
|
||||
scp deploy-registry.sh user@server:/opt/rezepte/
|
||||
```
|
||||
|
||||
## Option 2: CI/CD Pipeline (Automatisiert)
|
||||
@@ -90,14 +90,14 @@ jobs:
|
||||
- name: Build and push backend
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: ./nodejs-version/backend
|
||||
context: ./backend
|
||||
push: true
|
||||
tags: ghcr.io/${{ github.repository }}/backend:${{ github.sha }},ghcr.io/${{ github.repository }}/backend:latest
|
||||
|
||||
- name: Build and push frontend
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: ./nodejs-version/frontend
|
||||
context: ./frontend
|
||||
push: true
|
||||
tags: ghcr.io/${{ github.repository }}/frontend:${{ github.sha }},ghcr.io/${{ github.repository }}/frontend:latest
|
||||
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
username: ${{ secrets.USERNAME }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
script: |
|
||||
cd /opt/rezepte-klaus
|
||||
cd /opt/rezepte
|
||||
docker-compose -f docker-compose.registry.yml pull
|
||||
docker-compose -f docker-compose.registry.yml up -d
|
||||
```
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Rezepte Klaus - Docker Deployment
|
||||
# Rezepte - Docker Deployment
|
||||
|
||||
Dieses Projekt kann komplett über Docker containerisiert betrieben werden.
|
||||
|
||||
@@ -109,7 +109,7 @@ Für lokale Entwicklung:
|
||||
cp .env.development .env
|
||||
|
||||
# Backend starten
|
||||
cd nodejs-version/backend
|
||||
cd backend
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
@@ -178,7 +178,7 @@ docker-compose -f docker-compose.modern.yml down -v
|
||||
|
||||
### Database Backup
|
||||
```bash
|
||||
docker-compose -f docker-compose.modern.yml exec mysql mysqldump -u recipes_user -p rezepte_klaus > backup.sql
|
||||
docker-compose -f docker-compose.modern.yml exec mysql mysqldump -u recipes_user -p rezepte > backup.sql
|
||||
```
|
||||
|
||||
### Upload Backup
|
||||
@@ -189,7 +189,7 @@ tar -czf uploads-backup.tar.gz docker-data/uploads/
|
||||
### Restore
|
||||
```bash
|
||||
# Database
|
||||
docker-compose -f docker-compose.modern.yml exec -T mysql mysql -u recipes_user -p rezepte_klaus < backup.sql
|
||||
docker-compose -f docker-compose.modern.yml exec -T mysql mysql -u recipes_user -p rezepte < backup.sql
|
||||
|
||||
# Uploads
|
||||
tar -xzf uploads-backup.tar.gz
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Externe MySQL-Datenbank Integration - Rezepte Klaus
|
||||
# Externe MySQL-Datenbank Integration - Rezepte
|
||||
|
||||
## 🗄️ Bestehende MySQL-Datenbank nutzen (Gitea)
|
||||
|
||||
@@ -72,7 +72,7 @@ EXTERNAL_MYSQL_NETWORK=gitea_default
|
||||
1. ✅ **Container-Erkennung**: Findet Gitea MySQL-Container
|
||||
2. ✅ **Netzwerk-Validierung**: Prüft Docker-Netzwerk
|
||||
3. ✅ **Verbindungstest**: Testet MySQL-Zugriff
|
||||
4. ✅ **Datenbank-Setup**: Erstellt `rezepte_klaus` DB
|
||||
4. ✅ **Datenbank-Setup**: Erstellt `rezepte` DB
|
||||
5. ✅ **User-Erstellung**: Legt `rezepte_user` an
|
||||
6. ✅ **Daten-Import**: Importiert SQL-Dateien
|
||||
7. ✅ **Service-Start**: Startet alle Services
|
||||
@@ -97,7 +97,7 @@ EXTERNAL_MYSQL_NETWORK=gitea_default
|
||||
│ Shared MySQL │
|
||||
│ ┌─────────────────────────────┐│
|
||||
│ │ ┌──────────┐ ┌────────────┐││
|
||||
│ │ │ gitea │ │rezepte_klaus│││
|
||||
│ │ │ gitea │ │rezepte│││
|
||||
│ │ └──────────┘ └────────────┘││
|
||||
│ └─────────────────────────────┘│
|
||||
└─────────────────────────────────┘
|
||||
@@ -127,7 +127,7 @@ networks:
|
||||
```yaml
|
||||
backend:
|
||||
environment:
|
||||
- DATABASE_URL=mysql://rezepte_user:${MYSQL_REZEPTE_PASSWORD}@${MYSQL_HOST}:3306/rezepte_klaus
|
||||
- DATABASE_URL=mysql://rezepte_user:${MYSQL_REZEPTE_PASSWORD}@${MYSQL_HOST}:3306/rezepte
|
||||
networks:
|
||||
- traefik-network
|
||||
- gitea_default # Zugriff auf Gitea MySQL
|
||||
@@ -143,10 +143,10 @@ backend:
|
||||
### **Getrennte Benutzer:**
|
||||
```sql
|
||||
-- Gitea nutzt eigenen User (meist 'gitea')
|
||||
-- Rezepte Klaus bekommt eigenen User ('rezepte_user')
|
||||
-- Rezepte bekommt eigenen User ('rezepte_user')
|
||||
-- Keine gegenseitigen Zugriffe
|
||||
|
||||
GRANT ALL PRIVILEGES ON rezepte_klaus.* TO 'rezepte_user'@'%';
|
||||
GRANT ALL PRIVILEGES ON rezepte.* TO 'rezepte_user'@'%';
|
||||
-- Kein Zugriff auf 'gitea' Datenbank
|
||||
```
|
||||
|
||||
@@ -229,10 +229,10 @@ docker-compose -f docker-compose.traefik-external-db.yml logs -f backend
|
||||
docker exec -it gitea-mysql-1 mysql -uroot -p
|
||||
|
||||
# Als Rezepte-User (nur Rezepte)
|
||||
docker exec -it gitea-mysql-1 mysql -urezepte_user -p rezepte_klaus
|
||||
docker exec -it gitea-mysql-1 mysql -urezepte_user -p rezepte
|
||||
|
||||
# Backup erstellen
|
||||
docker exec gitea-mysql-1 mysqldump -uroot -p rezepte_klaus > backup.sql
|
||||
docker exec gitea-mysql-1 mysqldump -uroot -p rezepte > backup.sql
|
||||
```
|
||||
|
||||
## 🎯 Fazit
|
||||
|
||||
832
JS/package-lock.json
generated
832
JS/package-lock.json
generated
@@ -1,832 +0,0 @@
|
||||
{
|
||||
"name": "observatory-sim",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "observatory-sim",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.3",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.4.24",
|
||||
"on-finished": "2.4.1",
|
||||
"qs": "6.13.0",
|
||||
"raw-body": "2.5.2",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.21.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "1.20.3",
|
||||
"content-disposition": "0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "0.7.1",
|
||||
"cookie-signature": "1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "1.3.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "6.13.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "0.19.0",
|
||||
"serve-static": "1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
||||
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "2.0.1",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "2.0.0",
|
||||
"inherits": "2.0.4",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"toidentifier": "1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
|
||||
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.4.24",
|
||||
"unpipe": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
|
||||
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~1.0.2",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/encodeurl": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
|
||||
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.2",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
||||
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "0.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"name": "observatory-sim",
|
||||
"version": "1.0.0",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2"
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Kuppel-Simulation (JS)</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<h2>Kuppel-Simulation mit NFC-Tags (JavaScript)</h2>
|
||||
<canvas id="simCanvas" width="700" height="700"></canvas>
|
||||
<pre id="debug"></pre>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,120 +0,0 @@
|
||||
const canvas = document.getElementById("simCanvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
const WIDTH = 700;
|
||||
const HEIGHT = 700;
|
||||
const CENTER = { x: WIDTH / 2, y: HEIGHT / 2 };
|
||||
const RADIUS = 260;
|
||||
|
||||
let TAG_COUNT = 36;
|
||||
let TAG_STEP = 360 / TAG_COUNT;
|
||||
|
||||
let telescopeAngle = 0.0;
|
||||
let domeAngle = TAG_STEP / 2; // aktuelle Position
|
||||
let targetDomeAngle = domeAngle;
|
||||
let slotWidth = 20.0;
|
||||
|
||||
let domeSpeed = 1.0; // ° pro Frame
|
||||
let state = "IDLE"; // "IDLE" oder "MOVING"
|
||||
|
||||
function angleDiff(target, source) {
|
||||
let d = (target - source + 180) % 360 - 180;
|
||||
return d;
|
||||
}
|
||||
function getTagIndex(angle) {
|
||||
return Math.floor((angle % 360) / TAG_STEP);
|
||||
}
|
||||
function tagToAngle(idx) {
|
||||
return idx * TAG_STEP + TAG_STEP / 2;
|
||||
}
|
||||
|
||||
// Tastatursteuerung
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowLeft") telescopeAngle = (telescopeAngle - 2.5 + 360) % 360;
|
||||
if (e.key === "ArrowRight") telescopeAngle = (telescopeAngle + 2.5) % 360;
|
||||
});
|
||||
|
||||
function update() {
|
||||
let diff = angleDiff(telescopeAngle, domeAngle);
|
||||
let halfSlot = slotWidth / 2;
|
||||
let moveDir = "STOP";
|
||||
|
||||
if (state === "IDLE") {
|
||||
// Prüfen, ob Teleskop aus Schlitz läuft
|
||||
if (diff > halfSlot) {
|
||||
// CW-Bewegung → Kuppel muss eine Spaltbreite weiter drehen
|
||||
targetDomeAngle = (domeAngle + slotWidth) % 360;
|
||||
state = "MOVING";
|
||||
} else if (diff < -halfSlot) {
|
||||
// CCW-Bewegung → Kuppel muss eine Spaltbreite zurück
|
||||
targetDomeAngle = (domeAngle - slotWidth + 360) % 360;
|
||||
state = "MOVING";
|
||||
}
|
||||
}
|
||||
|
||||
if (state === "MOVING") {
|
||||
let delta = angleDiff(targetDomeAngle, domeAngle);
|
||||
if (Math.abs(delta) > domeSpeed) {
|
||||
domeAngle = (domeAngle + Math.sign(delta) * domeSpeed + 360) % 360;
|
||||
moveDir = Math.sign(delta) > 0 ? "+" : "-";
|
||||
} else {
|
||||
domeAngle = targetDomeAngle;
|
||||
state = "IDLE";
|
||||
}
|
||||
}
|
||||
|
||||
let activeTag = getTagIndex(domeAngle);
|
||||
draw(activeTag, diff, moveDir);
|
||||
requestAnimationFrame(update);
|
||||
}
|
||||
|
||||
function draw(activeTag, diff, moveDir) {
|
||||
ctx.clearRect(0, 0, WIDTH, HEIGHT);
|
||||
|
||||
// Tags
|
||||
for (let i = 0; i < TAG_COUNT; i++) {
|
||||
let a = (-2 * Math.PI * i) / TAG_COUNT;
|
||||
let x = CENTER.x + Math.cos(a) * RADIUS;
|
||||
let y = CENTER.y + Math.sin(a) * RADIUS;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, i === activeTag ? 6 : 3, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = i === activeTag ? "green" : "black";
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Spaltbogen
|
||||
let midAngle = -domeAngle * (Math.PI / 180);
|
||||
let half = (slotWidth / 2) * (Math.PI / 180);
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = "blue";
|
||||
ctx.lineWidth = 12;
|
||||
ctx.arc(CENTER.x, CENTER.y, RADIUS, midAngle - half, midAngle + half);
|
||||
ctx.stroke();
|
||||
|
||||
// Teleskop
|
||||
let tx = CENTER.x + Math.cos((-telescopeAngle * Math.PI) / 180) * (RADIUS - 60);
|
||||
let ty = CENTER.y + Math.sin((-telescopeAngle * Math.PI) / 180) * (RADIUS - 60);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(CENTER.x, CENTER.y);
|
||||
ctx.lineTo(tx, ty);
|
||||
ctx.strokeStyle = "red";
|
||||
ctx.lineWidth = 6;
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.arc(tx, ty, 8, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = "red";
|
||||
ctx.fill();
|
||||
|
||||
// Debug
|
||||
document.getElementById("debug").textContent =
|
||||
`Telescope: ${telescopeAngle.toFixed(1)}°\n` +
|
||||
`Dome: ${domeAngle.toFixed(1)}°\n` +
|
||||
`Target: ${targetDomeAngle.toFixed(1)}°\n` +
|
||||
`Diff: ${diff.toFixed(2)}°\n` +
|
||||
`Active Tag: ${activeTag}\n` +
|
||||
`Move: ${moveDir}\n` +
|
||||
`State: ${state}`;
|
||||
}
|
||||
|
||||
// Start
|
||||
update();
|
||||
@@ -1,9 +0,0 @@
|
||||
const express = require("express");
|
||||
const app = express();
|
||||
const port = 3000;
|
||||
|
||||
app.use(express.static("public"));
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Observatory simulation running at http://localhost:${port}`);
|
||||
});
|
||||
251
NODEJS_README.md
Normal file
251
NODEJS_README.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# Modern Recipe Management System
|
||||
|
||||
A complete modern web application built with Node.js, TypeScript, React, and MySQL - a modern alternative to the existing PHP application.
|
||||
|
||||
## 🚀 Architecture Overview
|
||||
|
||||
### Backend (Node.js + TypeScript)
|
||||
- **Express.js** REST API server
|
||||
- **Prisma ORM** for type-safe database access
|
||||
- **TypeScript** for development safety
|
||||
- **MySQL** database (shared with PHP version)
|
||||
- **Security** middleware (Helmet, CORS, rate limiting)
|
||||
|
||||
### Frontend (React + TypeScript)
|
||||
- **React 18** with functional components and hooks
|
||||
- **TypeScript** for type safety
|
||||
- **Vite** for fast development and building
|
||||
- **React Router** for client-side routing
|
||||
- **Axios** for API communication
|
||||
- **Modern CSS** with responsive design
|
||||
|
||||
### Database
|
||||
- **MySQL 8.0** (same database as PHP version)
|
||||
- **Prisma** schema mapped to existing tables
|
||||
- **Compatible** with current PHP application
|
||||
|
||||
## 🌐 Live URLs
|
||||
|
||||
- **React Frontend**: http://localhost:5173
|
||||
- **Node.js API**: http://localhost:3001
|
||||
- **PHP Application**: http://localhost:8082 (when Docker is running)
|
||||
- **phpMyAdmin**: http://localhost:8083 (when Docker is running)
|
||||
|
||||
## <20> Quick Start
|
||||
|
||||
### ⚙️ Node Version
|
||||
|
||||
Empfohlene Node Version: **22.12.0** (oder LTS 20.19.x). Projekt liefert `.nvmrc`.
|
||||
|
||||
Mit nvm aktivieren:
|
||||
```bash
|
||||
nvm install 22.12.0
|
||||
nvm use 22.12.0
|
||||
```
|
||||
Automatisch beim Wechsel ins Verzeichnis:
|
||||
```bash
|
||||
nvm use
|
||||
```
|
||||
|
||||
Falls ohne nvm: Offizielles Binary oder Paketmanager (Node >=22.12.0) installieren.
|
||||
|
||||
### 1. Start the Database (existing Docker setup)
|
||||
```bash
|
||||
# From main project directory
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 2. Start the Node.js Backend
|
||||
```bash
|
||||
cd backend
|
||||
nvm use # optional, falls nvm
|
||||
npm install
|
||||
npm run build
|
||||
node dist/app.js
|
||||
```
|
||||
|
||||
### 3. Start the React Frontend
|
||||
```bash
|
||||
cd frontend
|
||||
nvm use # optional
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 📱 Features Implemented
|
||||
|
||||
### ✅ Backend API
|
||||
- **Health Check** - Server status endpoint
|
||||
- **Recipe Management** - Full CRUD operations with search/pagination
|
||||
- **Ingredient Management** - CRUD operations for recipe ingredients
|
||||
- **Image Serving** - Static file serving for recipe images
|
||||
- **Input Validation** - Joi schema validation
|
||||
- **Error Handling** - Centralized error management
|
||||
- **Security** - CORS, Helmet, rate limiting, input sanitization
|
||||
|
||||
### ✅ Frontend Application
|
||||
- **Recipe List** - Grid view with search, filtering, pagination
|
||||
- **Responsive Design** - Mobile-first CSS design
|
||||
- **Image Display** - Recipe images with fallback handling
|
||||
- **Navigation** - Clean header with route navigation
|
||||
- **Search & Filter** - Real-time search and category filtering
|
||||
- **Error Handling** - User-friendly error messages
|
||||
- **Loading States** - Visual feedback for API calls
|
||||
|
||||
## 🎯 Current Status
|
||||
|
||||
### ✅ **COMPLETED**
|
||||
- Node.js backend with TypeScript
|
||||
- Prisma ORM with existing database schema
|
||||
- Complete REST API for recipes, ingredients, images
|
||||
- React frontend with modern UI
|
||||
- Database integration working
|
||||
- Both servers running successfully
|
||||
- API endpoints tested and working
|
||||
- Responsive design implemented
|
||||
|
||||
### 🔄 **Next Steps**
|
||||
1. **Recipe Detail View** - Individual recipe pages
|
||||
2. **Recipe Creation/Editing** - Forms for CRUD operations
|
||||
3. **Image Upload** - File upload functionality
|
||||
4. **Ingredient Management** - Dedicated ingredient pages
|
||||
5. **Docker Configuration** - Containerize Node.js stack
|
||||
6. **Performance Optimization** - Caching, lazy loading
|
||||
7. **Testing** - Unit and integration tests
|
||||
|
||||
## 📊 API Endpoints
|
||||
|
||||
### Recipes
|
||||
- `GET /api/recipes` - List recipes (pagination, search, filter)
|
||||
- `GET /api/recipes/:id` - Get single recipe
|
||||
- `lastUsed` wird nur noch gesetzt/aktualisiert wenn ein Rezept aktiv als "zubereitet" markiert wird (siehe Endpoint unten) – reines Ansehen ändert den Wert nicht mehr.
|
||||
|
||||
### Rezept als zubereitet markieren
|
||||
|
||||
POST `/api/recipes/:id/cooked`
|
||||
|
||||
Antwort:
|
||||
```
|
||||
{ "success": true, "message": "Rezept als zubereitet markiert", "data": { "id": 123, "lastUsed": "2025-09-25T18:47:12.123Z" } }
|
||||
```
|
||||
|
||||
Verwendung: Button "Zubereitet" auf der Detailseite oder manuell per API. Dadurch kann die Liste nach "Zuletzt zubereitet" sortiert werden (`?sortBy=lastUsed&sortOrder=desc`).
|
||||
|
||||
### Zuletzt zubereitete Rezepte
|
||||
|
||||
`GET /api/recipes/recent?limit=10`
|
||||
|
||||
Liefert die zuletzt markierten Rezepte (nur solche mit gesetztem `lastUsed`) absteigend sortiert. `limit` (1–50) steuert die Anzahl.
|
||||
|
||||
Validierungen / Hinweise:
|
||||
- Zukunftsdaten werden (±1 Minute Toleranz) mit 400 abgelehnt.
|
||||
- Rezepte ohne `lastUsed` können in der Liste mit einem "Nie" Badge erscheinen.
|
||||
- Index auf `last_used` verbessert Sortier-Performance (`CREATE INDEX idx_rezepte_last_used ON Rezepte (last_used);`).
|
||||
- `POST /api/recipes` - Create recipe
|
||||
- `PUT /api/recipes/:id` - Update recipe
|
||||
- `DELETE /api/recipes/:id` - Delete recipe
|
||||
|
||||
### Ingredients
|
||||
- `GET /api/ingredients` - List ingredients
|
||||
- `GET /api/ingredients/:id` - Get single ingredient
|
||||
- `POST /api/ingredients` - Create ingredient
|
||||
- `PUT /api/ingredients/:id` - Update ingredient
|
||||
- `DELETE /api/ingredients/:id` - Delete ingredient
|
||||
|
||||
### Images
|
||||
- `GET /api/images/recipe/:recipeId` - Get recipe images
|
||||
- `GET /api/images/serve/:imagePath` - Serve image file
|
||||
- `GET /api/images/:id` - Get image metadata
|
||||
- `POST /api/images/reorder/:recipeId` - Reorder all images for a recipe (body: `{ "order": [imageId1, imageId2, ...] }`) renames files sequentially `<recipeNumber>_0.*`, `<recipeNumber>_1.*`, ...
|
||||
|
||||
### Health
|
||||
- `GET /api/health` - Server health check
|
||||
|
||||
## 🔧 Development Commands
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Development
|
||||
npm run dev # Start with hot reload (if ts-node configured)
|
||||
npm run build # Build TypeScript
|
||||
npm start # Start production server
|
||||
node dist/app.js # Direct node execution
|
||||
|
||||
# Database
|
||||
npm run db:generate # Generate Prisma client
|
||||
npm run db:push # Push schema to database
|
||||
npm run db:studio # Open Prisma Studio GUI
|
||||
```
|
||||
|
||||
### Frontend
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Development
|
||||
npm run dev # Start Vite dev server
|
||||
npm run build # Build for production
|
||||
npm run preview # Preview production build
|
||||
npm run lint # Run ESLint
|
||||
```
|
||||
|
||||
## <20> Technology Benefits
|
||||
|
||||
### vs. PHP Version
|
||||
- **Type Safety** - TypeScript eliminates runtime type errors
|
||||
- **Modern Tooling** - Better developer experience with Vite, ESLint, Prettier
|
||||
- **API-First** - Clean separation enables mobile apps, integrations
|
||||
- **Maintainability** - Modern patterns, better error handling
|
||||
- **Performance** - React SPA, optimized builds, lazy loading
|
||||
- **Security** - Modern security best practices built-in
|
||||
|
||||
### Architecture Advantages
|
||||
- **Separation of Concerns** - API backend, UI frontend
|
||||
- **Scalability** - Horizontal scaling, microservices ready
|
||||
- **Testing** - Unit tests, integration tests, E2E tests possible
|
||||
- **Deployment** - Modern CI/CD, containerization, cloud deployment
|
||||
- **Extensibility** - Add mobile apps, integrations, webhooks
|
||||
|
||||
## <20> Integration Options
|
||||
|
||||
### 1. **Parallel Operation** (Current)
|
||||
- PHP app on port 8082
|
||||
- Node.js API on port 3001
|
||||
- React app on port 5173
|
||||
- Same MySQL database
|
||||
|
||||
### 2. **Gradual Migration**
|
||||
- Move features one by one from PHP to Node.js
|
||||
- Use API versioning for compatibility
|
||||
- Migrate users gradually
|
||||
|
||||
### 3. **Complete Replacement**
|
||||
- Full React frontend + Node.js backend
|
||||
- Retire PHP application
|
||||
- Modern deployment stack
|
||||
|
||||
## <20> UI/UX Features
|
||||
|
||||
- **Modern Design** - Clean, professional interface
|
||||
- **Responsive** - Mobile, tablet, desktop optimized
|
||||
- **Fast** - React SPA with instant navigation
|
||||
- **Search** - Real-time recipe search
|
||||
- **Filtering** - Category-based filtering
|
||||
- **Pagination** - Efficient large dataset handling
|
||||
- **Images** - Recipe photo display with fallbacks
|
||||
- **Navigation** - Intuitive menu structure
|
||||
- **Feedback** - Loading states, error messages
|
||||
- **Accessibility** - Semantic HTML, keyboard navigation
|
||||
|
||||
## <20> Deployment Ready
|
||||
|
||||
The application is ready for production deployment with:
|
||||
- **Environment Configuration** - .env files for different environments
|
||||
- **Build Process** - Optimized production builds
|
||||
- **Static Assets** - Vite optimization for frontend
|
||||
- **Security** - Production-ready security headers
|
||||
- **Error Handling** - Graceful error recovery
|
||||
- **Monitoring** - Health checks, logging endpoints
|
||||
|
||||
This modern stack provides a solid foundation for scaling the recipe management system with contemporary web technologies while maintaining compatibility with your existing data and workflows.
|
||||
@@ -1,4 +1,4 @@
|
||||
# phpMyAdmin Integration - Rezepte Klaus
|
||||
# phpMyAdmin Integration - Rezepte
|
||||
|
||||
## 🗄️ Datenbank-Verwaltung über Web-Interface
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Portainer Integration mit Traefik - Rezepte Klaus
|
||||
# Portainer Integration mit Traefik - Rezepte
|
||||
|
||||
## 🐳 Container-Management über Web-Interface
|
||||
|
||||
@@ -80,7 +80,7 @@ volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
```
|
||||
|
||||
## 🛠️ Portainer Features für Rezepte-Klaus
|
||||
## 🛠️ Portainer Features für Rezepte
|
||||
|
||||
### Container-Management:
|
||||
- ✅ **Stack-Verwaltung**: docker-compose.yml direkt bearbeiten
|
||||
@@ -121,7 +121,7 @@ watchtower:
|
||||
|
||||
## 🔄 Stack-Management
|
||||
|
||||
### 1. **Rezepte-Klaus als Stack**:
|
||||
### 1. **Rezepte als Stack**:
|
||||
```yaml
|
||||
# In Portainer: "Stacks" → "Add Stack"
|
||||
# Repository: Git-Integration möglich
|
||||
|
||||
127
README.md
127
README.md
@@ -1,34 +1,96 @@
|
||||
# Rezepte Klaus - Docker Setup
|
||||
# Rezepte - Docker & Modern Stack
|
||||
|
||||
Eine dockerisierte Version der Rezepte-Verwaltungsanwendung mit PHP, MySQL und phpMyAdmin.
|
||||
Dieses Repository enthält zwei Welten: den modernen Node.js / React Stack sowie die historische PHP Legacy-App. Über Docker Compose Profile kannst du gezielt nur die benötigten Teile starten.
|
||||
|
||||
## Komponenten
|
||||
## Services & Ports
|
||||
|
||||
- **PHP-App**: Rezepte-Anwendung (Port 8082)
|
||||
- **MySQL**: Datenbankserver (Port 3307)
|
||||
- **phpMyAdmin**: Datenbankadministration (Port 8083)
|
||||
| Service | Profil | Port Host -> Container | Beschreibung |
|
||||
|--------------|-----------|------------------------|--------------|
|
||||
| mysql | (auto) | 3307 -> 3306 | MySQL 8 Datenbank |
|
||||
| backend | (auto) | 3001 -> 3001 | Node.js API (Express + Prisma) |
|
||||
| frontend | (auto) | 3000 -> 80 | React Build via nginx |
|
||||
| phpmyadmin | admin | 8083 -> 80 | DB Verwaltung (optional) |
|
||||
|
||||
## Schnellstart
|
||||
## Node / Modern Stack
|
||||
Empfohlene Node Version: **22.12.0** (`.nvmrc`, `.tool-versions`).
|
||||
|
||||
1. **Docker starten:**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
Lokal ohne Docker (Entwicklung Hot Reload):
|
||||
```bash
|
||||
nvm use
|
||||
cd backend && npm install && npm run dev # startet API auf :3001
|
||||
cd ../frontend && npm install && npm run dev # startet Vite auf :5173
|
||||
```
|
||||
|
||||
2. **Anwendung öffnen:**
|
||||
- Rezepte-App: http://localhost:8082
|
||||
- phpMyAdmin: http://localhost:8083
|
||||
## Docker Nutzung
|
||||
|
||||
3. **Container stoppen:**
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
### Standard (nur moderner Stack: DB + API + Frontend)
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
Öffnen: http://localhost:3000
|
||||
|
||||
### Mit phpMyAdmin
|
||||
```bash
|
||||
docker compose --profile admin up -d
|
||||
```
|
||||
Öffnen: http://localhost:8083
|
||||
|
||||
### Alles stoppen
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### Neu bauen (z.B. nach Codeänderungen Backend/Frontend)
|
||||
```bash
|
||||
docker compose build backend frontend
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Nur Backend neu bauen
|
||||
```bash
|
||||
docker compose build backend && docker compose up -d backend
|
||||
```
|
||||
|
||||
### Logs
|
||||
```bash
|
||||
docker compose logs -f backend
|
||||
docker compose logs -f frontend
|
||||
docker compose logs -f mysql
|
||||
```
|
||||
|
||||
## CORS Hinweis
|
||||
Das Backend akzeptiert nur Ursprünge aus `CORS_ORIGIN` (kommagetrennt). In Produktion wird ein Wildcard `*` geblockt, außer du setzt bewusst `ALLOW_INSECURE_CORS=1` (nicht empfohlen). Beispiel:
|
||||
```env
|
||||
CORS_ORIGIN=http://esprimo:3000,http://localhost:3000
|
||||
```
|
||||
Nach Änderung Backend neu bauen / starten.
|
||||
|
||||
## Datenbankzugang
|
||||
|
||||
## Environment (.env)
|
||||
Beispiel siehe `.env.example`. Wichtigste Variablen:
|
||||
```
|
||||
DOMAIN=example.com
|
||||
MYSQL_DATABASE=rezepte
|
||||
MYSQL_USER=rezepte_user
|
||||
MYSQL_PASSWORD=change_this_password
|
||||
MYSQL_ROOT_PASSWORD=change_this_root_password
|
||||
BACKEND_PORT=3001
|
||||
FRONTEND_PORT=3000
|
||||
DATABASE_URL=mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@mysql:3306/${MYSQL_DATABASE}
|
||||
JWT_SECRET=please_change_to_secure_32_char_min
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
VITE_API_URL=http://localhost:3001/api
|
||||
UPLOAD_PATH=/app/uploads
|
||||
MAX_FILE_SIZE=5242880
|
||||
```
|
||||
Nicht gesetzte Variablen fallen auf sichere Defaults (oder Dummy Werte) zurück; vor Produktion immer mit echten Secrets überschreiben.
|
||||
|
||||
## Datenbankzugang
|
||||
|
||||
### Für die Anwendung:
|
||||
- Host: mysql
|
||||
- Database: rezepte_klaus
|
||||
- Database: rezepte
|
||||
- User: rezepte_user
|
||||
- Password: rezepte_pass
|
||||
|
||||
@@ -41,23 +103,16 @@ Eine dockerisierte Version der Rezepte-Verwaltungsanwendung mit PHP, MySQL und p
|
||||
- Username: root
|
||||
- Password: rezepte123
|
||||
|
||||
## Entwicklung
|
||||
## Entwicklung innerhalb Docker
|
||||
|
||||
### Container neu bauen:
|
||||
Status:
|
||||
```bash
|
||||
docker-compose build --no-cache
|
||||
docker-compose up -d
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### Logs anzeigen:
|
||||
Direktes DB Login (Host hat mysql Client):
|
||||
```bash
|
||||
docker-compose logs -f php-app
|
||||
docker-compose logs -f mysql
|
||||
```
|
||||
|
||||
### Container Status:
|
||||
```bash
|
||||
docker-compose ps
|
||||
mysql -h 127.0.0.1 -P 3307 -u rezepte_user -p'rezepte_pass' rezepte
|
||||
```
|
||||
|
||||
## Datenvolumes
|
||||
@@ -66,4 +121,12 @@ Die MySQL-Daten werden in einem Docker-Volume gespeichert und bleiben auch nach
|
||||
|
||||
## Datenbankinitialisierung
|
||||
|
||||
Beim ersten Start werden automatisch alle Tabellen und Daten aus den SQL-Dateien importiert.
|
||||
Beim ersten Start importiert MySQL automatisch SQL-Skripte aus `sql-init/`.
|
||||
|
||||
## Weiterführend
|
||||
- Moderne Stack Doku: `NODEJS_README.md`
|
||||
- Traefik / Registry Deploy: siehe entsprechende `*_SETUP.md` Dateien
|
||||
- Sicherheitshärtung TODO: CORS einschränken, CSP Header, chown Migration verbessern
|
||||
- Erweiterte Compose Overrides: siehe `legacy/README_COMPOSE_LEGACY.md`
|
||||
- Beispiel Umgebungsvariablen: `.env.example`
|
||||
- CI Build Tags: Branch, Semver, `sha-<short>`, Run-ID, `latest` (nur main)
|
||||
@@ -9,8 +9,8 @@ Optimaler Workflow für Entwicklung auf Mac mit Testing auf Linux-Server, der de
|
||||
### **Auf dem Linux-Server (via SSH):**
|
||||
```bash
|
||||
# 1. Repository klonen (falls nicht geschehen)
|
||||
git clone https://github.com/your-repo/rezepte-klaus.git
|
||||
cd rezepte-klaus
|
||||
git clone https://github.com/your-repo/rezepte.git
|
||||
cd rezepte
|
||||
|
||||
# 2. Development-Umgebung einrichten
|
||||
./setup-development.sh
|
||||
@@ -79,13 +79,13 @@ docker-compose -f docker-compose.development.yml restart frontend
|
||||
```bash
|
||||
# VS Code Extension installieren: "Remote - SSH"
|
||||
# Direkt auf Server entwickeln
|
||||
code --remote ssh-remote+user@server /path/to/rezepte-klaus
|
||||
code --remote ssh-remote+user@server /path/to/rezepte
|
||||
```
|
||||
|
||||
#### **C) rsync-Sync:**
|
||||
```bash
|
||||
# Automatischer Sync von Mac zu Server
|
||||
rsync -avz --exclude 'node_modules' ./ user@server:/path/to/rezepte-klaus/
|
||||
rsync -avz --exclude 'node_modules' ./ user@server:/path/to/rezepte/
|
||||
```
|
||||
|
||||
### **2. Container-Rebuild-Workflows:**
|
||||
|
||||
@@ -24,14 +24,14 @@
|
||||
|
||||
```bash
|
||||
# 1. Create deployment directory
|
||||
mkdir -p /opt/rezepte-klaus
|
||||
cd /opt/rezepte-klaus
|
||||
mkdir -p /opt/rezepte
|
||||
cd /opt/rezepte
|
||||
|
||||
# 2. Copy required files to server
|
||||
scp docker-compose.registry.yml user@server:/opt/rezepte-klaus/
|
||||
scp .env.production user@server:/opt/rezepte-klaus/
|
||||
scp *.sql user@server:/opt/rezepte-klaus/
|
||||
scp deploy-registry.sh user@server:/opt/rezepte-klaus/
|
||||
scp docker-compose.registry.yml user@server:/opt/rezepte/
|
||||
scp .env.production user@server:/opt/rezepte/
|
||||
scp *.sql user@server:/opt/rezepte/
|
||||
scp deploy-registry.sh user@server:/opt/rezepte/
|
||||
|
||||
# 3. Make deployment script executable
|
||||
chmod +x deploy-registry.sh
|
||||
|
||||
@@ -38,8 +38,8 @@ DOMAIN=my.domain.com
|
||||
ACME_EMAIL=admin@my.domain.com
|
||||
MYSQL_PASSWORD=super_secure_password_123
|
||||
MYSQL_ROOT_PASSWORD=even_more_secure_root_password_456
|
||||
BACKEND_IMAGE=ghcr.io/username/rezepte-klaus-backend:latest
|
||||
FRONTEND_IMAGE=ghcr.io/username/rezepte-klaus-frontend:latest
|
||||
BACKEND_IMAGE=ghcr.io/username/rezepte-backend:latest
|
||||
FRONTEND_IMAGE=ghcr.io/username/rezepte-frontend:latest
|
||||
```
|
||||
|
||||
## 🔧 Server-Deployment
|
||||
@@ -47,7 +47,7 @@ FRONTEND_IMAGE=ghcr.io/username/rezepte-klaus-frontend:latest
|
||||
### Minimale Dateien auf Server:
|
||||
```bash
|
||||
# Server-Struktur
|
||||
/opt/rezepte-klaus/
|
||||
/opt/rezepte/
|
||||
├── docker-compose.traefik.yml
|
||||
├── .env.production
|
||||
├── deploy-traefik.sh
|
||||
@@ -60,14 +60,14 @@ FRONTEND_IMAGE=ghcr.io/username/rezepte-klaus-frontend:latest
|
||||
### Deployment-Schritte:
|
||||
```bash
|
||||
# 1. Dateien auf Server kopieren
|
||||
scp docker-compose.traefik.yml user@server:/opt/rezepte-klaus/
|
||||
scp .env.production user@server:/opt/rezepte-klaus/
|
||||
scp *.sql user@server:/opt/rezepte-klaus/
|
||||
scp deploy-traefik.sh user@server:/opt/rezepte-klaus/
|
||||
scp docker-compose.traefik.yml user@server:/opt/rezepte/
|
||||
scp .env.production user@server:/opt/rezepte/
|
||||
scp *.sql user@server:/opt/rezepte/
|
||||
scp deploy-traefik.sh user@server:/opt/rezepte/
|
||||
|
||||
# 2. Auf Server einloggen und deployen
|
||||
ssh user@server
|
||||
cd /opt/rezepte-klaus
|
||||
cd /opt/rezepte
|
||||
chmod +x deploy-traefik.sh
|
||||
./deploy-traefik.sh
|
||||
```
|
||||
|
||||
13
backend/.env
Normal file
13
backend/.env
Normal file
@@ -0,0 +1,13 @@
|
||||
# Database (inside container use service name 'mysql')
|
||||
DATABASE_URL="mysql://rezepte_user:rezepte_pass@mysql:3306/rezepte"
|
||||
|
||||
# Server
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
|
||||
# CORS Configuration
|
||||
# Limit default origin; override via compose env if needed
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
# Prisma
|
||||
# DATABASE_URL="file:./dev.db"
|
||||
@@ -3,7 +3,7 @@ NODE_ENV=development
|
||||
PORT=3001
|
||||
|
||||
# Database
|
||||
DATABASE_URL="mysql://rezepte_user:rezepte_pass@localhost:3307/rezepte_klaus"
|
||||
DATABASE_URL="mysql://rezepte_user:rezepte_pass@localhost:3307/rezepte"
|
||||
|
||||
# JWT Secret (change in production!)
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||
@@ -1,5 +1,5 @@
|
||||
# Backend Dockerfile
|
||||
FROM node:18-alpine AS builder
|
||||
FROM node:22.12.0-alpine AS builder
|
||||
|
||||
# Install OpenSSL for Prisma compatibility
|
||||
RUN apk add --no-cache openssl openssl-dev
|
||||
@@ -20,7 +20,7 @@ COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:18-alpine AS production
|
||||
FROM node:22.12.0-alpine AS production
|
||||
|
||||
# Install required system dependencies for Prisma and health checks
|
||||
RUN apk add --no-cache \
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAgBA,QAAA,MAAM,GAAG,6CAAY,CAAC;AA2GtB,eAAe,GAAG,CAAC"}
|
||||
{"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAgBA,QAAA,MAAM,GAAG,6CAAY,CAAC;AAoJtB,eAAe,GAAG,CAAC"}
|
||||
@@ -27,19 +27,49 @@ const limiter = (0, express_rate_limit_1.default)({
|
||||
message: 'Too many requests from this IP, please try again later.',
|
||||
});
|
||||
app.use(limiter);
|
||||
const allowedOrigins = [
|
||||
'http://localhost:5173',
|
||||
'http://localhost:3000',
|
||||
config_1.config.cors.origin
|
||||
].filter(Boolean);
|
||||
const insecureOverride = process.env.ALLOW_INSECURE_CORS === '1';
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
let allowedOrigins = [];
|
||||
if (config_1.config.cors.origin.includes(',')) {
|
||||
allowedOrigins = config_1.config.cors.origin.split(',').map(o => o.trim()).filter(Boolean);
|
||||
}
|
||||
else if (config_1.config.cors.origin === '*' && (!isProd || insecureOverride)) {
|
||||
allowedOrigins = ['*'];
|
||||
}
|
||||
else {
|
||||
allowedOrigins = [config_1.config.cors.origin];
|
||||
}
|
||||
allowedOrigins = Array.from(new Set(allowedOrigins.map(o => o.replace(/\/$/, ''))));
|
||||
if (!isProd && !allowedOrigins.includes('*')) {
|
||||
['http://localhost:5173', 'http://localhost:3000'].forEach(def => {
|
||||
if (!allowedOrigins.includes(def))
|
||||
allowedOrigins.push(def);
|
||||
});
|
||||
}
|
||||
if (isProd && allowedOrigins.includes('*') && !insecureOverride) {
|
||||
console.warn('[CORS] Wildcard removed in production. Set CORS_ORIGIN explicitly or ALLOW_INSECURE_CORS=1 (NOT RECOMMENDED).');
|
||||
allowedOrigins = allowedOrigins.filter(o => o !== '*');
|
||||
}
|
||||
app.use((0, cors_1.default)({
|
||||
origin: allowedOrigins,
|
||||
origin: (origin, callback) => {
|
||||
if (!origin)
|
||||
return callback(null, true);
|
||||
if (allowedOrigins.includes('*') || allowedOrigins.includes(origin.replace(/\/$/, ''))) {
|
||||
return callback(null, true);
|
||||
}
|
||||
console.warn(`[CORS] Blocked origin: ${origin}`);
|
||||
return callback(new Error('CORS not allowed for this origin'));
|
||||
},
|
||||
credentials: true,
|
||||
}));
|
||||
app.use((req, res, next) => {
|
||||
const origin = req.headers.origin;
|
||||
if (origin && allowedOrigins.includes(origin)) {
|
||||
res.header('Access-Control-Allow-Origin', origin);
|
||||
const normalized = origin?.replace(/\/$/, '');
|
||||
if (allowedOrigins.includes('*')) {
|
||||
res.header('Access-Control-Allow-Origin', origin || '*');
|
||||
}
|
||||
else if (normalized && allowedOrigins.includes(normalized)) {
|
||||
res.header('Access-Control-Allow-Origin', normalized);
|
||||
}
|
||||
res.header('Access-Control-Allow-Credentials', 'true');
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
@@ -52,6 +82,7 @@ app.use((req, res, next) => {
|
||||
app.use(express_1.default.json({ limit: '10mb' }));
|
||||
app.use(express_1.default.urlencoded({ extended: true, limit: '10mb' }));
|
||||
app.use(requestLogger_1.requestLogger);
|
||||
app.use('/uploads', express_1.default.static(path_1.default.join(process.cwd(), 'uploads')));
|
||||
app.use('/api/health', health_1.default);
|
||||
app.use('/api/recipes', recipes_1.default);
|
||||
app.use('/api/ingredients', ingredients_1.default);
|
||||
@@ -70,8 +101,12 @@ app.get('/serve/*', (req, res, next) => {
|
||||
resolvedPath: fullPath
|
||||
});
|
||||
}
|
||||
const requestOrigin = req.headers.origin;
|
||||
const chosenOrigin = allowedOrigins.includes('*')
|
||||
? (requestOrigin || '*')
|
||||
: (requestOrigin && allowedOrigins.includes(requestOrigin) ? requestOrigin : allowedOrigins[0] || 'http://localhost:3000');
|
||||
res.set({
|
||||
'Access-Control-Allow-Origin': 'http://localhost:5173',
|
||||
'Access-Control-Allow-Origin': chosenOrigin,
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
});
|
||||
1
backend/dist/app.js.map
vendored
Normal file
1
backend/dist/app.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -10,7 +10,7 @@ exports.config = {
|
||||
port: process.env.PORT || 3001,
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
database: {
|
||||
url: process.env.DATABASE_URL || 'mysql://rezepte_user:rezepte_pass@localhost:3307/rezepte_klaus',
|
||||
url: process.env.DATABASE_URL || 'mysql://rezepte_user:rezepte_pass@localhost:3307/rezepte',
|
||||
},
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'your-super-secret-jwt-key',
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/config/config.ts"],"names":[],"mappings":";;;;;;AAAA,oDAA4B;AAE5B,gBAAM,CAAC,MAAM,EAAE,CAAC;AAEH,QAAA,MAAM,GAAG;IACpB,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI;IAC9B,OAAO,EAAE,OAAO,CAAC,GAAG,CAAC,QAAQ,IAAI,aAAa;IAE9C,QAAQ,EAAE;QACR,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY,IAAI,gEAAgE;KAClG;IAED,GAAG,EAAE;QACH,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,2BAA2B;QAC7D,SAAS,EAAE,KAAK;KACjB;IAED,MAAM,EAAE;QACN,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,WAAW;QAC5C,WAAW,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,SAAS,CAAC;QAC7D,YAAY,EAAE,CAAC,YAAY,EAAE,WAAW,EAAE,WAAW,EAAE,YAAY,CAAC;KACrE;IAED,IAAI,EAAE;QACJ,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,uBAAuB;KAC3D;CACO,CAAC"}
|
||||
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/config/config.ts"],"names":[],"mappings":";;;;;;AAAA,oDAA4B;AAE5B,gBAAM,CAAC,MAAM,EAAE,CAAC;AAEH,QAAA,MAAM,GAAG;IACpB,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,IAAI;IAC9B,OAAO,EAAE,OAAO,CAAC,GAAG,CAAC,QAAQ,IAAI,aAAa;IAE9C,QAAQ,EAAE;QACR,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC,YAAY,IAAI,0DAA0D;KAC5F;IAED,GAAG,EAAE;QACH,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,2BAA2B;QAC7D,SAAS,EAAE,KAAK;KACjB;IAED,MAAM,EAAE;QACN,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,WAAW;QAC5C,WAAW,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,SAAS,CAAC;QAC7D,YAAY,EAAE,CAAC,YAAY,EAAE,WAAW,EAAE,WAAW,EAAE,YAAY,CAAC;KACrE;IAED,IAAI,EAAE;QACJ,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,uBAAuB;KAC3D;CACO,CAAC"}
|
||||
@@ -5,7 +5,7 @@ const router = (0, express_1.Router)();
|
||||
router.get('/', (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Rezepte Klaus API is running!',
|
||||
message: 'Rezepte API is running!',
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: process.env.NODE_ENV,
|
||||
});
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"health.js","sourceRoot":"","sources":["../../src/routes/health.ts"],"names":[],"mappings":";;AAAA,qCAAoD;AAEpD,MAAM,MAAM,GAAG,IAAA,gBAAM,GAAE,CAAC;AAGxB,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,GAAY,EAAE,GAAa,EAAE,EAAE;IAC9C,GAAG,CAAC,IAAI,CAAC;QACP,OAAO,EAAE,IAAI;QACb,OAAO,EAAE,+BAA+B;QACxC,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;QACnC,WAAW,EAAE,OAAO,CAAC,GAAG,CAAC,QAAQ;KAClC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAGH,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IACtD,IAAI,CAAC;QAEH,GAAG,CAAC,IAAI,CAAC;YACP,OAAO,EAAE,IAAI;YACb,OAAO,EAAE,gCAAgC;YACzC,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;SACpC,CAAC,CAAC;IACL,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;YACnB,OAAO,EAAE,KAAK;YACd,OAAO,EAAE,4BAA4B;YACrC,KAAK,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,eAAe;SAChE,CAAC,CAAC;IACL,CAAC;AACH,CAAC,CAAC,CAAC;AAEH,kBAAe,MAAM,CAAC"}
|
||||
{"version":3,"file":"health.js","sourceRoot":"","sources":["../../src/routes/health.ts"],"names":[],"mappings":";;AAAA,qCAAoD;AAEpD,MAAM,MAAM,GAAG,IAAA,gBAAM,GAAE,CAAC;AAGxB,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,GAAY,EAAE,GAAa,EAAE,EAAE;IAC9C,GAAG,CAAC,IAAI,CAAC;QACP,OAAO,EAAE,IAAI;QACb,OAAO,EAAE,yBAAyB;QAClC,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;QACnC,WAAW,EAAE,OAAO,CAAC,GAAG,CAAC,QAAQ;KAClC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAGH,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;IACtD,IAAI,CAAC;QAEH,GAAG,CAAC,IAAI,CAAC;YACP,OAAO,EAAE,IAAI;YACb,OAAO,EAAE,gCAAgC;YACzC,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;SACpC,CAAC,CAAC;IACL,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;YACnB,OAAO,EAAE,KAAK;YACd,OAAO,EAAE,4BAA4B;YACrC,KAAK,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,eAAe;SAChE,CAAC,CAAC;IACL,CAAC;AACH,CAAC,CAAC,CAAC;AAEH,kBAAe,MAAM,CAAC"}
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"images.d.ts","sourceRoot":"","sources":["../../src/routes/images.ts"],"names":[],"mappings":"AAOA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAqQxB,eAAe,MAAM,CAAC"}
|
||||
{"version":3,"file":"images.d.ts","sourceRoot":"","sources":["../../src/routes/images.ts"],"names":[],"mappings":"AAOA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAiYxB,eAAe,MAAM,CAAC"}
|
||||
363
backend/dist/routes/images.js
vendored
Normal file
363
backend/dist/routes/images.js
vendored
Normal file
@@ -0,0 +1,363 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const express_1 = require("express");
|
||||
const client_1 = require("@prisma/client");
|
||||
const multer_1 = __importStar(require("multer"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const config_1 = require("../config/config");
|
||||
const router = (0, express_1.Router)();
|
||||
const prisma = new client_1.PrismaClient();
|
||||
const getUploadsDir = (subPath) => {
|
||||
const localUploadsDir = path_1.default.join(process.cwd(), 'uploads');
|
||||
const legacyUploadsDir = path_1.default.join(process.cwd(), '../../uploads');
|
||||
const baseDir = fs_1.default.existsSync(localUploadsDir)
|
||||
? localUploadsDir
|
||||
: legacyUploadsDir;
|
||||
return subPath ? path_1.default.join(baseDir, subPath) : baseDir;
|
||||
};
|
||||
const loadRecipe = async (req, res, next) => {
|
||||
try {
|
||||
const { recipeId } = req.params;
|
||||
if (!recipeId || isNaN(Number(recipeId))) {
|
||||
return res.status(400).json({ success: false, message: 'Valid recipeId is required' });
|
||||
}
|
||||
const recipe = await prisma.recipe.findUnique({ where: { id: Number(recipeId) } });
|
||||
if (!recipe) {
|
||||
return res.status(404).json({ success: false, message: 'Recipe not found' });
|
||||
}
|
||||
req.recipeNumber = recipe.recipeNumber;
|
||||
res.locals.recipe = recipe;
|
||||
next();
|
||||
}
|
||||
catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
const mimeExt = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/jpg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp'
|
||||
};
|
||||
const storage = multer_1.default.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const recipeNumber = req.recipeNumber || req.body.recipeNumber || req.params.recipeNumber;
|
||||
if (!recipeNumber) {
|
||||
return cb(new Error('Recipe number is required'), '');
|
||||
}
|
||||
const uploadDir = getUploadsDir(recipeNumber);
|
||||
try {
|
||||
if (!fs_1.default.existsSync(uploadDir)) {
|
||||
fs_1.default.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
return cb(new Error('Failed to prepare upload directory'), '');
|
||||
}
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const recipeNumber = req.recipeNumber || req.body.recipeNumber || req.params.recipeNumber;
|
||||
if (!recipeNumber) {
|
||||
return cb(new Error('Recipe number is required'), '');
|
||||
}
|
||||
const uploadDir = getUploadsDir(recipeNumber);
|
||||
const existingFiles = fs_1.default.existsSync(uploadDir)
|
||||
? fs_1.default.readdirSync(uploadDir).filter(f => f.startsWith(`${recipeNumber}_`))
|
||||
: [];
|
||||
let maxIndex = -1;
|
||||
existingFiles.forEach(f => {
|
||||
const m = f.match(new RegExp(`^${recipeNumber}_(\\d+)`));
|
||||
if (m && m[1] !== undefined) {
|
||||
const idx = parseInt(m[1], 10);
|
||||
if (!Number.isNaN(idx) && idx > maxIndex)
|
||||
maxIndex = idx;
|
||||
}
|
||||
});
|
||||
const nextIndex = maxIndex + 1;
|
||||
const ext = mimeExt[file.mimetype] || 'jpg';
|
||||
const filename = `${recipeNumber}_${nextIndex}.${ext}`;
|
||||
cb(null, filename);
|
||||
}
|
||||
});
|
||||
const upload = (0, multer_1.default)({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: config_1.config.upload.maxFileSize,
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
}
|
||||
else {
|
||||
cb(new Error('Invalid file type. Only JPEG, PNG and WebP are allowed.'));
|
||||
}
|
||||
},
|
||||
});
|
||||
router.post('/upload/:recipeId', loadRecipe, (req, res, next) => {
|
||||
const mw = upload.array('images', 10);
|
||||
mw(req, res, function (err) {
|
||||
if (err) {
|
||||
if (err instanceof multer_1.MulterError) {
|
||||
return res.status(400).json({ success: false, message: `Upload error: ${err.message}` });
|
||||
}
|
||||
return res.status(400).json({ success: false, message: err.message || 'Upload failed' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
}, async (req, res, next) => {
|
||||
try {
|
||||
const { recipeId } = req.params;
|
||||
const files = req.files;
|
||||
const recipe = res.locals.recipe;
|
||||
if (!files || files.length === 0) {
|
||||
return res.status(400).json({ success: false, message: 'No files uploaded' });
|
||||
}
|
||||
const images = await Promise.all(files.map(file => {
|
||||
const relativePath = `uploads/${recipe.recipeNumber}/${file.filename}`;
|
||||
return prisma.recipeImage.create({
|
||||
data: { recipeId: Number(recipeId), filePath: relativePath }
|
||||
});
|
||||
}));
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: images,
|
||||
message: `${files.length} images uploaded successfully`,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
if (req.files) {
|
||||
const files = req.files;
|
||||
files.forEach(file => { if (fs_1.default.existsSync(file.path))
|
||||
fs_1.default.unlinkSync(file.path); });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Image ID is required',
|
||||
});
|
||||
}
|
||||
const image = await prisma.recipeImage.findUnique({
|
||||
where: { id: parseInt(id) }
|
||||
});
|
||||
if (!image) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Image not found',
|
||||
});
|
||||
}
|
||||
const fullPath = path_1.default.join(process.cwd(), '../..', image.filePath);
|
||||
if (fs_1.default.existsSync(fullPath)) {
|
||||
fs_1.default.unlinkSync(fullPath);
|
||||
}
|
||||
await prisma.recipeImage.delete({
|
||||
where: { id: parseInt(id) }
|
||||
});
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Image deleted successfully',
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.get('/recipe/:recipeId', async (req, res, next) => {
|
||||
try {
|
||||
const { recipeId } = req.params;
|
||||
if (!recipeId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Recipe ID is required',
|
||||
});
|
||||
}
|
||||
const images = await prisma.recipeImage.findMany({
|
||||
where: { recipeId: parseInt(recipeId) },
|
||||
orderBy: { id: 'asc' }
|
||||
});
|
||||
return res.json({
|
||||
success: true,
|
||||
data: images,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.get('/serve/:imagePath(*)', (req, res, next) => {
|
||||
try {
|
||||
const imagePath = req.params.imagePath;
|
||||
if (!imagePath) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Image path is required',
|
||||
});
|
||||
}
|
||||
const cleanPath = imagePath.replace(/^uploads\//, '');
|
||||
const fullPath = path_1.default.join(getUploadsDir(), cleanPath);
|
||||
console.log(`Serving image: ${imagePath} -> ${fullPath}`);
|
||||
if (!fs_1.default.existsSync(fullPath)) {
|
||||
console.log(`Image not found: ${fullPath}`);
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Image not found',
|
||||
requestedPath: imagePath,
|
||||
resolvedPath: fullPath
|
||||
});
|
||||
}
|
||||
const allowedOrigins = ['http://localhost:5173', 'http://localhost:3000'];
|
||||
const origin = req.headers.origin;
|
||||
const corsOrigin = process.env.CORS_ORIGIN === '*'
|
||||
? (origin || '*')
|
||||
: (origin && allowedOrigins.includes(origin)) ? origin : 'http://localhost:3000';
|
||||
res.set({
|
||||
'Access-Control-Allow-Origin': corsOrigin,
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
});
|
||||
return res.sendFile(path_1.default.resolve(fullPath));
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error serving image:', error);
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Image ID is required',
|
||||
});
|
||||
}
|
||||
const image = await prisma.recipeImage.findUnique({
|
||||
where: { id: parseInt(id) }
|
||||
});
|
||||
if (!image) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Image not found',
|
||||
});
|
||||
}
|
||||
return res.json({
|
||||
success: true,
|
||||
data: image,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.post('/reorder/:recipeId', async (req, res, next) => {
|
||||
try {
|
||||
const { recipeId } = req.params;
|
||||
const { order } = req.body;
|
||||
if (!recipeId || isNaN(Number(recipeId))) {
|
||||
return res.status(400).json({ success: false, message: 'Valid recipeId required' });
|
||||
}
|
||||
if (!Array.isArray(order) || order.length === 0) {
|
||||
return res.status(400).json({ success: false, message: 'order (non-empty array) required' });
|
||||
}
|
||||
const rid = Number(recipeId);
|
||||
const recipe = await prisma.recipe.findUnique({ where: { id: rid } });
|
||||
if (!recipe)
|
||||
return res.status(404).json({ success: false, message: 'Recipe not found' });
|
||||
const images = await prisma.recipeImage.findMany({ where: { recipeId: rid }, orderBy: { id: 'asc' } });
|
||||
if (images.length === 0)
|
||||
return res.status(400).json({ success: false, message: 'No images to reorder' });
|
||||
const existingIds = images.map(i => i.id).sort((a, b) => a - b);
|
||||
const provided = [...order].sort((a, b) => a - b);
|
||||
if (existingIds.length !== provided.length || !existingIds.every((v, i) => v === provided[i])) {
|
||||
return res.status(400).json({ success: false, message: 'order must contain all image IDs exactly once' });
|
||||
}
|
||||
const recipeNumber = recipe.recipeNumber;
|
||||
const baseDir = getUploadsDir(recipeNumber);
|
||||
if (!fs_1.default.existsSync(baseDir)) {
|
||||
return res.status(500).json({ success: false, message: 'Upload directory missing on server' });
|
||||
}
|
||||
const idToImage = new Map(images.map(i => [i.id, i]));
|
||||
const newSequence = order.map((id, idx) => ({ idx, image: idToImage.get(id) }));
|
||||
const tempRenames = [];
|
||||
for (const { idx, image } of newSequence) {
|
||||
const fileName = image.filePath.split('/').pop() || '';
|
||||
const extMatch = fileName.match(/\.(jpg|jpeg|png|webp)$/i);
|
||||
const ext = extMatch && extMatch[1] ? extMatch[1].toLowerCase() : 'jpg';
|
||||
const finalName = `${recipeNumber}_${idx}.${ext === 'jpeg' ? 'jpg' : ext}`;
|
||||
const oldFull = path_1.default.join(baseDir, path_1.default.basename(fileName));
|
||||
const tempName = `${recipeNumber}__reorder_${idx}_${Date.now()}_${Math.random().toString(36).slice(2)}.${ext}`;
|
||||
const tempFull = path_1.default.join(baseDir, tempName);
|
||||
const finalFull = path_1.default.join(baseDir, finalName);
|
||||
if (!fs_1.default.existsSync(oldFull)) {
|
||||
return res.status(500).json({ success: false, message: `File missing on disk: ${fileName}` });
|
||||
}
|
||||
fs_1.default.renameSync(oldFull, tempFull);
|
||||
tempRenames.push({ from: tempFull, to: finalFull, final: `uploads/${recipeNumber}/${finalName}`, idx, ext });
|
||||
}
|
||||
for (const r of tempRenames) {
|
||||
fs_1.default.renameSync(r.from, r.to);
|
||||
}
|
||||
await Promise.all(newSequence.map(({ idx, image }) => {
|
||||
const renameRecord = tempRenames.find(tr => tr.idx === idx);
|
||||
return prisma.recipeImage.update({
|
||||
where: { id: image.id },
|
||||
data: { filePath: renameRecord.final }
|
||||
});
|
||||
}));
|
||||
const updated = await prisma.recipeImage.findMany({ where: { recipeId: rid }, orderBy: { id: 'asc' } });
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Images reordered',
|
||||
data: updated,
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
exports.default = router;
|
||||
//# sourceMappingURL=images.js.map
|
||||
1
backend/dist/routes/images.js.map
vendored
Normal file
1
backend/dist/routes/images.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"version":3,"file":"recipes.d.ts","sourceRoot":"","sources":["../../src/routes/recipes.ts"],"names":[],"mappings":"AAIA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAoQxB,eAAe,MAAM,CAAC"}
|
||||
{"version":3,"file":"recipes.d.ts","sourceRoot":"","sources":["../../src/routes/recipes.ts"],"names":[],"mappings":"AAIA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA6QxB,eAAe,MAAM,CAAC"}
|
||||
@@ -109,6 +109,13 @@ router.get('/:id', async (req, res, next) => {
|
||||
console.log(`Could not load separate ingredients for recipe ${recipe.recipeNumber}:`, ingredientError);
|
||||
}
|
||||
}
|
||||
const now = new Date();
|
||||
prisma.recipe.update({
|
||||
where: { id: recipeId },
|
||||
data: { lastUsed: now },
|
||||
select: { id: true }
|
||||
}).catch(err => console.warn('lastUsed update failed for recipe', recipeId, err.message));
|
||||
recipe.lastUsed = now;
|
||||
return res.json({
|
||||
success: true,
|
||||
data: recipe,
|
||||
File diff suppressed because one or more lines are too long
@@ -39,6 +39,9 @@
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.1.4",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0 <21 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rezepte-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Rezepte Klaus - Node.js Backend",
|
||||
"description": "Rezepte - Node.js Backend",
|
||||
"main": "dist/app.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/app.ts",
|
||||
@@ -53,6 +53,9 @@
|
||||
"typescript",
|
||||
"express"
|
||||
],
|
||||
"author": "Klaus",
|
||||
"author": "Recipe Admin",
|
||||
"license": "MIT"
|
||||
,"engines": {
|
||||
"node": ">=20.19.0 <21 || >=22.12.0"
|
||||
}
|
||||
}
|
||||
@@ -22,12 +22,14 @@ model Recipe {
|
||||
ingredients String? @map("Zutaten") @db.Text
|
||||
instructions String? @map("Zubereitung") @db.Text
|
||||
comment String? @map("Kommentar") @db.Text
|
||||
lastUsed DateTime? @map("last_used") @db.DateTime(0)
|
||||
|
||||
// Relations
|
||||
images RecipeImage[]
|
||||
ingredientsList Ingredient[]
|
||||
|
||||
@@map("Rezepte")
|
||||
@@index([lastUsed])
|
||||
}
|
||||
|
||||
model Ingredient {
|
||||
@@ -30,35 +30,57 @@ const limiter = rateLimit({
|
||||
});
|
||||
app.use(limiter);
|
||||
|
||||
// CORS configuration - Allow both development and production origins
|
||||
const allowedOrigins = [
|
||||
'http://localhost:5173', // Vite dev server
|
||||
'http://localhost:3000', // Docker frontend
|
||||
config.cors.origin // Environment configured origin
|
||||
].filter(Boolean);
|
||||
// CORS Hardening
|
||||
// Supports comma separated origins. Wildcard '*' only allowed if ALLOW_INSECURE_CORS=1 and not in production.
|
||||
const insecureOverride = process.env.ALLOW_INSECURE_CORS === '1';
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
// Add local network origins if CORS_ORIGIN is "*" (for local network access)
|
||||
const corsConfig = config.cors.origin === '*'
|
||||
? {
|
||||
origin: true, // Allow all origins for local network
|
||||
credentials: true,
|
||||
let allowedOrigins: string[] = [];
|
||||
if (config.cors.origin.includes(',')) {
|
||||
allowedOrigins = config.cors.origin.split(',').map(o => o.trim()).filter(Boolean);
|
||||
} else if (config.cors.origin === '*' && (!isProd || insecureOverride)) {
|
||||
allowedOrigins = ['*'];
|
||||
} else {
|
||||
allowedOrigins = [config.cors.origin];
|
||||
}
|
||||
|
||||
// De-dupe & normalize trailing slashes
|
||||
allowedOrigins = Array.from(new Set(allowedOrigins.map(o => o.replace(/\/$/, ''))));
|
||||
|
||||
// Auto-add common localhost dev origins if not prod and not wildcard
|
||||
if (!isProd && !allowedOrigins.includes('*')) {
|
||||
['http://localhost:5173','http://localhost:3000','http://esprimo:3000','http://esprimo:5173'].forEach(def => {
|
||||
if (!allowedOrigins.includes(def)) allowedOrigins.push(def);
|
||||
});
|
||||
}
|
||||
|
||||
// If in production and wildcard attempted without override, remove it
|
||||
if (isProd && allowedOrigins.includes('*') && !insecureOverride) {
|
||||
console.warn('[CORS] Wildcard removed in production. Set CORS_ORIGIN explicitly or ALLOW_INSECURE_CORS=1 (NOT RECOMMENDED).');
|
||||
allowedOrigins = allowedOrigins.filter(o => o !== '*');
|
||||
}
|
||||
|
||||
app.use(cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin) return callback(null, true); // Non-browser / same-origin
|
||||
if (allowedOrigins.includes('*') || allowedOrigins.includes(origin.replace(/\/$/, ''))) {
|
||||
return callback(null, true);
|
||||
}
|
||||
: {
|
||||
origin: allowedOrigins,
|
||||
credentials: true,
|
||||
};
|
||||
|
||||
app.use(cors(corsConfig));
|
||||
console.warn(`[CORS] Blocked origin: ${origin}`);
|
||||
return callback(new Error('CORS not allowed for this origin'));
|
||||
},
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
// Additional CORS headers for all requests
|
||||
app.use((req, res, next) => {
|
||||
const origin = req.headers.origin;
|
||||
|
||||
if (config.cors.origin === '*') {
|
||||
// Allow all origins for local network access
|
||||
const normalized = origin?.replace(/\/$/, '');
|
||||
if (allowedOrigins.includes('*')) {
|
||||
res.header('Access-Control-Allow-Origin', origin || '*');
|
||||
} else if (origin && allowedOrigins.includes(origin)) {
|
||||
res.header('Access-Control-Allow-Origin', origin);
|
||||
} else if (normalized && allowedOrigins.includes(normalized)) {
|
||||
res.header('Access-Control-Allow-Origin', normalized);
|
||||
}
|
||||
res.header('Access-Control-Allow-Credentials', 'true');
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
@@ -107,8 +129,12 @@ app.get('/serve/*', (req, res, next) => {
|
||||
}
|
||||
|
||||
// Set headers for images
|
||||
const requestOrigin = req.headers.origin as string | undefined;
|
||||
const chosenOrigin = allowedOrigins.includes('*')
|
||||
? (requestOrigin || '*')
|
||||
: (requestOrigin && allowedOrigins.includes(requestOrigin) ? requestOrigin : allowedOrigins[0] || 'http://localhost:3000');
|
||||
res.set({
|
||||
'Access-Control-Allow-Origin': 'http://localhost:5173',
|
||||
'Access-Control-Allow-Origin': chosenOrigin,
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
});
|
||||
@@ -7,7 +7,7 @@ export const config = {
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
|
||||
database: {
|
||||
url: process.env.DATABASE_URL || 'mysql://rezepte_user:rezepte_pass@localhost:3307/rezepte_klaus',
|
||||
url: process.env.DATABASE_URL || 'mysql://rezepte_user:rezepte_pass@localhost:3307/rezepte',
|
||||
},
|
||||
|
||||
jwt: {
|
||||
@@ -6,7 +6,7 @@ const router = Router();
|
||||
router.get('/', (req: Request, res: Response) => {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Rezepte Klaus API is running!',
|
||||
message: 'Rezepte API is running!',
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: process.env.NODE_ENV,
|
||||
});
|
||||
409
backend/src/routes/images.ts
Normal file
409
backend/src/routes/images.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import multer, { MulterError } from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { config } from '../config/config';
|
||||
|
||||
const router = Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Utility function to get correct uploads directory path
|
||||
const getUploadsDir = (subPath?: string): string => {
|
||||
// In Docker or when uploads directory exists in current directory, use local uploads
|
||||
const localUploadsDir = path.join(process.cwd(), 'uploads');
|
||||
const legacyUploadsDir = path.join(process.cwd(), '../../uploads');
|
||||
|
||||
const baseDir = fs.existsSync(localUploadsDir)
|
||||
? localUploadsDir
|
||||
: legacyUploadsDir;
|
||||
|
||||
return subPath ? path.join(baseDir, subPath) : baseDir;
|
||||
};
|
||||
|
||||
// Middleware: load recipe by :recipeId and attach recipeNumber for storage
|
||||
const loadRecipe = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { recipeId } = req.params;
|
||||
if (!recipeId || isNaN(Number(recipeId))) {
|
||||
return res.status(400).json({ success: false, message: 'Valid recipeId is required' });
|
||||
}
|
||||
const recipe = await prisma.recipe.findUnique({ where: { id: Number(recipeId) } });
|
||||
if (!recipe) {
|
||||
return res.status(404).json({ success: false, message: 'Recipe not found' });
|
||||
}
|
||||
// Attach for later usage
|
||||
(req as any).recipeNumber = recipe.recipeNumber;
|
||||
res.locals.recipe = recipe;
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
// Map mimetypes to extensions
|
||||
const mimeExt: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/jpg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp'
|
||||
};
|
||||
|
||||
// Configure multer for file uploads (uses injected recipeNumber)
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const recipeNumber = (req as any).recipeNumber || req.body.recipeNumber || req.params.recipeNumber;
|
||||
if (!recipeNumber) {
|
||||
return cb(new Error('Recipe number is required'), '');
|
||||
}
|
||||
const uploadDir = getUploadsDir(recipeNumber);
|
||||
try {
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
} catch (e) {
|
||||
return cb(new Error('Failed to prepare upload directory'), '');
|
||||
}
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const recipeNumber = (req as any).recipeNumber || req.body.recipeNumber || req.params.recipeNumber;
|
||||
if (!recipeNumber) {
|
||||
return cb(new Error('Recipe number is required'), '');
|
||||
}
|
||||
const uploadDir = getUploadsDir(recipeNumber);
|
||||
const existingFiles = fs.existsSync(uploadDir)
|
||||
? fs.readdirSync(uploadDir).filter(f => f.startsWith(`${recipeNumber}_`))
|
||||
: [];
|
||||
// Determine next index by scanning existing indices
|
||||
let maxIndex = -1;
|
||||
existingFiles.forEach(f => {
|
||||
const m = f.match(new RegExp(`^${recipeNumber}_(\\d+)`));
|
||||
if (m && m[1] !== undefined) {
|
||||
const idx = parseInt(m[1]!, 10);
|
||||
if (!Number.isNaN(idx) && idx > maxIndex) maxIndex = idx;
|
||||
}
|
||||
});
|
||||
const nextIndex = maxIndex + 1;
|
||||
const ext = mimeExt[file.mimetype] || 'jpg';
|
||||
const filename = `${recipeNumber}_${nextIndex}.${ext}`;
|
||||
cb(null, filename);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: config.upload.maxFileSize, // 5MB
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type. Only JPEG, PNG and WebP are allowed.'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Upload images for a recipe (preload recipe middleware before multer)
|
||||
router.post('/upload/:recipeId', loadRecipe, (req, res, next) => {
|
||||
const mw = upload.array('images', 10);
|
||||
mw(req, res, function(err: any) {
|
||||
if (err) {
|
||||
if (err instanceof MulterError) {
|
||||
return res.status(400).json({ success: false, message: `Upload error: ${err.message}` });
|
||||
}
|
||||
return res.status(400).json({ success: false, message: err.message || 'Upload failed' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
}, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { recipeId } = req.params;
|
||||
const files = req.files as Express.Multer.File[];
|
||||
const recipe = res.locals.recipe;
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
return res.status(400).json({ success: false, message: 'No files uploaded' });
|
||||
}
|
||||
|
||||
const images = await Promise.all(files.map(file => {
|
||||
const relativePath = `uploads/${recipe.recipeNumber}/${file.filename}`;
|
||||
return prisma.recipeImage.create({
|
||||
data: { recipeId: Number(recipeId), filePath: relativePath }
|
||||
});
|
||||
}));
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: images,
|
||||
message: `${files.length} images uploaded successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
if (req.files) {
|
||||
const files = req.files as Express.Multer.File[];
|
||||
files.forEach(file => { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete an image
|
||||
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Image ID is required',
|
||||
});
|
||||
}
|
||||
|
||||
const image = await prisma.recipeImage.findUnique({
|
||||
where: { id: parseInt(id) }
|
||||
});
|
||||
|
||||
if (!image) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Image not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Delete file from filesystem
|
||||
const fullPath = path.join(process.cwd(), '../..', image.filePath);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
fs.unlinkSync(fullPath);
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
await prisma.recipeImage.delete({
|
||||
where: { id: parseInt(id) }
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Image deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get all images for a recipe by recipe ID
|
||||
router.get('/recipe/:recipeId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { recipeId } = req.params;
|
||||
|
||||
if (!recipeId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Recipe ID is required',
|
||||
});
|
||||
}
|
||||
|
||||
const images = await prisma.recipeImage.findMany({
|
||||
where: { recipeId: parseInt(recipeId) },
|
||||
orderBy: { id: 'asc' }
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: images,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Serve image file
|
||||
router.get('/serve/:imagePath(*)', (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const imagePath = req.params.imagePath;
|
||||
|
||||
if (!imagePath) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Image path is required',
|
||||
});
|
||||
}
|
||||
|
||||
// Remove leading 'uploads/' if present to avoid duplication
|
||||
const cleanPath = imagePath.replace(/^uploads\//, '');
|
||||
const fullPath = path.join(getUploadsDir(), cleanPath);
|
||||
|
||||
console.log(`Serving image: ${imagePath} -> ${fullPath}`);
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
console.log(`Image not found: ${fullPath}`);
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Image not found',
|
||||
requestedPath: imagePath,
|
||||
resolvedPath: fullPath
|
||||
});
|
||||
}
|
||||
|
||||
// Set CORS headers for images - support multiple origins including local network
|
||||
const allowedOrigins = ['http://localhost:5173', 'http://localhost:3000'];
|
||||
const origin = req.headers.origin;
|
||||
|
||||
// Check if CORS_ORIGIN is set to "*" for local network access
|
||||
const corsOrigin = process.env.CORS_ORIGIN === '*'
|
||||
? (origin || '*')
|
||||
: (origin && allowedOrigins.includes(origin)) ? origin : 'http://localhost:3000';
|
||||
|
||||
res.set({
|
||||
'Access-Control-Allow-Origin': corsOrigin,
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
'Cache-Control': 'public, max-age=31536000', // Cache for 1 year
|
||||
});
|
||||
|
||||
return res.sendFile(path.resolve(fullPath));
|
||||
} catch (error) {
|
||||
console.error('Error serving image:', error);
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get image metadata
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Image ID is required',
|
||||
});
|
||||
}
|
||||
|
||||
const image = await prisma.recipeImage.findUnique({
|
||||
where: { id: parseInt(id) }
|
||||
});
|
||||
|
||||
if (!image) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Image not found',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: image,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Reorder images for a recipe
|
||||
router.post('/reorder/:recipeId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
/*
|
||||
Payload: { order: number[] }
|
||||
- order: Array of image IDs in desired final sequence.
|
||||
- First becomes *_0.ext (main), second *_1.ext, ...
|
||||
*/
|
||||
try {
|
||||
const { recipeId } = req.params;
|
||||
const { order } = req.body as { order?: number[] };
|
||||
|
||||
if (!recipeId || isNaN(Number(recipeId))) {
|
||||
return res.status(400).json({ success: false, message: 'Valid recipeId required' });
|
||||
}
|
||||
if (!Array.isArray(order) || order.length === 0) {
|
||||
return res.status(400).json({ success: false, message: 'order (non-empty array) required' });
|
||||
}
|
||||
|
||||
const rid = Number(recipeId);
|
||||
const recipe = await prisma.recipe.findUnique({ where: { id: rid } });
|
||||
if (!recipe) return res.status(404).json({ success: false, message: 'Recipe not found' });
|
||||
|
||||
const images = await prisma.recipeImage.findMany({ where: { recipeId: rid }, orderBy: { id: 'asc' } });
|
||||
if (images.length === 0) return res.status(400).json({ success: false, message: 'No images to reorder' });
|
||||
|
||||
// Validate order covers exactly all image IDs
|
||||
const existingIds = images.map(i => i.id).sort((a,b)=>a-b);
|
||||
const provided = [...order].sort((a,b)=>a-b);
|
||||
if (existingIds.length !== provided.length || !existingIds.every((v,i)=>v===provided[i])) {
|
||||
return res.status(400).json({ success: false, message: 'order must contain all image IDs exactly once' });
|
||||
}
|
||||
|
||||
const recipeNumber = recipe.recipeNumber;
|
||||
|
||||
// Determine the actual directory from the first image path
|
||||
// Images might be in uploads/R005/ format instead of uploads/5/
|
||||
let actualDir = recipeNumber;
|
||||
if (images.length > 0 && images[0]) {
|
||||
const firstImagePath = images[0].filePath;
|
||||
// Extract directory from path like "uploads/R005/R005_0.jpg" -> "R005"
|
||||
const pathParts = firstImagePath.split('/');
|
||||
if (pathParts.length >= 2) {
|
||||
const dirName = pathParts[pathParts.length - 2];
|
||||
if (dirName) {
|
||||
actualDir = dirName; // Get the directory name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const baseDir = getUploadsDir(actualDir);
|
||||
if (!fs.existsSync(baseDir)) {
|
||||
return res.status(500).json({ success: false, message: 'Upload directory missing on server' });
|
||||
}
|
||||
|
||||
// Build mapping: targetIndex -> image object
|
||||
const idToImage = new Map(images.map(i => [i.id, i] as const));
|
||||
const newSequence = order.map((id, idx) => ({ idx, image: idToImage.get(id)! }));
|
||||
|
||||
// Phase 1: rename to temporary names to avoid collisions
|
||||
const tempRenames: { from: string; to: string; final: string; idx: number; ext: string }[] = [];
|
||||
for (const { idx, image } of newSequence) {
|
||||
const fileName = image.filePath.split('/').pop() || '';
|
||||
const extMatch = fileName.match(/\.(jpg|jpeg|png|webp)$/i);
|
||||
const ext = extMatch && extMatch[1] ? extMatch[1].toLowerCase() : 'jpg';
|
||||
const finalName = `${actualDir}_${idx}.${ext === 'jpeg' ? 'jpg' : ext}`;
|
||||
// Previous implementation incorrectly traversed ../../ resulting in /uploads/... (missing /app prefix in container)
|
||||
// image.filePath is like 'uploads/R005/R005_0.png' and baseDir points to '/app/uploads/R005'
|
||||
// So we join baseDir with just the filename to locate the current file.
|
||||
const oldFull = path.join(baseDir, path.basename(fileName));
|
||||
const tempName = `${actualDir}__reorder_${idx}_${Date.now()}_${Math.random().toString(36).slice(2)}.${ext}`;
|
||||
const tempFull = path.join(baseDir, tempName);
|
||||
const finalFull = path.join(baseDir, finalName);
|
||||
if (!fs.existsSync(oldFull)) {
|
||||
return res.status(500).json({ success: false, message: `File missing on disk: ${fileName}` });
|
||||
}
|
||||
fs.renameSync(oldFull, tempFull);
|
||||
tempRenames.push({ from: tempFull, to: finalFull, final: `uploads/${actualDir}/${finalName}`, idx, ext });
|
||||
}
|
||||
|
||||
// Phase 2: rename temp -> final
|
||||
for (const r of tempRenames) {
|
||||
fs.renameSync(r.from, r.to);
|
||||
}
|
||||
|
||||
// Update DB entries in a transaction-like sequence
|
||||
// (MySQL autocommit; if one fails we attempt best-effort rollback is complex -> assume disk ops succeeded)
|
||||
await Promise.all(newSequence.map(({ idx, image }) => {
|
||||
const renameRecord = tempRenames.find(tr => tr.idx === idx)!;
|
||||
return prisma.recipeImage.update({
|
||||
where: { id: image.id },
|
||||
data: { filePath: renameRecord.final }
|
||||
});
|
||||
}));
|
||||
|
||||
const updated = await prisma.recipeImage.findMany({ where: { recipeId: rid }, orderBy: { id: 'asc' } });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Images reordered',
|
||||
data: updated,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -50,6 +50,10 @@ router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
where.category = { contains: category as string };
|
||||
}
|
||||
|
||||
// Whitelist allowed sort fields to prevent SQL injection vectors via Prisma
|
||||
const allowedSortFields = new Set(['title','category','servings','recipeNumber','lastUsed','id']);
|
||||
const chosenSortField = allowedSortFields.has(String(sortBy)) ? String(sortBy) : 'title';
|
||||
|
||||
const [recipes, total] = await Promise.all([
|
||||
prisma.recipe.findMany({
|
||||
where,
|
||||
@@ -57,7 +61,8 @@ router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
images: true,
|
||||
ingredientsList: true,
|
||||
},
|
||||
orderBy: { [sortBy as string]: sortOrder as 'asc' | 'desc' },
|
||||
// @ts-ignore lastUsed exists in DB schema; Prisma typing cache issue workaround
|
||||
orderBy: { [chosenSortField]: sortOrder === 'desc' ? 'desc' : 'asc' },
|
||||
skip,
|
||||
take: limitNum,
|
||||
}),
|
||||
@@ -79,8 +84,24 @@ router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Neueste zubereitete Rezepte (nach lastUsed sortiert) – vor :id Route platzieren
|
||||
router.get('/recent', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { limit = '10' } = req.query;
|
||||
const take = Math.min(Math.max(parseInt(limit as string) || 10, 1), 50);
|
||||
const recipes = await prisma.recipe.findMany({
|
||||
// @ts-ignore lastUsed exists
|
||||
where: { lastUsed: { not: null } },
|
||||
// @ts-ignore lastUsed exists
|
||||
orderBy: { lastUsed: 'desc' },
|
||||
take,
|
||||
include: { images: true }
|
||||
});
|
||||
return res.json({ success: true, data: recipes });
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
// Get single recipe by ID
|
||||
// Get recipe by ID
|
||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
@@ -135,6 +156,8 @@ router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
}
|
||||
}
|
||||
|
||||
// lastUsed wird nicht mehr beim Ansehen aktualisiert, sondern explizit über einen "cooked" Endpoint
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: recipe,
|
||||
@@ -262,4 +285,45 @@ router.delete('/:id', async (req: Request, res: Response, next: NextFunction) =>
|
||||
}
|
||||
});
|
||||
|
||||
// Endpoint um ein Rezept als "zubereitet" zu markieren -> aktualisiert lastUsed
|
||||
router.post('/:id/cooked', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
return res.status(400).json({ success: false, message: 'Recipe ID is required' });
|
||||
}
|
||||
const recipeId = parseInt(id, 10);
|
||||
if (isNaN(recipeId)) {
|
||||
return res.status(400).json({ success: false, message: 'Invalid recipe ID format' });
|
||||
}
|
||||
|
||||
let customDate: Date | null = null;
|
||||
if (req.body && req.body.lastUsed) {
|
||||
const parsed = new Date(req.body.lastUsed);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
return res.status(400).json({ success: false, message: 'Ungültiges Datum (lastUsed)' });
|
||||
}
|
||||
customDate = parsed;
|
||||
}
|
||||
|
||||
const targetDate = customDate || new Date();
|
||||
if (targetDate.getTime() > Date.now() + 60_000) { // 1 Minute Puffer
|
||||
return res.status(400).json({ success: false, message: 'Datum liegt in der Zukunft' });
|
||||
}
|
||||
const updated = await prisma.recipe.update({
|
||||
where: { id: recipeId },
|
||||
// @ts-ignore lastUsed exists
|
||||
data: { lastUsed: targetDate }
|
||||
});
|
||||
|
||||
// @ts-ignore lastUsed field is present in database
|
||||
return res.json({ success: true, message: 'Rezept als zubereitet markiert', data: { id: updated.id, lastUsed: (updated as any).lastUsed } });
|
||||
} catch (error) {
|
||||
if ((error as any).code === 'P2025') {
|
||||
return res.status(404).json({ success: false, message: 'Recipe not found' });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_DIR="/opt/backups/rezepte-klaus"
|
||||
BACKUP_DIR="/opt/backups/rezepte"
|
||||
|
||||
# Create backup directory
|
||||
mkdir -p $BACKUP_DIR
|
||||
@@ -19,7 +19,7 @@ if docker ps | grep -q rezepte-mysql-prod; then
|
||||
docker exec rezepte-mysql-prod mysqldump \
|
||||
-u rezepte_user \
|
||||
-p${MYSQL_PASSWORD:-change_this_password} \
|
||||
rezepte_klaus > $BACKUP_DIR/database_$DATE.sql
|
||||
rezepte > $BACKUP_DIR/database_$DATE.sql
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Database backup completed: database_$DATE.sql"
|
||||
|
||||
@@ -15,11 +15,11 @@ TAG=${IMAGE_TAG:-"latest"}
|
||||
|
||||
# Image names (with optional namespace)
|
||||
if [ -n "$NAMESPACE" ]; then
|
||||
BACKEND_IMAGE="$REGISTRY/$NAMESPACE/rezepte-klaus-backend:$TAG"
|
||||
FRONTEND_IMAGE="$REGISTRY/$NAMESPACE/rezepte-klaus-frontend:$TAG"
|
||||
BACKEND_IMAGE="$REGISTRY/$NAMESPACE/rezepte-backend:$TAG"
|
||||
FRONTEND_IMAGE="$REGISTRY/$NAMESPACE/rezepte-frontend:$TAG"
|
||||
else
|
||||
BACKEND_IMAGE="$REGISTRY/rezepte-klaus-backend:$TAG"
|
||||
FRONTEND_IMAGE="$REGISTRY/rezepte-klaus-frontend:$TAG"
|
||||
BACKEND_IMAGE="$REGISTRY/rezepte-backend:$TAG"
|
||||
FRONTEND_IMAGE="$REGISTRY/rezepte-frontend:$TAG"
|
||||
fi
|
||||
|
||||
echo "📦 Building images..."
|
||||
@@ -34,7 +34,7 @@ fi
|
||||
|
||||
# Build backend
|
||||
echo "🔨 Building backend image..."
|
||||
docker build -t "$BACKEND_IMAGE" ./nodejs-version/backend
|
||||
docker build -t "$BACKEND_IMAGE" ./backend
|
||||
|
||||
# Build frontend (with production API URL)
|
||||
echo "🔨 Building frontend image..."
|
||||
@@ -42,12 +42,12 @@ if [ -n "$API_BASE_URL" ]; then
|
||||
docker build \
|
||||
--build-arg VITE_API_BASE_URL="$API_BASE_URL" \
|
||||
-t "$FRONTEND_IMAGE" \
|
||||
./nodejs-version/frontend
|
||||
./frontend
|
||||
else
|
||||
docker build \
|
||||
--build-arg VITE_API_BASE_URL="https://${DOMAIN:-yourdomain.com}/api" \
|
||||
-t "$FRONTEND_IMAGE" \
|
||||
./nodejs-version/frontend
|
||||
./frontend
|
||||
fi
|
||||
|
||||
# Push images
|
||||
|
||||
45
build_and_deploy.sh
Executable file
45
build_and_deploy.sh
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
# Build Docker-Container and deplay to docker.citysensor.de
|
||||
#
|
||||
# Call: build_and_deplay.sh name
|
||||
# name = name of target image
|
||||
#
|
||||
# The Dockerfile must be named like Dockerfile_name
|
||||
#
|
||||
# V 1.0.0 2025-09-28 rxf
|
||||
# - adapted from build_and_copy.sh and from deplay.sh
|
||||
|
||||
set -x
|
||||
registry=docker.citysensor.de
|
||||
name=$1
|
||||
|
||||
usage()
|
||||
{
|
||||
echo "Usage build_and_deploy.sh name [-h]"
|
||||
echo " Build docker container $name and copy to $registry"
|
||||
echo "Params:"
|
||||
echo " name: name of image"
|
||||
}
|
||||
|
||||
if [[ "$name" == "" ]]; then
|
||||
echo "No target name given!"
|
||||
exit
|
||||
fi
|
||||
|
||||
while getopts n:p:h? o
|
||||
do
|
||||
case "$o" in
|
||||
h) usage; exit 0;;
|
||||
*) usage; exit 1;;
|
||||
esac
|
||||
done
|
||||
shift $((OPTIND-1))
|
||||
|
||||
#docker build -f Dockerfile_$orgName --no-cache -t $name .
|
||||
|
||||
#dat=`date +%Y%m%d%H%M`
|
||||
#docker tag $name $name:V_$dat
|
||||
#docker tag $name docker.citysensor.de/$name:latest
|
||||
#dat=`date +%Y%m%d%H%M`
|
||||
#docker tag $name docker.citysensor.de/$name:V_$dat
|
||||
#docker push docker.citysensor.de/$name
|
||||
@@ -8,7 +8,7 @@ if (session_status() === PHP_SESSION_NONE) {
|
||||
|
||||
// Docker environment variables with fallbacks for local development
|
||||
$host_name = $_ENV['DB_HOST'] ?? 'localhost';
|
||||
$database = $_ENV['DB_NAME'] ?? 'rezepte_klaus';
|
||||
$database = $_ENV['DB_NAME'] ?? 'rezepte';
|
||||
$user_name = $_ENV['DB_USER'] ?? 'root';
|
||||
$password = $_ENV['DB_PASS'] ?? '';
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ if (session_status() === PHP_SESSION_NONE) {
|
||||
|
||||
// Docker environment variables with fallbacks for local development
|
||||
$host_name = $_ENV['DB_HOST'] ?? 'localhost';
|
||||
$database = $_ENV['DB_NAME'] ?? 'rezepte_klaus';
|
||||
$database = $_ENV['DB_NAME'] ?? 'rezepte';
|
||||
$user_name = $_ENV['DB_USER'] ?? 'root';
|
||||
$password = $_ENV['DB_PASS'] ?? '';
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
$host_name = 'localhost';
|
||||
$database = 'rezepte_klaus';
|
||||
$database = 'rezepte';
|
||||
$user_name = 'root';
|
||||
$password = '';
|
||||
|
||||
|
||||
@@ -34,16 +34,16 @@ echo ""
|
||||
echo "🖼️ Testing Image Serving:"
|
||||
echo "Checking upload directory structure..."
|
||||
|
||||
# Check if upload directory exists
|
||||
if [ -d "./upload" ]; then
|
||||
echo "✅ Upload directory found:"
|
||||
ls -la ./upload/ | head -10
|
||||
# Check if uploads directory exists
|
||||
if [ -d "./uploads" ]; then
|
||||
echo "✅ Uploads directory found:"
|
||||
ls -la ./uploads/ | head -10
|
||||
|
||||
# Find a test image
|
||||
TEST_IMAGE=$(find ./upload -name "*.jpg" | head -1)
|
||||
TEST_IMAGE=$(find ./uploads -name "*.jpg" | head -1)
|
||||
if [ -n "$TEST_IMAGE" ]; then
|
||||
# Remove ./upload/ prefix for API path
|
||||
RELATIVE_PATH=${TEST_IMAGE#./upload/}
|
||||
# Remove ./uploads/ prefix for API path
|
||||
RELATIVE_PATH=${TEST_IMAGE#./uploads/}
|
||||
echo ""
|
||||
echo "🧪 Testing image URL: $RELATIVE_PATH"
|
||||
echo "Full URL: http://$HOST_IP:3001/api/images/serve/$RELATIVE_PATH"
|
||||
@@ -63,7 +63,8 @@ if [ -d "./upload" ]; then
|
||||
echo "❌ No JPG images found in upload directory"
|
||||
fi
|
||||
else
|
||||
echo "❌ Upload directory not found"
|
||||
echo "❌ Uploads directory not found. Directory contents:"
|
||||
ls -la ./ | grep -E "(upload|Rezepte)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🗄️ Setting up Rezepte Klaus with external MySQL (Gitea)"
|
||||
echo "🗄️ Setting up Rezepte with external MySQL (Gitea)"
|
||||
echo "======================================================"
|
||||
|
||||
# Check if .env.external-db exists
|
||||
@@ -63,33 +63,33 @@ else
|
||||
fi
|
||||
|
||||
# Create database and user
|
||||
echo "🏗️ Setting up Rezepte Klaus database..."
|
||||
echo "🏗️ Setting up Rezepte database..."
|
||||
|
||||
# SQL commands for database setup
|
||||
DATABASE_SETUP_SQL="
|
||||
-- Create Rezepte Klaus database
|
||||
CREATE DATABASE IF NOT EXISTS rezepte_klaus
|
||||
-- Create Rezepte database
|
||||
CREATE DATABASE IF NOT EXISTS rezepte
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- Create dedicated user for Rezepte Klaus
|
||||
-- Create dedicated user for Rezepte
|
||||
CREATE USER IF NOT EXISTS 'rezepte_user'@'%' IDENTIFIED BY '${MYSQL_REZEPTE_PASSWORD}';
|
||||
|
||||
-- Grant permissions
|
||||
GRANT ALL PRIVILEGES ON rezepte_klaus.* TO 'rezepte_user'@'%';
|
||||
GRANT ALL PRIVILEGES ON rezepte.* TO 'rezepte_user'@'%';
|
||||
|
||||
-- Refresh privileges
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
-- Show created database
|
||||
SHOW DATABASES LIKE 'rezepte_klaus';
|
||||
SHOW DATABASES LIKE 'rezepte';
|
||||
"
|
||||
|
||||
# Execute database setup
|
||||
echo "$DATABASE_SETUP_SQL" | docker exec -i "$MYSQL_HOST" mysql -u"${MYSQL_ADMIN_USER:-root}" -p"${MYSQL_ADMIN_PASSWORD}"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Database 'rezepte_klaus' and user 'rezepte_user' created successfully"
|
||||
echo "✅ Database 'rezepte' and user 'rezepte_user' created successfully"
|
||||
else
|
||||
echo "❌ Error creating database or user"
|
||||
exit 1
|
||||
@@ -102,7 +102,7 @@ REQUIRED_FILES=("Rezepte.sql" "ingredients.sql" "Zubereitung.sql" "rezepte_bilde
|
||||
for file in "${REQUIRED_FILES[@]}"; do
|
||||
if [ -f "$file" ]; then
|
||||
echo " Importing $file..."
|
||||
docker exec -i "$MYSQL_HOST" mysql -u"${MYSQL_ADMIN_USER:-root}" -p"${MYSQL_ADMIN_PASSWORD}" rezepte_klaus < "$file"
|
||||
docker exec -i "$MYSQL_HOST" mysql -u"${MYSQL_ADMIN_USER:-root}" -p"${MYSQL_ADMIN_PASSWORD}" rezepte < "$file"
|
||||
if [ $? -eq 0 ]; then
|
||||
echo " ✅ $file imported successfully"
|
||||
else
|
||||
@@ -124,7 +124,7 @@ echo "📥 Pulling latest images..."
|
||||
docker compose -f docker compose.traefik-external-db.yml pull
|
||||
|
||||
# Start services
|
||||
echo "🚀 Starting Rezepte Klaus services with external MySQL..."
|
||||
echo "🚀 Starting Rezepte services with external MySQL..."
|
||||
docker compose -f docker compose.traefik-external-db.yml up -d
|
||||
|
||||
# Wait for services to be healthy
|
||||
@@ -145,13 +145,13 @@ if [ "$HEALTHY_SERVICES" -ge 4 ]; then
|
||||
echo ""
|
||||
echo "🗄️ Database Information:"
|
||||
echo " MySQL Host: $MYSQL_HOST (shared with Gitea)"
|
||||
echo " Rezepte Database: rezepte_klaus"
|
||||
echo " Rezepte Database: rezepte"
|
||||
echo " Rezepte User: rezepte_user"
|
||||
echo ""
|
||||
echo "📊 Service Status:"
|
||||
docker compose -f docker compose.traefik-external-db.yml ps
|
||||
echo ""
|
||||
echo "💡 phpMyAdmin now shows both Gitea and Rezepte Klaus databases!"
|
||||
echo "💡 phpMyAdmin now shows both Gitea and Rezepte databases!"
|
||||
else
|
||||
echo "❌ Deployment failed! Check logs:"
|
||||
docker compose -f docker compose.traefik-external-db.yml logs --tail=50
|
||||
@@ -163,4 +163,4 @@ echo "📋 Useful commands:"
|
||||
echo " View logs: docker compose -f docker compose.traefik-external-db.yml logs -f"
|
||||
echo " Update: docker compose -f docker compose.traefik-external-db.yml pull && docker compose -f docker compose.traefik-external-db.yml up -d"
|
||||
echo " Stop: docker compose -f docker compose.traefik-external-db.yml down"
|
||||
echo " Database access: docker exec -it $MYSQL_HOST mysql -urezepte_user -p rezepte_klaus"
|
||||
echo " Database access: docker exec -it $MYSQL_HOST mysql -urezepte_user -p rezepte"
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🚀 Deploying Rezepte Klaus to production..."
|
||||
echo "🚀 Deploying Rezepte to production..."
|
||||
|
||||
# Check if .env.production exists
|
||||
if [ ! -f .env.production ]; then
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🚀 Deploying Rezepte Klaus from Docker Registry..."
|
||||
echo "🚀 Deploying Rezepte from Docker Registry..."
|
||||
|
||||
# Check if .env.production exists
|
||||
if [ ! -f .env.production ]; then
|
||||
@@ -59,8 +59,8 @@ if [ "$HEALTHY_SERVICES" -ge 3 ]; then
|
||||
docker-compose -f docker-compose.registry.yml ps
|
||||
echo ""
|
||||
echo "🏷️ Image Information:"
|
||||
echo "Backend: ${BACKEND_IMAGE:-ghcr.io/your-username/rezepte-klaus-backend:latest}"
|
||||
echo "Frontend: ${FRONTEND_IMAGE:-ghcr.io/your-username/rezepte-klaus-frontend:latest}"
|
||||
echo "Backend: ${BACKEND_IMAGE:-ghcr.io/your-username/rezepte-backend:latest}"
|
||||
echo "Frontend: ${FRONTEND_IMAGE:-ghcr.io/your-username/rezepte-frontend:latest}"
|
||||
else
|
||||
echo "❌ Deployment failed! Check logs:"
|
||||
docker-compose -f docker-compose.registry.yml logs --tail=50
|
||||
|
||||
66
deploy-traefik-production.sh
Executable file
66
deploy-traefik-production.sh
Executable file
@@ -0,0 +1,66 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🚀 Deploying Rezepte to production with Traefik..."
|
||||
echo "📍 Target: https://rezepte.fuerst-stuttgart.de"
|
||||
|
||||
# Check if .env.traefik exists
|
||||
if [ ! -f .env.traefik ]; then
|
||||
echo "❌ Error: .env.traefik file not found!"
|
||||
echo "Please copy .env.traefik.example to .env.traefik and configure it."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load environment variables
|
||||
export $(cat .env.traefik | grep -v '^#' | xargs)
|
||||
|
||||
# Validate required environment variables
|
||||
if [ -z "$MYSQL_PASSWORD" ] || [ -z "$DOMAIN" ] || [ -z "$ACME_EMAIL" ]; then
|
||||
echo "❌ Error: Required environment variables not set in .env.traefik"
|
||||
echo "Please configure MYSQL_PASSWORD, DOMAIN, and ACME_EMAIL"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📥 Pulling latest changes..."
|
||||
git pull origin main
|
||||
|
||||
echo "🛑 Stopping existing containers..."
|
||||
docker compose -f docker-compose.traefik.yml --env-file .env.traefik down
|
||||
|
||||
echo "🏗️ Building images..."
|
||||
docker compose -f docker-compose.traefik.yml --env-file .env.traefik build --no-cache
|
||||
|
||||
echo "🚀 Starting services..."
|
||||
docker compose -f docker-compose.traefik.yml --env-file .env.traefik up -d
|
||||
|
||||
echo "⏳ Waiting for services to start..."
|
||||
sleep 45
|
||||
|
||||
echo "🔍 Checking service health..."
|
||||
HEALTHY_SERVICES=$(docker compose -f docker-compose.traefik.yml --env-file .env.traefik ps --filter "status=running" --format "table {{.Service}}\t{{.Status}}" | grep -c "Up" || true)
|
||||
|
||||
if [ "$HEALTHY_SERVICES" -ge 3 ]; then
|
||||
echo "✅ Deployment successful!"
|
||||
echo ""
|
||||
echo "🌐 Application URLs:"
|
||||
echo " 📱 Rezepte App: https://rezepte.fuerst-stuttgart.de"
|
||||
echo " 🗄️ phpMyAdmin: https://pma.fuerst-stuttgart.de"
|
||||
echo " ⚙️ Traefik Dashboard: https://traefik.fuerst-stuttgart.de"
|
||||
echo ""
|
||||
echo "📊 Service Status:"
|
||||
docker compose -f docker-compose.traefik.yml --env-file .env.traefik ps
|
||||
echo ""
|
||||
echo "🔐 Let's Encrypt certificates will be automatically generated."
|
||||
echo "🔄 Services are configured with auto-restart policies."
|
||||
else
|
||||
echo "❌ Deployment failed! Check logs:"
|
||||
echo ""
|
||||
echo "🐳 Container Status:"
|
||||
docker compose -f docker-compose.traefik.yml --env-file .env.traefik ps
|
||||
echo ""
|
||||
echo "📋 Recent logs:"
|
||||
docker compose -f docker-compose.traefik.yml --env-file .env.traefik logs --tail=20
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🎉 Production deployment completed successfully!"
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🚀 Deploying Rezepte Klaus with Traefik Proxy..."
|
||||
echo "🚀 Deploying Rezepte with Traefik Proxy..."
|
||||
|
||||
# Check if .env.production exists
|
||||
if [ ! -f .env.production ]; then
|
||||
@@ -71,8 +71,8 @@ if [ "$HEALTHY_SERVICES" -ge 6 ]; then
|
||||
docker compose -f docker compose.traefik.yml ps
|
||||
echo ""
|
||||
echo "🏷️ Image Information:"
|
||||
echo "Backend: ${BACKEND_IMAGE:-ghcr.io/your-username/rezepte-klaus-backend:latest}"
|
||||
echo "Frontend: ${FRONTEND_IMAGE:-ghcr.io/your-username/rezepte-klaus-frontend:latest}"
|
||||
echo "Backend: ${BACKEND_IMAGE:-ghcr.io/your-username/rezepte-backend:latest}"
|
||||
echo "Frontend: ${FRONTEND_IMAGE:-ghcr.io/your-username/rezepte-frontend:latest}"
|
||||
echo ""
|
||||
echo "🔒 SSL Certificates:"
|
||||
echo "Traefik will automatically request Let's Encrypt certificates."
|
||||
|
||||
@@ -4,7 +4,7 @@ services:
|
||||
container_name: rezepte-mysql-dev
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- MYSQL_DATABASE=rezepte_klaus
|
||||
- MYSQL_DATABASE=rezepte
|
||||
- MYSQL_USER=rezepte
|
||||
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
|
||||
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
|
||||
@@ -27,13 +27,13 @@ services:
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./nodejs-version/backend
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: rezepte-backend-dev
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- DATABASE_URL=mysql://rezepte:${MYSQL_PASSWORD}@mysql:3306/rezepte_klaus
|
||||
- DATABASE_URL=mysql://rezepte:${MYSQL_PASSWORD}@mysql:3306/rezepte
|
||||
- CORS_ORIGIN=${CORS_ORIGIN:-*}
|
||||
- PORT=3001
|
||||
ports:
|
||||
@@ -43,8 +43,8 @@ services:
|
||||
# Mount existing uploads from host for development
|
||||
- ./upload:/app/uploads:ro
|
||||
# Development: Mount source code for hot reload
|
||||
- ./nodejs-version/backend/src:/app/src:ro
|
||||
- ./nodejs-version/backend/prisma:/app/prisma:ro
|
||||
- ./backend/src:/app/src:ro
|
||||
- ./backend/prisma:/app/prisma:ro
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
@@ -58,7 +58,7 @@ services:
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./nodejs-version/frontend
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: rezepte-frontend-dev
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -5,7 +5,7 @@ services:
|
||||
container_name: rezepte-mysql
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: rootpassword
|
||||
MYSQL_DATABASE: rezepte_klaus
|
||||
MYSQL_DATABASE: rezepte
|
||||
MYSQL_USER: rezepte_user
|
||||
MYSQL_PASSWORD: rezepte_pass
|
||||
ports:
|
||||
@@ -24,13 +24,13 @@ services:
|
||||
# Backend API
|
||||
backend:
|
||||
build:
|
||||
context: ./nodejs-version/backend
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: rezepte-backend
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3001
|
||||
DATABASE_URL: mysql://rezepte_user:rezepte_pass@mysql:3306/rezepte_klaus
|
||||
DATABASE_URL: mysql://rezepte_user:rezepte_pass@mysql:3306/rezepte
|
||||
JWT_SECRET: your-super-secret-jwt-key-change-in-production
|
||||
UPLOAD_PATH: /app/uploads
|
||||
MAX_FILE_SIZE: 5242880
|
||||
@@ -56,7 +56,7 @@ services:
|
||||
# Frontend Application
|
||||
frontend:
|
||||
build:
|
||||
context: ./nodejs-version/frontend
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
# Use host IP instead of localhost for API calls
|
||||
|
||||
@@ -5,7 +5,7 @@ services:
|
||||
container_name: rezepte-mysql
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: rootpassword
|
||||
MYSQL_DATABASE: rezepte_klaus
|
||||
MYSQL_DATABASE: rezepte
|
||||
MYSQL_USER: rezepte_user
|
||||
MYSQL_PASSWORD: rezepte_pass
|
||||
ports:
|
||||
@@ -24,13 +24,13 @@ services:
|
||||
# Backend API
|
||||
backend:
|
||||
build:
|
||||
context: ./nodejs-version/backend
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: rezepte-backend
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3001
|
||||
DATABASE_URL: mysql://rezepte_user:rezepte_pass@mysql:3306/rezepte_klaus
|
||||
DATABASE_URL: mysql://rezepte_user:rezepte_pass@mysql:3306/rezepte
|
||||
JWT_SECRET: your-super-secret-jwt-key-change-in-production
|
||||
UPLOAD_PATH: /app/uploads
|
||||
MAX_FILE_SIZE: 5242880
|
||||
@@ -55,7 +55,7 @@ services:
|
||||
# Frontend Application
|
||||
frontend:
|
||||
build:
|
||||
context: ./nodejs-version/frontend
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_URL: http://localhost:3001/api
|
||||
@@ -87,7 +87,7 @@ services:
|
||||
- rezepte-network
|
||||
environment:
|
||||
DB_HOST: mysql
|
||||
DB_NAME: rezepte_klaus
|
||||
DB_NAME: rezepte
|
||||
DB_USER: rezepte_user
|
||||
DB_PASS: rezepte_pass
|
||||
profiles:
|
||||
|
||||
71
docker-compose.prod.yml
Normal file
71
docker-compose.prod.yml
Normal file
@@ -0,0 +1,71 @@
|
||||
services:
|
||||
mysql:
|
||||
# Production DB (no host port exposure by default)
|
||||
image: mysql:8.0
|
||||
container_name: rezepte-mysql-prod
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_DATABASE: rezepte
|
||||
MYSQL_USER: rezepte_user
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-change_this_password}
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-change_this_root_password}
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
# Optional initial import only for first deploy (remove afterwards)
|
||||
# - ./Rezepte.sql:/docker-entrypoint-initdb.d/01-rezepte.sql:ro
|
||||
# - ./ingredients.sql:/docker-entrypoint-initdb.d/02-ingredients.sql:ro
|
||||
# - ./Zubereitung.sql:/docker-entrypoint-initdb.d/03-zubereitung.sql:ro
|
||||
# - ./rezepte_bilder.sql:/docker-entrypoint-initdb.d/04-bilder.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||
timeout: 20s
|
||||
retries: 10
|
||||
networks:
|
||||
- rezepte-network
|
||||
|
||||
backend:
|
||||
image: ${BACKEND_IMAGE:-ghcr.io/your-username/rezepte-backend:latest}
|
||||
container_name: rezepte-backend-prod
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: mysql://rezepte_user:${MYSQL_PASSWORD:-change_this_password}@mysql:3306/rezepte
|
||||
JWT_SECRET: ${JWT_SECRET:-change_this_jwt_secret_min_32_characters}
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:-https://rezepte.${DOMAIN}}
|
||||
PORT: 3001
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3001/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- rezepte-network
|
||||
|
||||
frontend:
|
||||
image: ${FRONTEND_IMAGE:-ghcr.io/your-username/rezepte-frontend:latest}
|
||||
container_name: rezepte-frontend-prod
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
networks:
|
||||
- rezepte-network
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
driver: local
|
||||
uploads_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
rezepte-network:
|
||||
driver: bridge
|
||||
@@ -4,7 +4,7 @@ services:
|
||||
container_name: rezepte-mysql-prod
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_DATABASE: rezepte_klaus
|
||||
MYSQL_DATABASE: rezepte
|
||||
MYSQL_USER: rezepte_user
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-change_this_password}
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-change_this_root_password}
|
||||
@@ -26,13 +26,13 @@ services:
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./nodejs-version/backend
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: rezepte-backend-prod
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=mysql://rezepte_user:${MYSQL_PASSWORD:-change_this_password}@mysql:3306/rezepte_klaus
|
||||
- DATABASE_URL=mysql://rezepte_user:${MYSQL_PASSWORD:-change_this_password}@mysql:3306/rezepte
|
||||
- JWT_SECRET=${JWT_SECRET:-change_this_jwt_secret_min_32_characters}
|
||||
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost}
|
||||
- PORT=3001
|
||||
@@ -55,7 +55,7 @@ services:
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./nodejs-version/frontend
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- VITE_API_BASE_URL=${API_BASE_URL:-http://localhost:3001/api}
|
||||
|
||||
7
docker-compose.registry.override.yml
Normal file
7
docker-compose.registry.override.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
backend:
|
||||
image: ${BACKEND_IMAGE:-ghcr.io/your-username/rezepte-backend:latest}
|
||||
build: null
|
||||
frontend:
|
||||
image: ${FRONTEND_IMAGE:-ghcr.io/your-username/rezepte-frontend:latest}
|
||||
build: null
|
||||
@@ -4,7 +4,7 @@ services:
|
||||
container_name: rezepte-mysql-prod
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_DATABASE: rezepte_klaus
|
||||
MYSQL_DATABASE: rezepte
|
||||
MYSQL_USER: rezepte_user
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-change_this_password}
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-change_this_root_password}
|
||||
@@ -24,12 +24,12 @@ services:
|
||||
|
||||
backend:
|
||||
# Use pre-built image from registry instead of building
|
||||
image: ${BACKEND_IMAGE:-ghcr.io/your-username/rezepte-klaus-backend:latest}
|
||||
image: ${BACKEND_IMAGE:-ghcr.io/your-username/rezepte-backend:latest}
|
||||
container_name: rezepte-backend-prod
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=mysql://rezepte_user:${MYSQL_PASSWORD:-change_this_password}@mysql:3306/rezepte_klaus
|
||||
- DATABASE_URL=mysql://rezepte_user:${MYSQL_PASSWORD:-change_this_password}@mysql:3306/rezepte
|
||||
- JWT_SECRET=${JWT_SECRET:-change_this_jwt_secret_min_32_characters}
|
||||
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost}
|
||||
- PORT=3001
|
||||
@@ -52,7 +52,7 @@ services:
|
||||
|
||||
frontend:
|
||||
# Use pre-built image from registry instead of building
|
||||
image: ${FRONTEND_IMAGE:-ghcr.io/your-username/rezepte-klaus-frontend:latest}
|
||||
image: ${FRONTEND_IMAGE:-ghcr.io/your-username/rezepte-frontend:latest}
|
||||
container_name: rezepte-frontend-prod
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
@@ -47,12 +47,12 @@ services:
|
||||
|
||||
backend:
|
||||
# Use pre-built image from registry instead of building
|
||||
image: ${BACKEND_IMAGE:-ghcr.io/your-username/rezepte-klaus-backend:latest}
|
||||
image: ${BACKEND_IMAGE:-ghcr.io/your-username/rezepte-backend:latest}
|
||||
container_name: rezepte-backend-prod
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=mysql://rezepte_user:${MYSQL_REZEPTE_PASSWORD}@${MYSQL_HOST:-mysql}:${MYSQL_PORT:-3306}/rezepte_klaus
|
||||
- DATABASE_URL=mysql://rezepte_user:${MYSQL_REZEPTE_PASSWORD}@${MYSQL_HOST:-mysql}:${MYSQL_PORT:-3306}/rezepte
|
||||
- CORS_ORIGIN=https://rezepte.${DOMAIN}
|
||||
- PORT=3001
|
||||
volumes:
|
||||
@@ -83,7 +83,7 @@ services:
|
||||
|
||||
frontend:
|
||||
# Use pre-built image from registry instead of building
|
||||
image: ${FRONTEND_IMAGE:-ghcr.io/your-username/rezepte-klaus-frontend:latest}
|
||||
image: ${FRONTEND_IMAGE:-ghcr.io/your-username/rezepte-frontend:latest}
|
||||
container_name: rezepte-frontend-prod
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
@@ -164,10 +164,3 @@ volumes:
|
||||
portainer_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
traefik-network:
|
||||
driver: bridge
|
||||
# Reference to external network (will be created by Gitea)
|
||||
# This network should already exist from your Gitea installation
|
||||
gitea_default:
|
||||
external: true
|
||||
79
docker-compose.traefik.override.yml
Normal file
79
docker-compose.traefik.override.yml
Normal file
@@ -0,0 +1,79 @@
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.0
|
||||
container_name: traefik
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- --api.dashboard=true
|
||||
- --api.insecure=false
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}
|
||||
- --certificatesresolvers.letsencrypt.acme.storage=/acme.json
|
||||
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
|
||||
- --log.level=INFO
|
||||
- --accesslog=true
|
||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
||||
- --entrypoints.web.http.redirections.entrypoint.permanent=true
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- traefik_acme:/acme.json
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.traefik.rule=Host(`traefik.${DOMAIN}`)"
|
||||
- "traefik.http.routers.traefik.entrypoints=websecure"
|
||||
- "traefik.http.routers.traefik.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.traefik.service=api@internal"
|
||||
- "traefik.http.routers.traefik.middlewares=auth"
|
||||
# Basic Auth Beispiel (unbedingt Hash anpassen)
|
||||
- "traefik.http.middlewares.auth.basicauth.users=admin:$$2y$$10$$8eO9J8Ef.LswB5K4l1.ZJ.qZBOa6ZXJ3X2y3zCZLCr9zHVJ8vJ2Ga"
|
||||
networks:
|
||||
- traefik-network
|
||||
- rezepte-network
|
||||
|
||||
backend:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.backend.rule=Host(`rezepte.${DOMAIN}`) && PathPrefix(`/api`)"
|
||||
- "traefik.http.routers.backend.entrypoints=websecure"
|
||||
- "traefik.http.routers.backend.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.backend.loadbalancer.server.port=3001"
|
||||
- "traefik.http.routers.backend-uploads.rule=Host(`rezepte.${DOMAIN}`) && PathPrefix(`/uploads`)"
|
||||
- "traefik.http.routers.backend-uploads.entrypoints=websecure"
|
||||
- "traefik.http.routers.backend-uploads.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.backend-uploads.service=backend"
|
||||
- "traefik.http.routers.backend.priority=10"
|
||||
- "traefik.http.routers.backend-uploads.priority=10"
|
||||
|
||||
frontend:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.frontend.rule=Host(`rezepte.${DOMAIN}`)"
|
||||
- "traefik.http.routers.frontend.entrypoints=websecure"
|
||||
- "traefik.http.routers.frontend.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.frontend.loadbalancer.server.port=80"
|
||||
- "traefik.http.routers.frontend.priority=1"
|
||||
|
||||
phpmyadmin:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.phpmyadmin.rule=Host(`phpmyadmin.${DOMAIN}`)"
|
||||
- "traefik.http.routers.phpmyadmin.entrypoints=websecure"
|
||||
- "traefik.http.routers.phpmyadmin.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.phpmyadmin.loadbalancer.server.port=80"
|
||||
|
||||
volumes:
|
||||
traefik_acme:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
traefik-network:
|
||||
driver: bridge
|
||||
rezepte-network:
|
||||
external: false
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user