Compare commits

..

11 Commits

149 changed files with 2404 additions and 1949 deletions

64
.env
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View File

@@ -1,3 +1,3 @@
JS/node_modules/
nodejs-version/backend/node_modules
nodejs-version/frontend/node_modules
backend/node_modules
frontend/node_modules

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22.12.0

1
.tool-versions Normal file
View File

@@ -0,0 +1 @@
node 22.12.0

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View File

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

View File

@@ -1,11 +0,0 @@
{
"name": "observatory-sim",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2"
}
}

View File

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

View File

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

View File

@@ -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
View 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` (150) 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.

View File

@@ -1,4 +1,4 @@
# phpMyAdmin Integration - Rezepte Klaus
# phpMyAdmin Integration - Rezepte
## 🗄️ Datenbank-Verwaltung über Web-Interface

View File

@@ -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
View File

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

View File

@@ -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:**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -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',

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -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": {

View File

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

View File

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

View File

@@ -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',
});

View File

@@ -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: {

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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'] ?? '';

View File

@@ -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'] ?? '';

View File

@@ -6,7 +6,7 @@ if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$host_name = 'localhost';
$database = 'rezepte_klaus';
$database = 'rezepte';
$user_name = 'root';
$password = '';

View File

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

View File

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

View File

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

View File

@@ -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
View 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!"

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View 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