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