diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3c69f39 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# ============================================================ +# Umgebungsvariablen für docker-compose.dev.yml +# Kopiere diese Datei zu ".env" und passe die Werte an. +# ============================================================ + +# JWT Secret Key +# Ersetze diesen Wert in Produktion durch einen langen, zufälligen String, +# z.B. generiert mit: python -c "import secrets; print(secrets.token_hex(32))" +SECRET_KEY=dev-change-me + +# Pfad zur Datenbankkonfigurationsdatei im Container +# Standardwert für Docker: /config/db_config.json +DB_CONFIG_PATH=/config/db_config.json diff --git a/README.md b/README.md index 6332b1e..10120ed 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,120 @@ -# 🐳 Docker-Webplattform: React + Flask + externe MariaDB +# Docker-Webplattform: React + Flask + externe MariaDB -Dieses Projekt ist eine vollständig dockerisierte Webanwendung, die ein React-Frontend und ein Flask-Backend **in einem einzigen Container** vereint. Sie kommuniziert mit einer **externen MariaDB-Datenbank** (z. B. auf einem Unraid-Server) und bietet ein Setup-System, Login, Rollenverwaltung und modulare Tools. +Vollständig dockerisierte Webanwendung mit React-Frontend und Flask-Backend in einem einzigen Container. Verbindet sich mit einer externen MariaDB-Datenbank und bietet Setup-System, Login, Rollenverwaltung und modulare Tools. --- -## ⚙️ Was macht der Docker-Container? +## Screenshot -- 🚀 Startet automatisch **Flask + React** in einer Umgebung -- 🛠 Bei Erststart zeigt er eine **Setup-Webseite** zum Eintragen der DB-Verbindung -- 💾 Speichert die Konfiguration in `config/db_config.json` -- 🌐 Verbindet sich mit der **externen MariaDB-Datenbank** -- 👤 Erstellt automatisch ein `admin`-Benutzerkonto (`admin / admin`) -- 🔐 Ermöglicht Login und Rollenzuordnung -- 🧰 Liefert das React-Frontend direkt über Flask aus (keine extra Node-Instanz) -- 🧾 Verwaltet Tools wie z. B. ein MD5-Hash-Modul -- ✅ Docker-fähig, kompatibel mit Docker Compose oder Portainer +![Admin-Dashboard](logs/Screenshot%202026-01-22%20151541.png) --- -## 📦 Projektstruktur +## Setup-Anleitung -## 📄 Lizenz +### Voraussetzungen +- Docker & Docker Compose installiert +- Externe MariaDB/MySQL-Datenbank erreichbar (z.B. auf Unraid, NAS oder anderem Server) -Außerdem ist es notwendig den Ursprüglichen Entwickler anzugeben +### 1. Repository klonen -Dieses Projekt steht unter der **Creative Commons BY-NC 4.0 Lizenz**. -➡️ Du darfst ihn verwenden, verändern und teilen – **aber nicht kommerziell nutzen.** -Volltext: [https://creativecommons.org/licenses/by-nc/4.0] (https://creativecommons.org/licenses/by-nc/4.0) +```bash +git clone https://github.com/Nirodan/Tools.git +cd Tools +``` -💡 Hinweis: Die Urheberschaft erfolgt unter Pseudonym. +### 2. Umgebungsvariablen konfigurieren -Author: Source page: Nirodan/Github:Nirodan Production \ No newline at end of file +```bash +cp .env.example .env +``` + +Dann `.env` öffnen und `SECRET_KEY` durch einen sicheren zufälligen Wert ersetzen: + +```bash +# Sicheren Key generieren: +python -c "import secrets; print(secrets.token_hex(32))" +``` + +### 3. Container starten + +```bash +docker compose -f docker-compose.dev.yml up -d +``` + +### 4. Datenbank einrichten (Erststart) + +Beim ersten Start öffnet sich automatisch die Setup-Seite unter `http://localhost:5050/setup`. + +Dort die MariaDB-Verbindungsdaten eintragen: + +| Feld | Beispiel | +|------------|-------------------| +| Host | `192.168.1.100` | +| Port | `3306` | +| User | `tools_user` | +| Password | `geheimes_pw` | +| Database | `tools_db` | + +Nach dem Speichern wird automatisch ein Admin-Account angelegt: +- **Benutzername:** `admin` +- **Passwort:** `admin` *(bitte sofort im Admin-Dashboard ändern)* + +### 5. Anwendung aufrufen + +``` +http://localhost:5050 +``` + +--- + +## Was der Container macht + +- Startet automatisch Flask + React in einer Umgebung +- Bei Erststart: Setup-Webseite zum Eintragen der DB-Verbindung +- Speichert die Konfiguration in einem Docker-Volume (`config/db_config.json`) +- Verbindet sich mit der externen MariaDB-Datenbank +- Erstellt automatisch ein `admin`-Benutzerkonto +- Liefert das React-Frontend direkt über Flask aus (keine extra Node-Instanz) + +--- + +## Projektstruktur + +``` +Tools/ +├── backend/ +│ ├── app.py # Flask-Einstiegspunkt +│ ├── admin.py # Admin-API (Nutzer- und Websitenverwaltung) +│ ├── auth/ # Login, Logout, Token-Verifikation +│ ├── tools/ # Modulare Tools (MD5 etc.) +│ └── util/ # DB-Pool, Konfiguration, Logger, Rate-Limiter +├── frontend/ +│ └── src/ +│ ├── App.jsx +│ ├── components/ # React-Komponenten +│ └── services/api.js # Axios-Instanz mit Auth-Interceptor +├── docker-compose.dev.yml +├── Dockerfile +├── .env.example # Vorlage für Umgebungsvariablen +└── README.md +``` + +--- + +## Sicherheitshinweise + +- `SECRET_KEY` niemals im Klartext in der Versionskontrolle speichern – immer `.env` verwenden +- Standard-Admin-Passwort (`admin`) nach dem Erststart sofort ändern +- MD5 ist kryptografisch unsicher – nicht für Passwort-Hashing verwenden + +--- + +## Lizenz + +Dieses Projekt steht unter der **Creative Commons BY-NC 4.0 Lizenz**. +Du darfst es verwenden, verändern und teilen – aber nicht kommerziell nutzen. +Volltext: https://creativecommons.org/licenses/by-nc/4.0 + +Author: Nirodan – https://github.com/Nirodan diff --git a/backend/admin.py b/backend/admin.py index ca298dc..9eb905b 100644 --- a/backend/admin.py +++ b/backend/admin.py @@ -1,8 +1,7 @@ from flask import Blueprint, request, jsonify -from mysql.connector import connect from werkzeug.security import generate_password_hash from auth.token import verify_token -from util.db_config import load_config +from util.db_pool import get_connection from util.logger import logger admin_bp = Blueprint("admin", __name__) @@ -47,8 +46,7 @@ def list_users(): if err: return err try: - cfg = load_config() - conn = connect(**cfg) + conn = get_connection() cur = conn.cursor(dictionary=True) _ensure_tables(cur) cur.execute("SELECT id, username, role FROM users ORDER BY username ASC") @@ -73,8 +71,7 @@ def create_user(): if not username or not password: return jsonify({"message": "Username und Passwort erforderlich"}), 400 try: - cfg = load_config() - conn = connect(**cfg) + conn = get_connection() cur = conn.cursor(dictionary=True) _ensure_tables(cur) cur.execute("SELECT id FROM users WHERE username=%s", (username,)) @@ -108,8 +105,7 @@ def update_user(user_id): if role is None and password is None: return jsonify({"message": "Nichts zu aktualisieren"}), 400 try: - cfg = load_config() - conn = connect(**cfg) + conn = get_connection() cur = conn.cursor() _ensure_tables(cur) if role: @@ -135,8 +131,7 @@ def delete_user(user_id): if err: return err try: - cfg = load_config() - conn = connect(**cfg) + conn = get_connection() cur = conn.cursor() _ensure_tables(cur) # Schutz: Admin darf sich nicht selbst löschen @@ -170,8 +165,7 @@ def list_websites_admin(): if err: return err try: - cfg = load_config() - conn = connect(**cfg) + conn = get_connection() cur = conn.cursor(dictionary=True) _ensure_tables(cur) cur.execute("SELECT id, name, url, description FROM websites ORDER BY name ASC") @@ -196,8 +190,7 @@ def create_website(): if not name or not url: return jsonify({"message": "Name und URL erforderlich"}), 400 try: - cfg = load_config() - conn = connect(**cfg) + conn = get_connection() cur = conn.cursor() _ensure_tables(cur) cur.execute( @@ -221,8 +214,7 @@ def update_website(item_id): return err data = request.get_json() or {} try: - cfg = load_config() - conn = connect(**cfg) + conn = get_connection() cur = conn.cursor() _ensure_tables(cur) cur.execute( @@ -244,8 +236,7 @@ def delete_website(item_id): if err: return err try: - cfg = load_config() - conn = connect(**cfg) + conn = get_connection() cur = conn.cursor() _ensure_tables(cur) cur.execute("DELETE FROM websites WHERE id=%s", (item_id,)) @@ -266,8 +257,7 @@ def list_websites_public(): if not user: return jsonify({"message": "Nicht autorisiert"}), 401 try: - cfg = load_config() - conn = connect(**cfg) + conn = get_connection() cur = conn.cursor(dictionary=True) _ensure_tables(cur) cur.execute("SELECT id, name, url, description FROM websites ORDER BY name ASC") @@ -278,8 +268,3 @@ def list_websites_public(): except Exception as e: logger.error(f"[Public list_websites] {e}") return jsonify({"message": "Serverfehler"}), 500 - - -@admin_bp.route("/api/scripts", methods=["GET"]) -def list_scripts_public(): - return jsonify({"message": "Scripts wurden entfernt"}), 410 diff --git a/backend/app.py b/backend/app.py index b20a579..d62ca37 100644 --- a/backend/app.py +++ b/backend/app.py @@ -8,14 +8,15 @@ from flask import Flask, send_from_directory, redirect from util.logger import logger from util.db_config import is_configured, load_config, test_connection from util.setup_routes import setup_blueprint +from util.limiter import limiter from auth import auth_bp from tools import md5_blueprint from admin import admin_bp app = Flask(__name__, template_folder="templates") +limiter.init_app(app) - -# 📦 Blueprints registrieren +# Blueprints registrieren app.register_blueprint(setup_blueprint) app.register_blueprint(auth_bp) app.register_blueprint(md5_blueprint) diff --git a/backend/auth/login.py b/backend/auth/login.py index 08b937c..0d2657f 100644 --- a/backend/auth/login.py +++ b/backend/auth/login.py @@ -1,13 +1,15 @@ from flask import request, jsonify -from mysql.connector import connect from werkzeug.security import check_password_hash -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import jwt from util.logger import logger -from util.db_config import load_config +from util.db_pool import get_connection +from util.limiter import limiter from auth.token import SECRET_KEY + +@limiter.limit("10 per minute") def login_route(): data = request.get_json() username = data.get('username') @@ -18,8 +20,7 @@ def login_route(): return jsonify({"message": "Server misconfigured"}), 500 try: - config = load_config() - conn = connect(**config) + conn = get_connection() cursor = conn.cursor(dictionary=True) cursor.execute("SELECT * FROM users WHERE username = %s", (username,)) user = cursor.fetchone() @@ -27,12 +28,12 @@ def login_route(): conn.close() if user and check_password_hash(user['password'], password): - logger.info(f"✅ Login successful: {username}") + logger.info(f"Login successful: {username}") payload = { "username": user['username'], "role": user['role'], - "exp": datetime.utcnow() + timedelta(minutes=60) + "exp": datetime.now(timezone.utc) + timedelta(minutes=60) } token = jwt.encode(payload, SECRET_KEY, algorithm="HS256") diff --git a/backend/requirements.txt b/backend/requirements.txt index 9ebfc05..e905c84 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,5 +1,6 @@ flask flask-cors +flask-limiter mysql-connector-python werkzeug>=2.3 PyJWT diff --git a/backend/util/db_pool.py b/backend/util/db_pool.py new file mode 100644 index 0000000..fe8c0b9 --- /dev/null +++ b/backend/util/db_pool.py @@ -0,0 +1,26 @@ +import mysql.connector.pooling +from util.logger import logger + +_pool = None + + +def get_connection(): + global _pool + if _pool is None: + from util.db_config import load_config + config = load_config() + if not config: + raise RuntimeError("DB-Konfiguration nicht verfügbar") + _pool = mysql.connector.pooling.MySQLConnectionPool( + pool_name="tools_pool", + pool_size=5, + **config + ) + logger.info("DB-Verbindungspool erstellt (pool_size=5)") + return _pool.get_connection() + + +def reset_pool(): + """Pool zurücksetzen – nach Konfigurationsänderung aufrufen.""" + global _pool + _pool = None diff --git a/backend/util/limiter.py b/backend/util/limiter.py new file mode 100644 index 0000000..d8bf600 --- /dev/null +++ b/backend/util/limiter.py @@ -0,0 +1,4 @@ +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address + +limiter = Limiter(key_func=get_remote_address) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 92dd560..d99d87d 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -5,9 +5,8 @@ services: dockerfile: Dockerfile ports: - "5050:5000" - environment: - - SECRET_KEY=dev-change-me - - DB_CONFIG_PATH=/config/db_config.json + env_file: + - .env volumes: - config-data:/config - logs-data:/app/backend/logs diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 23d8137..28a03dd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,6 +6,7 @@ import Md5Tool from './components/Md5Tool'; import NavBar from './components/NavBar'; import ToolOverview from './components/ToolOverview'; import AdminDashboard from './components/AdminDashboard'; +import ErrorBoundary from './components/ErrorBoundary'; import './css/base.css'; @@ -21,23 +22,25 @@ function App() { const role = localStorage.getItem('role'); return ( - - - - : } /> - } /> - {/*} />*/} - : } /> - - : - } - /> - - + + + + + : } /> + } /> + {/*} />*/} + : } /> + + : + } + /> + + + ); } diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..e305524 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.jsx @@ -0,0 +1,33 @@ +import { Component } from 'react'; + +class ErrorBoundary extends Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + componentDidCatch(error, info) { + console.error('ErrorBoundary caught:', error, info); + } + + render() { + if (this.state.hasError) { + return ( +
+

Ein unerwarteter Fehler ist aufgetreten.

+

{this.state.error?.message}

+ +
+ ); + } + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/frontend/src/components/Md5Tool.jsx b/frontend/src/components/Md5Tool.jsx index 1af5d95..611822d 100644 --- a/frontend/src/components/Md5Tool.jsx +++ b/frontend/src/components/Md5Tool.jsx @@ -18,11 +18,16 @@ function Md5Tool() { return (

MD5 Hasher

+

+ Sicherheitshinweis: MD5 ist kryptografisch unsicher und darf + nicht zum Hashen von Passwörtern verwendet werden. Für Passwörter bitte + bcrypt, Argon2 oder scrypt einsetzen. +

setInput(e.target.value)} - placeholder="Gib ein Passwort ein" + placeholder="Eingabe" /> {result &&

MD5: {result}

}