diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..2fc6a64
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,97 @@
+# =============================================================================
+# Formeleon - Docker Ignore File
+# Author: Björn Runden
+# Description: Specifies files and directories to exclude from Docker build context.
+# =============================================================================
+
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# Virtual environments
+venv/
+ENV/
+env/
+.venv/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+
+# Testing
+.tox/
+.coverage
+.coverage.*
+htmlcov/
+.pytest_cache/
+.mypy_cache/
+.hypothesis/
+
+# Logs
+*.log
+logs/
+.logs/
+
+# Database files (persistent, handled by volumes)
+*.db
+*.sqlite
+*.sqlite3
+formeleon.db
+
+# Local instance files
+#instance/
+
+# Docker
+Dockerfile
+.dockerignore
+docker-compose*.yml
+
+# Git
+.git/
+.gitignore
+.gitmodules
+
+# Documentation
+docs/
+*.md
+
+# Build artifacts
+*.tar.gz
+*.zip
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Local development files
+.env
+.env.local
+.env.*.local
+
+# Data directories
+data/
+node_modules/
+
+# Note: static/, templates/, migrations/ are handled by volumes in development
+# but need to be copied in Dockerfile for production builds
diff --git a/.env.example b/.env.example
index 6d79779..80b163c 100644
--- a/.env.example
+++ b/.env.example
@@ -30,7 +30,7 @@ DEV_MODE=true
# =============================================================================
# SQLite (default for development)
-# SQLALCHEMY_DATABASE_URI=sqlite:///formeleon.db
+# SQLALCHEMY_DATABASE_URI=sqlite:///instance/formeleon.db
# PostgreSQL
# SQLALCHEMY_DATABASE_URI=postgresql://username:password@localhost/formeleon
diff --git a/Dockerfile b/Dockerfile
index daf5a10..01ba290 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,52 +2,77 @@
# Formeleon - Multi-tenant Form Generator
# Author: Björn Runden
# File: Dockerfile
-# Description: Docker container configuration for Formeleon application.
+# Description: Multi-stage Docker build for development and production.
# =============================================================================
-# Use official Python runtime as parent image
-# Use official Python 3.13 runtime as parent image
-FROM python:3.13-slim
+# =============================================================================
+# Builder Stage: Install dependencies in a virtual environment
+# =============================================================================
+FROM python:3.13-slim as builder
-# Set environment variables
+# Set environment variables for builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
- FLASK_ENV=production \
- FLASK_APP=app.py
+ PIP_NO_CACHE_DIR=1 \
+ PIP_DISABLE_PIP_VERSION_CHECK=1
-# Set working directory
-WORKDIR /app
-
-# Install system dependencies (einschließlich curl für Healthcheck)
+# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
- curl \
&& rm -rf /var/lib/apt/lists/*
-# Copy requirements file first to leverage Docker cache
-COPY requirements.txt .
+WORKDIR /app
-# Install Python dependencies
-RUN pip install --no-cache-dir --upgrade pip && \
- pip install --no-cache-dir -r requirements.txt
+# Create and activate virtual environment
+RUN python -m venv /opt/venv
+ENV PATH="/opt/venv/bin:$PATH"
+
+# Install python dependencies
+COPY requirements.txt .
+RUN pip install --upgrade pip && \
+ pip install -r requirements.txt
+
+# =============================================================================
+# Final Stage: Lean runtime image
+# =============================================================================
+FROM python:3.13-slim
+
+# Set environment variables for runtime
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ FLASK_APP=app.py \
+ PATH="/opt/venv/bin:$PATH"
+
+# Install required runtime system dependencies (curl for healthcheck)
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ curl \
+ libpq5 \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+# Copy the virtual environment from builder stage
+COPY --from=builder /opt/venv /opt/venv
+
+# Copy entrypoint script and ensure it's executable
+COPY entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
# Copy application code
COPY . .
-# Create uploads directory
+# Create necessary runtime directories
RUN mkdir -p /tmp/formeleon_uploads && \
+ mkdir -p /app/data && \
+ mkdir -p /app/logs && \
touch /tmp/formeleon_uploads/.gitkeep
-# Expose port
EXPOSE 5000
# Health check
HEALTHCHECK --interval=30s --timeout=3s \
- CMD curl -f http://localhost:5000/api/v1/status || exit 1
+ CMD curl -f http://127.0.0.1:5000/ || exit 1
-# Run the application with gthread workers for high concurrency
-CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--threads", "2", "--worker-class", "gthread", "app:app"]
-
-# For development (uncomment and use docker-compose)
-# CMD ["flask", "run", "--host=0.0.0.0", "--port=5000"]
+ENTRYPOINT ["/entrypoint.sh"]
+CMD ["server"]
diff --git a/README.md b/README.md
index ea69f1a..4d0891a 100644
--- a/README.md
+++ b/README.md
@@ -21,27 +21,31 @@ Formeleon ist ein zentral gehosteter Service (ChamäleonCloud), der es Webseiten
## 🛠️ Tech-Stack
-* **Backend:** Python 3.12, Flask (Blueprint-Architektur), Flask-SQLAlchemy, Flask-Migrate (Alembic)
+* **Backend:** Python 3.13, Flask (Blueprint-Architektur), Flask-SQLAlchemy, Flask-Migrate (Alembic)
* **Frontend (Admin-Panel):** Bootstrap 5, Mithril.js / HTML5
* **Authentication:** OIDC via Zitadel (Admin-Panel)
* **Deployment:** Docker, Gunicorn, Gitea Actions (Runner)
---
-## 🚀 Schnellstart (Dev-Mode)
+## 🚀 Schnellstart (Lokal / Docker-less)
+
+Das Projekt kann auf Windows/Linux komplett ohne Docker in einer Virtual Environment entwickelt werden:
1. **Repository klonen & Umgebung einrichten:**
```bash
- python3 -m venv venv
+ python -m venv venv
+
+ # Auf Linux/Mac:
source venv/bin/activate
+ # Auf Windows:
+ venv\Scripts\activate
+
pip install -r requirements.txt
```
-2. **Umgebungsvariablen konfigurieren:**
- ```bash
- cp .env.example .env
- # DEV_MODE=true ist standardmäßig aktiviert
- ```
+2. **Umgebungsvariablen (optional):**
+ *(Formeleon startet standardmäßig im Dev-Mode mit einer lokalen SQLite-DB `formeleon.db`)*
3. **Datenbank initialisieren & starten:**
```bash
@@ -49,18 +53,57 @@ Formeleon ist ein zentral gehosteter Service (ChamäleonCloud), der es Webseiten
flask db init
# Datenbank-Schema erstellen
- flask db migrate -m "initial migration"
flask db upgrade
# Optionales Seeding mit Testdaten
flask seed-db
- # Anwendung starten
- flask run --port=5000
+ # Anwendung starten (auf Port 5000 mit Hot-Reload)
+ flask run --port=5000 --debug
```
---
+## 🐳 Schnellstart (Docker)
+
+Für das vollständige Linux- / Staging-Gefühl kannst du Docker nutzen. Durch Volume-Mounts bleibt der Code synchron, und du kannst im Editor (Windows/Mac) programmieren.
+
+```bash
+# 1. Image bauen und Container starten
+docker-compose up --build
+
+# 2. Anwendung beenden
+# (Strg+C oder:)
+docker-compose down
+
+# 3. Datenbank und Volumes komplett zurücksetzen
+docker-compose down -v && docker-compose up --build
+```
+
+### Docker CLI-Kommandos ausführen
+Du kannst `flask` Kommandos direkt gegen den laufenden Container feuern:
+
+```bash
+# Datenbank Migrationen anlegen
+docker-compose run --rm app flask db migrate -m "neue migration"
+
+# Datenbank-Schema updaten
+docker-compose run --rm app flask db upgrade
+
+# Datenbank mit Testdaten füllen
+docker-compose run --rm app flask seed-db
+
+# Shell im Container öffnen
+docker-compose run --rm app bash
+```
+
+### Zugriff auf die Anwendung
+- **Admin Dashboard:** http://localhost:5000/admin/
+- **API Endpunkt:** http://localhost:5000/api/v1/
+- **Health Check:** http://localhost:5000/
+
+---
+
## 📚 Dokumentation
* [`AGENTS.md`](./AGENTS.md) – Regelwerk und Anweisungen für KI-Coding-Agenten.
diff --git a/config.py.orig b/config.py.orig
new file mode 100644
index 0000000..f52d49c
--- /dev/null
+++ b/config.py.orig
@@ -0,0 +1,178 @@
+# ==============================================================================
+# Formeleon - Multi-tenant Form Generator
+# Author: Björn Runden
+# Module: config.py
+# Description: Application configuration with environment variable support.
+# Handles development mode, reCAPTCHA, Zitadel OIDC, and database settings.
+# ==============================================================================
+
+"""
+Application configuration module for Formeleon.
+
+This module centralizes all configuration settings for the Flask application,
+loading from environment variables with sensible defaults for development.
+
+Environment Variables:
+ - DEV_MODE: Enable development mode (default: False)
+ - SECRET_KEY: Flask secret key for session management
+ - SQLALCHEMY_DATABASE_URI: Database connection string
+ - SQLALCHEMY_TRACK_MODIFICATIONS: Track modifications (default: False)
+
+ - ZITADEL_ISSUER: OIDC issuer URL for Zitadel
+ - ZITADEL_CLIENT_ID: OIDC client ID
+ - ZITADEL_CLIENT_SECRET: OIDC client secret
+ - ZITADEL_REDIRECT_URI: OIDC redirect URI
+
+ - RECAPTCHA_SECRET_KEY: reCAPTCHA v3 secret key
+ - RECAPTCHA_SITE_KEY: reCAPTCHA v3 site key
+ - RECAPTCHA_THRESHOLD: Minimum score threshold (default: 0.5)
+ - MOCK_RECAPTCHA_SCORE: Mock score for dev mode (default: 0.9)
+
+ - DEV_USER_ROLE: Role for dev mode user (default: 'admin')
+ - DEV_TENANT_ID: Tenant ID for dev mode (default: 'tenant_dev_123')
+ - DEV_USER_NAME: Username for dev mode (default: 'Dev Admin')
+ - DEV_USER_EMAIL: Email for dev mode (default: 'dev@formeleon.local')
+"""
+
+import os
+from typing import Optional
+
+
+BASE_DIR = os.path.abspath(os.path.dirname(__file__))
+
+class Config:
+ """
+ Base configuration class with default values.
+
+ All configuration values can be overridden via environment variables.
+ """
+
+ # Flask Core Settings
+ SECRET_KEY: str = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
+ DEBUG: bool = os.environ.get('FLASK_DEBUG', 'false').lower() == 'true'
+ TESTING: bool = os.environ.get('FLASK_TESTING', 'false').lower() == 'true'
+
+ # Development Mode
+ DEV_MODE: bool = os.environ.get('DEV_MODE', 'false').lower() == 'true'
+
+ # Dev User Settings
+ DEV_USER_ROLE: str = os.environ.get('DEV_USER_ROLE', 'admin')
+ DEV_TENANT_ID: str = os.environ.get('DEV_TENANT_ID', 'tenant_dev_123')
+ DEV_USER_NAME: str = os.environ.get('DEV_USER_NAME', 'Dev Admin')
+ DEV_USER_EMAIL: str = os.environ.get('DEV_USER_EMAIL', 'dev@formeleon.local')
+
+ # Database Configuration
+ SQLALCHEMY_DATABASE_URI: str = os.environ.get(
+ 'SQLALCHEMY_DATABASE_URI',
+ f'sqlite:///{os.path.join(BASE_DIR, "instance", "formeleon.db")}'
+ )
+ SQLALCHEMY_TRACK_MODIFICATIONS: bool = False
+ SQLALCHEMY_ECHO: bool = os.environ.get('SQLALCHEMY_ECHO', 'false').lower() == 'true'
+
+ # Zitadel OIDC Configuration
+ ZITADEL_ISSUER: Optional[str] = os.environ.get('ZITADEL_ISSUER')
+ ZITADEL_CLIENT_ID: Optional[str] = os.environ.get('ZITADEL_CLIENT_ID')
+ ZITADEL_CLIENT_SECRET: Optional[str] = os.environ.get('ZITADEL_CLIENT_SECRET')
+ ZITADEL_REDIRECT_URI: Optional[str] = os.environ.get(
+ 'ZITADEL_REDIRECT_URI',
+ 'http://localhost:5000/auth/callback'
+ )
+ ZITADEL_SCOPES: list = ['openid', 'profile', 'email']
+
+ # reCAPTCHA v3 Configuration
+ RECAPTCHA_SECRET_KEY: Optional[str] = os.environ.get('RECAPTCHA_SECRET_KEY')
+ RECAPTCHA_SITE_KEY: Optional[str] = os.environ.get('RECAPTCHA_SITE_KEY')
+ RECAPTCHA_THRESHOLD: float = float(os.environ.get('RECAPTCHA_THRESHOLD', '0.5'))
+ MOCK_RECAPTCHA_SCORE: float = float(os.environ.get('MOCK_RECAPTCHA_SCORE', '0.9'))
+ RECAPTCHA_VERIFY_URL: str = 'https://www.google.com/recaptcha/api/siteverify'
+
+ # Application Settings
+ DEFAULT_PAGE_SIZE: int = 20
+ MAX_PAGE_SIZE: int = 100
+
+ # Form Submission Settings
+ ALLOWED_ORIGINS: list = os.environ.get('ALLOWED_ORIGINS', '*').split(',')
+ ENABLE_CORS: bool = os.environ.get('ENABLE_CORS', 'true').lower() == 'true'
+
+ # File Upload Settings
+ MAX_CONTENT_LENGTH: int = 16 * 1024 * 1024 # 16MB
+ UPLOAD_FOLDER: str = os.environ.get('UPLOAD_FOLDER', '/tmp/formeleon_uploads')
+ ALLOWED_EXTENSIONS: set = {'png', 'jpg', 'jpeg', 'gif', 'pdf', 'txt'}
+
+ # Session Configuration
+ SESSION_COOKIE_SECURE: bool = os.environ.get('SESSION_COOKIE_SECURE', 'false').lower() == 'true'
+ SESSION_COOKIE_HTTPONLY: bool = True
+ SESSION_COOKIE_SAMESITE: str = os.environ.get('SESSION_COOKIE_SAMESITE', 'Lax')
+ PERMANENT_SESSION_LIFETIME: int = 3600 # 1 hour in seconds
+
+ def is_dev_mode(self) -> bool:
+ """
+ Check if application is running in development mode.
+
+ :return: True if DEV_MODE is enabled, False otherwise.
+ """
+ return self.DEV_MODE
+
+ def is_recaptcha_enabled(self) -> bool:
+ """
+ Check if reCAPTCHA is properly configured.
+
+ :return: True if both secret and site keys are configured, False otherwise.
+ """
+ return (self.RECAPTCHA_SECRET_KEY is not None and
+ self.RECAPTCHA_SITE_KEY is not None)
+
+ def is_zitadel_enabled(self) -> bool:
+ """
+ Check if Zitadel OIDC is properly configured.
+
+ :return: True if all required Zitadel settings are present, False otherwise.
+ """
+ return (self.ZITADEL_ISSUER is not None and
+ self.ZITADEL_CLIENT_ID is not None and
+ self.ZITADEL_CLIENT_SECRET is not None)
+
+
+class DevelopmentConfig(Config):
+ """
+ Development-specific configuration.
+
+ Overrides base configuration with development-friendly settings.
+ """
+ DEBUG: bool = True
+ DEV_MODE: bool = True
+ SQLALCHEMY_ECHO: bool = True
+ SESSION_COOKIE_SECURE: bool = False
+
+
+class ProductionConfig(Config):
+ """
+ Production-specific configuration.
+
+ Overrides base configuration with production-safe settings.
+ """
+ DEBUG: bool = False
+ DEV_MODE: bool = False
+ SQLALCHEMY_ECHO: bool = False
+ SESSION_COOKIE_SECURE: bool = True
+ SESSION_COOKIE_SAMESITE: str = 'Strict'
+
+
+class TestingConfig(Config):
+ """
+ Testing-specific configuration.
+
+ Overrides base configuration with test-friendly settings.
+ """
+ TESTING: bool = True
+ SQLALCHEMY_DATABASE_URI: str = 'sqlite:///:memory:'
+ WTF_CSRF_ENABLED: bool = False
+
+
+# Configuration mapping for easy access
+config_map: dict = {
+ 'development': DevelopmentConfig,
+ 'production': ProductionConfig,
+ 'testing': TestingConfig,
+ 'default': DevelopmentConfig if os.environ.get('FLASK_ENV', 'development') == 'development' else ProductionConfig
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index 33aa573..2785942 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,14 +1,4 @@
-# =============================================================================
-# Formeleon - Multi-tenant Form Generator
-# Author: Björn Runden
-# File: docker-compose.yml
-# Description: Docker Compose configuration for local development.
-# =============================================================================
-
-version: '3.8'
-
services:
- # Formeleon Application
app:
build:
context: .
@@ -17,67 +7,14 @@ services:
ports:
- "5000:5000"
volumes:
- - .:/app
- - ./formeleon.db:/app/formeleon.db
+ # Mount entire project directory for hot-reload in development
+ # Exclude the virtual environment if it exists locally to prevent conflicts with container venv
+ - ./:/app
+ - /app/venv
+ # Persistent data volumes mapping to host
+# - ./data:/app/data
- ./logs:/app/logs
- - /tmp/formeleon_uploads:/tmp/formeleon_uploads
- environment:
- - FLASK_ENV=development
- - FLASK_DEBUG=1
- - DEV_MODE=true
- - SECRET_KEY=dev-secret-key-change-in-production
- - SQLALCHEMY_DATABASE_URI=sqlite:///formeleon.db
- - ZITADEL_ISSUER=
- - ZITADEL_CLIENT_ID=
- - ZITADEL_CLIENT_SECRET=
- - RECAPTCHA_SITE_KEY=
- - RECAPTCHA_SECRET_KEY=
- - RECAPTCHA_THRESHOLD=0.5
- - MOCK_RECAPTCHA_SCORE=0.9
- - DEV_USER_ROLE=admin
- - DEV_TENANT_ID=tenant_dev_123
- - DEV_USER_NAME=Dev Admin
- - DEV_USER_EMAIL=dev@formeleon.local
- - ENABLE_CORS=true
- - ALLOWED_ORIGINS=*
- command: flask run --host=0.0.0.0 --port=5000
+# - ./formeleon.db:/app/formeleon.db
+ env_file:
+ - .env
restart: unless-stopped
- networks:
- - formeleon-network
-
- # PostgreSQL Database (optional, for production-like development)
- # Uncomment to use PostgreSQL instead of SQLite
- # postgres:
- # image: postgres:15-alpine
- # container_name: formeleon-postgres
- # environment:
- # - POSTGRES_DB=formeleon
- # - POSTGRES_USER=formeleon
- # - POSTGRES_PASSWORD=formeleon
- # volumes:
- # - postgres_data:/var/lib/postgresql/data
- # ports:
- # - "5432:5432"
- # restart: unless-stopped
- # networks:
- # - formeleon-network
-
- # Redis for Session Storage (optional)
- # redis:
- # image: redis:7-alpine
- # container_name: formeleon-redis
- # ports:
- # - "6379:6379"
- # volumes:
- # - redis_data:/data
- # restart: unless-stopped
- # networks:
- # - formeleon-network
-
-networks:
- formeleon-network:
- driver: bridge
-
-# volumes:
-# postgres_data:
-# redis_data:
diff --git a/docs/deployment.md b/docs/deployment.md
index 27abcf8..f6ff2ca 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -1,6 +1,181 @@
# Deployment & CI/CD Guide
-## 1. Gitea Actions Workflow
+## 1. Docker Development Cycle
+
+Der empfohlene Entwicklungs-Workflow nutzt Docker Compose für eine isolierte Umgebung mit Hot-Reload.
+
+### Voraussetzungen
+- Docker (v20.10+)
+- Docker Compose (v2.0+)
+
+### Ersts Start (Build & Initialize)
+```bash
+# Navigiere zum Projektverzeichnis
+cd formeleon
+
+# Build Images und Start Container
+docker-compose up --build
+```
+
+Das Entrypoint-Script führt automatisch folgende Schritte aus:
+1. Prüft Python-Abhängigkeiten
+2. Führt Datenbank-Migrationen aus (`flask db upgrade`)
+3. Seeded die Datenbank mit Testdaten (`flask seed-db`) - nur bei leerer DB
+4. Startet den Flask-Entwicklungsserver mit Hot-Reload
+
+### Täglicher Entwicklungs-Workflow
+
+#### Container starten/stoppen
+```bash
+# Starten (im Hintergrund)
+docker-compose up -d
+
+# Stoppen
+docker-compose down
+
+# Neu starten (nach Code-Änderungen)
+docker-compose restart app
+
+# Logs anzeigen
+docker-compose logs -f app
+```
+
+#### Code-Änderungen testen
+Dank der Volume-Mounts in `docker-compose.yml` werden Änderungen an folgenden Verzeichnissen sofort im Container sichtbar:
+- `app.py`, `config.py`, `models.py`
+- `routes/`
+- `services/`
+- `utils/`
+- `templates/`
+- `static/`
+- `migrations/`
+
+**Kein Container-Rebuild nötig!** Der Flask-Dev-Server erkennt Änderungen und lädt neu.
+
+### Flask CLI-Kommandos in Docker
+
+Führe Flask-Kommandos im laufenden Container aus:
+
+```bash
+# Allgemeine Syntax
+docker-compose run --rm app flask
+
+# Beispiele:
+# Migration erstellen
+docker-compose run --rm app flask db migrate -m "add user table"
+
+# Migration anwenden
+docker-compose run --rm app flask db upgrade
+
+# Migration zurücksetzen
+docker-compose run --rm app flask db downgrade
+
+# Datenbank mit Testdaten füllen
+docker-compose run --rm app flask seed-db
+
+# Mandanten verwalten
+docker-compose run --rm app flask create-tenant "Test" "public-key-123"
+
+# Benutzer verwalten
+docker-compose run --rm app flask create-user "test@example.com" "Test User" --role=admin
+```
+
+### Shell im Container
+
+```bash
+# Interaktive Shell
+docker-compose exec app bash
+
+# Python Shell
+docker-compose exec app python
+
+# Flask Shell (mit App-Kontext)
+docker-compose exec app flask shell
+```
+
+### Datenbank zurücksetzen
+
+```bash
+# Container stoppen und Volumes löschen (ACHTUNG: Alle Daten gehen verloren!)
+docker-compose down -v
+
+# Neu starten mit frischer Datenbank
+docker-compose up --build
+```
+
+---
+
+## 2. Docker Setup für Produktion
+
+Für die Produktion sollte ein separates `docker-compose.prod.yml` erstellt werden.
+
+### `docker-compose.prod.yml` (Beispiel)
+```yaml
+version: '3.8'
+
+services:
+ web:
+ build: .
+ container_name: formeleon-web
+ ports:
+ - "8000:5000"
+ environment:
+ - FLASK_ENV=production
+ - FLASK_DEBUG=0
+ - DEV_MODE=false
+ - SECRET_KEY=${SECRET_KEY}
+ - SQLALCHEMY_DATABASE_URI=${DATABASE_URL}
+ - ZITADEL_ISSUER=${ZITADEL_ISSUER}
+ - ZITADEL_CLIENT_ID=${ZITADEL_CLIENT_ID}
+ - ZITADEL_CLIENT_SECRET=${ZITADEL_CLIENT_SECRET}
+ - RECAPTCHA_SITE_KEY=${RECAPTCHA_SITE_KEY}
+ - RECAPTCHA_SECRET_KEY=${RECAPTCHA_SECRET_KEY}
+ volumes:
+ - ./data:/app/data
+ restart: always
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:5000/"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+
+ postgres:
+ image: postgres:15-alpine
+ container_name: formeleon-postgres
+ environment:
+ - POSTGRES_DB=formeleon
+ - POSTGRES_USER=formeleon
+ - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ ports:
+ - "5432:5432"
+ restart: always
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U formeleon"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+
+volumes:
+ postgres_data:
+```
+
+### Produktion starten
+```bash
+# Mit kombinierter Konfiguration
+docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
+
+# Logs anzeigen
+docker-compose logs -f
+
+# Status prüfen
+docker-compose ps
+```
+
+---
+
+## 3. Gitea Actions Workflow
Bei einem Push auf den `main`-Branch oder einem Release-Tag führt der Gitea-Runner automatisiert folgende Schritte auf dem Zielserver aus:
@@ -8,27 +183,70 @@ Bei einem Push auf den `main`-Branch oder einem Release-Tag führt der Gitea-Run
2. Erstellen/Aktualisieren der Docker-Images.
3. Zero-Downtime Restart der Container via Docker Compose.
+### Beispiel `.gitea/workflows/deploy.yml`
+```yaml
+name: Deploy to Production
+on:
+ push:
+ branches: [main]
+ tags: ['v*']
+
+jobs:
+ deploy:
+ runs-on: self-hosted
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Stop services
+ run: docker-compose -f docker-compose.yml -f docker-compose.prod.yml down
+
+ - name: Pull latest images
+ run: docker-compose -f docker-compose.yml -f docker-compose.prod.yml pull
+
+ - name: Build and start
+ run: docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
+
+ - name: Run migrations
+ run: docker-compose -f docker-compose.yml -f docker-compose.prod.yml run --rm web flask db upgrade
+
+ - name: Cleanup
+ run: docker system prune -f
+```
+
---
-## 2. Docker Setup
+## 4. Docker Troubleshooting
-Das System läuft in einem schlanken Gunicorn-Container hinter einem Reverse Proxy.
+### Häufige Probleme & Lösungen
-### `docker-compose.yml` (Auszug)
-```yaml
-version: '3.8'
+| Problem | Lösung |
+|---------|--------|
+| Port 5000 bereits belegt | `docker-compose down` und neu starten |
+| Datenbank-Fehler | `docker-compose down -v && docker-compose up --build` |
+| Python-Module nicht gefunden | `docker-compose build --no-cache && docker-compose up` |
+| Container startet nicht | `docker-compose logs app` prüfen |
+| Hot-Reload funktioniert nicht | Volume-Mounts in docker-compose.yml prüfen |
-services:
- web:
- build: .
- restart: always
- env_file:
- - .env
- ports:
- - "8000:8000"
- volumes:
- - sqlite_data:/app/data
+### Debug-Modus
-volumes:
- sqlite_data:
+```bash
+# Container im Debug-Modus starten
+docker-compose up --build --abort-on-container-exit
+
+# Oder mit Shell
+ENTERPOINT_OVERRIDE=bash docker-compose run --rm --service-ports app
+```
+
+### Cache leeren
+
+```bash
+# Docker Cache leeren
+docker builder prune
+
+# Unused Images entfernen
+docker image prune -a
+
+# System cleanup
+docker system prune -a --volumes
```
diff --git a/entrypoint.sh b/entrypoint.sh
new file mode 100755
index 0000000..3d2da30
--- /dev/null
+++ b/entrypoint.sh
@@ -0,0 +1,54 @@
+#!/bin/bash
+# =============================================================================
+# Formeleon - Entrypoint Script
+# Author: Björn Runden
+# Description: Entrypoint script for Docker containers.
+# =============================================================================
+
+set -e
+
+APP_NAME="formeleon"
+
+log_info() {
+ echo "[$(date +'%Y-%m-%d %H:%M:%S')] [INFO] [${APP_NAME}] $1"
+}
+
+log_error() {
+ echo "[$(date +'%Y-%m-%d %H:%M:%S')] [ERROR] [${APP_NAME}] $1" >&2
+}
+
+# If the first argument is a known flask or system command, run it directly
+if [ "$1" = "flask" ] || [ "$1" = "bash" ] || [ "$1" = "sh" ] || [ "$1" = "python" ]; then
+ log_info "Executing command: $*"
+ exec "$@"
+fi
+
+# If the user passed "server" (default from Dockerfile CMD), start the app
+if [ "$1" = "server" ]; then
+
+ # Optional DB Migration check on startup for Dev Mode (convenience)
+ if [ "${DEV_MODE:-false}" = "true" ] || [ "${FLASK_ENV:-development}" = "development" ]; then
+ log_info "Development mode detected. Ensuring DB schema is up to date..."
+
+ # Initialize migrations if folder doesn't exist
+ if [ ! -d "migrations" ]; then
+ log_info "Initializing migrations..."
+ flask db init
+ fi
+
+ # Always try to upgrade to latest
+ flask db upgrade || log_error "Failed to upgrade DB, skipping."
+ fi
+
+ # Start the actual server
+ if [ "${FLASK_ENV:-development}" = "development" ]; then
+ log_info "Starting Flask development server with hot reload..."
+ exec flask run --host=0.0.0.0 --port=5000 --debug
+ else
+ log_info "Starting Gunicorn production server..."
+ exec gunicorn --bind 0.0.0.0:5000 --workers 4 --threads 2 --worker-class gthread app:app
+ fi
+fi
+
+# Fallback: just execute whatever was passed
+exec "$@"
diff --git a/fix_routes_api.patch b/fix_routes_api.patch
new file mode 100644
index 0000000..8e77a35
--- /dev/null
+++ b/fix_routes_api.patch
@@ -0,0 +1,18 @@
+--- routes/api.py
++++ routes/api.py
+@@ -58,15 +58,6 @@
+ def before_api_request():
+ """Before request handler for API routes."""
+- # Set CORS headers for API routes
+- if current_app.config.get('ENABLE_CORS', True):
+- response = current_app.make_default_options_response()
+- response.headers['Access-Control-Allow-Origin'] = '*'
+- response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
+- response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-Public-Key, X-Request-ID'
+-
+ # Handle OPTIONS requests
+ if request.method == 'OPTIONS':
+- return response
++ return current_app.make_default_options_response()
+
+ # Skip authentication for status endpoint and options requests
diff --git a/routes/admin.py b/routes/admin.py
index a375bbd..5f9e393 100644
--- a/routes/admin.py
+++ b/routes/admin.py
@@ -43,6 +43,10 @@ from models import db, Tenant, FormConfig, Submission, User, UserRole, TenantSta
from models import AuditLog
from services.recaptcha import RecaptchaService
+from services.tenant_admin_service import TenantAdminService
+from services.form_admin_service import FormAdminService
+from services.user_admin_service import UserAdminService
+
logger = logging.getLogger(__name__)
@@ -155,12 +159,7 @@ def list_tenants():
"""List all tenants."""
user = get_current_user()
- # Filter tenants based on user role
- if user.role == UserRole.ADMIN:
- tenants = Tenant.query.order_by(Tenant.created_at.desc()).all()
- else:
- # Non-admin users can only see their assigned tenants
- tenants = user.tenants if user else []
+ tenants = TenantAdminService.get_tenants(user)
return render_template('admin/tenants/list.html',
user=user,
@@ -181,27 +180,8 @@ def create_tenant():
allowed_origins = request.form.get('allowed_origins', '*').strip()
status = request.form.get('status', 'active')
- # Generate tenant ID
- tenant_id = f'tenant_{name.lower().replace(" ", "_")}_{str(uuid.uuid4())[:8]}'
-
- # Create tenant
- tenant = Tenant(
- id=tenant_id,
- name=name,
- public_key=public_key or str(uuid.uuid4()).hex[:32],
- allowed_origins=allowed_origins,
- status=TenantStatus(status) if status in [s.value for s in TenantStatus] else TenantStatus.ACTIVE
- )
- db.session.add(tenant)
- db.session.commit()
-
- # Audit log
- AuditLog.log_action(
- action='create',
- resource_type='tenant',
- user=user,
- resource_id=tenant.id,
- details={'name': name, 'public_key': public_key}
+ tenant = TenantAdminService.create_tenant(
+ name, public_key, allowed_origins, status, user
)
flash('Tenant created successfully!', 'success')
@@ -230,24 +210,13 @@ def view_tenant(tenant_id):
if not user.is_admin():
abort(403, description='Admin privileges required to edit tenant')
- # Update tenant
- tenant.name = request.form.get('name', tenant.name)
- tenant.public_key = request.form.get('public_key', tenant.public_key)
- tenant.allowed_origins = request.form.get('allowed_origins', tenant.allowed_origins)
-
- status = request.form.get('status')
- if status in [s.value for s in TenantStatus]:
- tenant.status = TenantStatus(status)
-
- db.session.commit()
-
- # Audit log
- AuditLog.log_action(
- action='update',
- resource_type='tenant',
- user=user,
- resource_id=tenant.id,
- details={'name': tenant.name, 'status': tenant.status.value}
+ TenantAdminService.update_tenant(
+ tenant,
+ request.form.get('name'),
+ request.form.get('public_key'),
+ request.form.get('allowed_origins'),
+ request.form.get('status'),
+ user
)
flash('Tenant updated successfully!', 'success')
@@ -276,18 +245,7 @@ def delete_tenant(tenant_id):
if not tenant:
abort(404, description='Tenant not found')
- # Delete tenant (cascade will handle related forms and submissions)
- db.session.delete(tenant)
- db.session.commit()
-
- # Audit log
- AuditLog.log_action(
- action='delete',
- resource_type='tenant',
- user=user,
- resource_id=tenant.id,
- details={'name': tenant.name}
- )
+ TenantAdminService.delete_tenant(tenant, user)
flash('Tenant deleted successfully!', 'success')
return redirect(url_for('admin.list_tenants'))
@@ -300,19 +258,7 @@ def list_forms():
user = get_current_user()
tenant = get_current_tenant()
- # Get forms based on user access
- if user.role == UserRole.ADMIN:
- # Admin can see all forms
- forms = FormConfig.query.join(Tenant).order_by(
- FormConfig.created_at.desc()
- ).all()
- elif tenant:
- # Tenant user sees only their tenant's forms
- forms = FormConfig.query.filter_by(tenant_id=tenant.id).order_by(
- FormConfig.created_at.desc()
- ).all()
- else:
- forms = []
+ forms = FormAdminService.get_forms(user, tenant)
return render_template('admin/forms/list.html',
user=user,
@@ -353,30 +299,10 @@ def create_form():
except json.JSONDecodeError:
settings = {}
- # Generate form ID
- form_id = f'form_{name.lower().replace(" ", "_")}_{str(uuid.uuid4())[:8]}'
-
- # Create form
- form = FormConfig(
- id=form_id,
- tenant_id=tenant.id,
- name=name,
- description=description,
- form_structure=form_structure,
- settings=settings,
- is_active=request.form.get('is_active', 'true').lower() == 'true'
- )
- db.session.add(form)
- db.session.commit()
-
- # Audit log
- AuditLog.log_action(
- action='create',
- resource_type='form',
- user=user,
- tenant=tenant,
- resource_id=form.id,
- details={'name': name, 'tenant_id': tenant.id}
+ form = FormAdminService.create_form(
+ tenant, name, description, form_structure, settings,
+ request.form.get('is_active', 'true').lower() == 'true',
+ user
)
flash('Form created successfully!', 'success')
@@ -438,37 +364,26 @@ def view_form(form_id):
abort(403, description='Access denied')
if request.method == 'POST':
- # Update form
- form.name = request.form.get('name', form.name)
- form.description = request.form.get('description', form.description)
- form.is_active = request.form.get('is_active', 'true').lower() == 'true'
-
- # Update form structure
form_structure_str = request.form.get('form_structure', '{}')
+ form_structure = None
try:
import json
- form.form_structure = json.loads(form_structure_str)
+ form_structure = json.loads(form_structure_str)
except json.JSONDecodeError:
- pass # Keep existing structure
-
- # Update settings
+ pass
+
settings_str = request.form.get('settings', '{}')
+ settings = None
try:
import json
- form.settings = json.loads(settings_str)
+ settings = json.loads(settings_str)
except json.JSONDecodeError:
- pass # Keep existing settings
-
- db.session.commit()
-
- # Audit log
- AuditLog.log_action(
- action='update',
- resource_type='form',
- user=user,
- tenant=tenant,
- resource_id=form.id,
- details={'name': form.name, 'is_active': form.is_active}
+ pass
+
+ FormAdminService.update_form(
+ form, request.form.get('name'), request.form.get('description'),
+ request.form.get('is_active', 'true').lower() == 'true',
+ form_structure, settings, user, tenant
)
flash('Form updated successfully!', 'success')
@@ -506,19 +421,7 @@ def delete_form(form_id):
if user.role != UserRole.ADMIN and tenant not in user.tenants:
abort(403, description='Access denied')
- # Delete form (cascade will handle submissions)
- db.session.delete(form)
- db.session.commit()
-
- # Audit log
- AuditLog.log_action(
- action='delete',
- resource_type='form',
- user=user,
- tenant=tenant,
- resource_id=form.id,
- details={'name': form.name}
- )
+ FormAdminService.delete_form(form, user, tenant)
flash('Form deleted successfully!', 'success')
return redirect(url_for('admin.list_forms'))
@@ -622,7 +525,7 @@ def update_submission_status(submission_id):
def list_users():
"""List all users (admin only)."""
user = get_current_user()
- users = User.query.order_by(User.created_at.desc()).all()
+ users = UserAdminService.get_users()
return render_template('admin/users/list.html',
user=user,
@@ -641,23 +544,9 @@ def view_user(user_id):
abort(404, description='User not found')
if request.method == 'POST':
- # Update user
- target_user.email = request.form.get('email', target_user.email)
- target_user.name = request.form.get('name', target_user.name)
-
- role = request.form.get('role')
- if role in [r.value for r in UserRole]:
- target_user.role = UserRole(role)
-
- db.session.commit()
-
- # Audit log
- AuditLog.log_action(
- action='update',
- resource_type='user',
- user=user,
- resource_id=target_user.id,
- details={'email': target_user.email, 'role': target_user.role.value}
+ UserAdminService.update_user(
+ target_user, request.form.get('email'), request.form.get('name'),
+ request.form.get('role'), user
)
flash('User updated successfully!', 'success')
@@ -689,17 +578,7 @@ def delete_user(user_id):
flash('Cannot delete your own account!', 'error')
return redirect(url_for('admin.view_user', user_id=target_user.id))
- db.session.delete(target_user)
- db.session.commit()
-
- # Audit log
- AuditLog.log_action(
- action='delete',
- resource_type='user',
- user=user,
- resource_id=target_user.id,
- details={'email': target_user.email}
- )
+ UserAdminService.delete_user(target_user, user)
flash('User deleted successfully!', 'success')
return redirect(url_for('admin.list_users'))