Security, code quality and frontend improvements

- Move SECRET_KEY out of docker-compose into .env (env_file), add .env.example
- Add flask-limiter with 10 req/min on login route; introduce util/limiter.py
- Replace direct mysql.connector.connect() calls with MySQLConnectionPool via util/db_pool.py
- Fix deprecated datetime.utcnow() -> datetime.now(timezone.utc) in auth/login.py
- Remove dead /api/scripts 410 route from admin.py
- Add MD5 security warning in Md5Tool.jsx
- Add ErrorBoundary component and wrap App.jsx
- Expand README with setup guide, screenshot and project structure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nirodan
2026-04-24 13:52:53 +02:00
parent 8e2c2d740e
commit 80ec5eca7b
12 changed files with 232 additions and 75 deletions
+13
View File
@@ -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
+106 -20
View File
@@ -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
```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
+10 -25
View File
@@ -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
+3 -2
View File
@@ -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)
+8 -7
View File
@@ -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")
+1
View File
@@ -1,5 +1,6 @@
flask
flask-cors
flask-limiter
mysql-connector-python
werkzeug>=2.3
PyJWT
+26
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
+2 -3
View File
@@ -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
+20 -17
View File
@@ -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 (
<BrowserRouter>
<NavBar />
<Routes>
<Route path="/" element={isLoggedIn ? <ToolOverview /> : <Navigate to="/login" />} />
<Route path="/login" element={<LoginForm />} />
{/*<Route path="/register" element={<RegisterForm />} />*/}
<Route path="/tools/md5" element={isLoggedIn ? <Md5Tool /> : <Navigate to="/login" />} />
<Route
path="/admin"
element={
isLoggedIn && role === 'admin'
? <AdminDashboard />
: <Navigate to="/" />
}
/>
</Routes>
</BrowserRouter>
<ErrorBoundary>
<BrowserRouter>
<NavBar />
<Routes>
<Route path="/" element={isLoggedIn ? <ToolOverview /> : <Navigate to="/login" />} />
<Route path="/login" element={<LoginForm />} />
{/*<Route path="/register" element={<RegisterForm />} />*/}
<Route path="/tools/md5" element={isLoggedIn ? <Md5Tool /> : <Navigate to="/login" />} />
<Route
path="/admin"
element={
isLoggedIn && role === 'admin'
? <AdminDashboard />
: <Navigate to="/" />
}
/>
</Routes>
</BrowserRouter>
</ErrorBoundary>
);
}
+33
View File
@@ -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 (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<h2>Ein unerwarteter Fehler ist aufgetreten.</h2>
<p style={{ opacity: 0.7 }}>{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false, error: null })}>
Erneut versuchen
</button>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
+6 -1
View File
@@ -18,11 +18,16 @@ function Md5Tool() {
return (
<div className="main-content">
<h2>MD5 Hasher</h2>
<p style={{ color: '#e07b00', fontSize: '0.875rem', marginBottom: '0.75rem' }}>
<strong>Sicherheitshinweis:</strong> MD5 ist kryptografisch unsicher und darf
nicht zum Hashen von Passwörtern verwendet werden. Für Passwörter bitte
bcrypt, Argon2 oder scrypt einsetzen.
</p>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Gib ein Passwort ein"
placeholder="Eingabe"
/>
<button onClick={hashPassword}>Hash berechnen</button>
{result && <p><strong>MD5:</strong> {result}</p>}