Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc89229372 | |||
| c95738d0a0 | |||
| 256c8ffdec | |||
| 918ff02216 | |||
| 9c0efcd89b | |||
| ac31290a87 | |||
| 9db922703b | |||
| 20cdbd69eb | |||
| 7827cda224 | |||
| 98bb34f094 | |||
| 31494c9dab | |||
| dedde400e1 | |||
| 75062dbf5e | |||
| ef03e76950 | |||
| 34c82f3dca | |||
| 45e1934bee | |||
| 1a6f476dc8 | |||
| 955bc9a7bf | |||
| 7f9c5c874a | |||
| 80ec5eca7b |
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(cd Y:/Tools && docker compose -f docker-compose.dev.yml up -d --build 2>&1 | tail -30)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit -m ' *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Build & Deploy
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login docker.io \
|
||||
-u ${{ secrets.REGISTRY_USER }} --password-stdin
|
||||
|
||||
- name: Build & Push latest + version tag
|
||||
run: |
|
||||
docker build -t DEINUSERNAME/tools-app:latest .
|
||||
docker build -t DEINUSERNAME/tools-app:${{ gitea.ref_name }} .
|
||||
docker push DEINUSERNAME/tools-app:latest
|
||||
docker push DEINUSERNAME/tools-app:${{ gitea.ref_name }}
|
||||
@@ -73,3 +73,4 @@ Thumbs.db
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
logs/Screenshot 2026-01-22 151541.png
|
||||
.claude/*
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Deployment Guide
|
||||
|
||||
## Voraussetzungen auf Unraid
|
||||
- Docker installiert
|
||||
- Gitea Act Runner läuft als Container (Label: `self-hosted`)
|
||||
|
||||
## Einmalige Einrichtung
|
||||
|
||||
### 1. Gitea Secrets setzen
|
||||
Im Repository unter Settings → Secrets folgende Secrets anlegen:
|
||||
- `REGISTRY_USER` – dein Docker Hub Benutzername
|
||||
- `REGISTRY_TOKEN` – ein Docker Hub Access Token
|
||||
(Docker Hub → Account Settings → Security → New Access Token)
|
||||
|
||||
### 2. Repository Actions aktivieren
|
||||
Im Repository unter Settings → "Enable Repository Actions" aktivieren.
|
||||
|
||||
### 3. Act Runner auf Unraid starten
|
||||
Token holen unter: https://git.nirodan.de/-/admin/actions/runners
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--restart always \
|
||||
--name gitea-runner \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /mnt/user/appdata/gitea-runner:/data \
|
||||
-e GITEA_INSTANCE_URL=https://git.nirodan.de/ \
|
||||
-e GITEA_RUNNER_REGISTRATION_TOKEN=DEIN_TOKEN \
|
||||
-e GITEA_RUNNER_NAME=unraid-runner \
|
||||
gitea/act_runner:latest
|
||||
```
|
||||
|
||||
### 4. App Container auf Unraid starten
|
||||
Einmalig in Docker Hub einloggen damit Credentials gespeichert werden:
|
||||
```bash
|
||||
docker login docker.io
|
||||
```
|
||||
|
||||
Container starten:
|
||||
```bash
|
||||
docker run -d \
|
||||
--name tools-app \
|
||||
--restart unless-stopped \
|
||||
-p 5000:5000 \
|
||||
--env-file .env \
|
||||
DEINUSERNAME/tools-app:latest
|
||||
```
|
||||
|
||||
### 5. Auto-Update auf Unraid
|
||||
Unraid hat ein eingebautes Update-Feature – kein Watchtower nötig.
|
||||
|
||||
**Docker-Tab → Container → Update-Symbol (↓) anklicken**
|
||||
|
||||
Das zieht automatisch das neueste Image und startet den Container neu.
|
||||
|
||||
## Release erstellen = Deployment auslösen
|
||||
|
||||
```bash
|
||||
# Tag erstellen und pushen
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
Dann in Gitea:
|
||||
Repository → Releases → "New Release" → Tag auswählen → Publish
|
||||
|
||||
Der Runner baut automatisch das Image und pusht es auf Docker Hub.
|
||||
Anschließend im Unraid Docker-Tab manuell auf Update klicken.
|
||||
|
||||
## Rollback auf alte Version
|
||||
|
||||
In Unraid den Container stoppen und mit altem Tag neu starten:
|
||||
```bash
|
||||
docker stop tools-app
|
||||
docker run -d \
|
||||
--name tools-app \
|
||||
--restart unless-stopped \
|
||||
-p 5000:5000 \
|
||||
--env-file .env \
|
||||
DEINUSERNAME/tools-app:v0.9.0
|
||||
```
|
||||
|
||||
## Nutzung durch andere
|
||||
|
||||
Das Image ist öffentlich auf Docker Hub verfügbar. Einfach pullen und starten:
|
||||
|
||||
```bash
|
||||
docker pull DEINUSERNAME/tools-app:latest
|
||||
|
||||
docker run -d \
|
||||
--name tools-app \
|
||||
--restart unless-stopped \
|
||||
-p 5000:5000 \
|
||||
--env-file .env \
|
||||
DEINUSERNAME/tools-app:latest
|
||||
```
|
||||
+8
-1
@@ -19,6 +19,10 @@ COPY backend/templates ./backend/templates
|
||||
# Store DB config in a docker-friendly location (/config), override via DB_CONFIG_PATH env if needed
|
||||
COPY backend/config /config
|
||||
COPY backend/requirements.txt ./requirements.txt
|
||||
COPY backend/entrypoint.sh ./entrypoint.sh
|
||||
|
||||
# Persistent directories (overridden by Docker volumes at runtime)
|
||||
RUN mkdir -p /app/backend/logs /app/backend/backups
|
||||
|
||||
# Frontend aus Build-Stage übernehmen
|
||||
COPY --from=frontend-build /app/frontend/dist ./frontend/dist
|
||||
@@ -26,9 +30,12 @@ COPY --from=frontend-build /app/frontend/dist ./frontend/dist
|
||||
# Python-Abhängigkeiten installieren
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Entrypoint ausführbar machen
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
# Flask starten
|
||||
WORKDIR /app/backend
|
||||
ENV PYTHONPATH=/app/backend
|
||||
ENV DB_CONFIG_PATH=/config/db_config.json
|
||||
EXPOSE 5000
|
||||
CMD ["python", "app.py"]
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
|
||||
@@ -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
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 📦 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
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Nirodan/Tools.git
|
||||
cd Tools
|
||||
```
|
||||
|
||||
### 2. Umgebungsvariablen konfigurieren
|
||||
|
||||
```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 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)
|
||||
Du darfst es verwenden, verändern und teilen – aber nicht kommerziell nutzen.
|
||||
Volltext: https://creativecommons.org/licenses/by-nc/4.0
|
||||
|
||||
💡 Hinweis: Die Urheberschaft erfolgt unter Pseudonym.
|
||||
|
||||
Author: Source page: Nirodan/Github:Nirodan Production
|
||||
Author: Nirodan – https://github.com/Nirodan
|
||||
|
||||
+120
-136
@@ -1,12 +1,13 @@
|
||||
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__)
|
||||
|
||||
_VALID_ROLES = {"user", "admin"}
|
||||
|
||||
|
||||
def _require_admin():
|
||||
user = verify_token()
|
||||
@@ -18,43 +19,20 @@ def _require_admin():
|
||||
return user, None
|
||||
|
||||
|
||||
def _ensure_tables(cur):
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS websites (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
url VARCHAR(255) NOT NULL,
|
||||
description VARCHAR(255) DEFAULT ''
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
@admin_bp.route("/api/admin/users", methods=["GET"])
|
||||
def list_users():
|
||||
_, err = _require_admin()
|
||||
if err:
|
||||
return err
|
||||
try:
|
||||
cfg = load_config()
|
||||
conn = connect(**cfg)
|
||||
cur = conn.cursor(dictionary=True)
|
||||
_ensure_tables(cur)
|
||||
cur.execute("SELECT id, username, role FROM users ORDER BY username ASC")
|
||||
users = cur.fetchall()
|
||||
cur.close()
|
||||
conn.close()
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.cursor(dictionary=True)
|
||||
cur.execute("SELECT id, username, role FROM users ORDER BY username ASC")
|
||||
users = cur.fetchall()
|
||||
cur.close()
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify(users)
|
||||
except Exception as e:
|
||||
logger.error(f"[Admin list_users] {e}")
|
||||
@@ -66,30 +44,31 @@ def create_user():
|
||||
admin, err = _require_admin()
|
||||
if err:
|
||||
return err
|
||||
data = request.get_json() or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
username = data.get("username", "").strip()
|
||||
password = data.get("password", "")
|
||||
role = data.get("role", "user")
|
||||
if not username or not password:
|
||||
return jsonify({"message": "Username und Passwort erforderlich"}), 400
|
||||
if role not in _VALID_ROLES:
|
||||
return jsonify({"message": f"Ungültige Rolle. Erlaubt: {', '.join(_VALID_ROLES)}"}), 400
|
||||
try:
|
||||
cfg = load_config()
|
||||
conn = connect(**cfg)
|
||||
cur = conn.cursor(dictionary=True)
|
||||
_ensure_tables(cur)
|
||||
cur.execute("SELECT id FROM users WHERE username=%s", (username,))
|
||||
if cur.fetchone():
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.cursor(dictionary=True)
|
||||
cur.execute("SELECT id FROM users WHERE username=%s", (username,))
|
||||
if cur.fetchone():
|
||||
cur.close()
|
||||
return jsonify({"message": "Nutzer existiert bereits"}), 409
|
||||
cur.execute(
|
||||
"INSERT INTO users (username, password, role) VALUES (%s, %s, %s)",
|
||||
(username, generate_password_hash(password), role)
|
||||
)
|
||||
conn.commit()
|
||||
new_id = cur.lastrowid
|
||||
cur.close()
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify({"message": "Nutzer existiert bereits"}), 409
|
||||
cur.execute(
|
||||
"INSERT INTO users (username, password, role) VALUES (%s, %s, %s)",
|
||||
(username, generate_password_hash(password), role)
|
||||
)
|
||||
conn.commit()
|
||||
new_id = cur.lastrowid
|
||||
cur.close()
|
||||
conn.close()
|
||||
logger.info(f"✅ User erstellt: {username} durch {admin['username']}")
|
||||
return jsonify({"id": new_id, "username": username, "role": role}), 201
|
||||
except Exception as e:
|
||||
@@ -102,26 +81,28 @@ def update_user(user_id):
|
||||
admin, err = _require_admin()
|
||||
if err:
|
||||
return err
|
||||
data = request.get_json() or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
role = data.get("role")
|
||||
password = data.get("password")
|
||||
if role is None and password is None:
|
||||
return jsonify({"message": "Nichts zu aktualisieren"}), 400
|
||||
if role is not None and role not in _VALID_ROLES:
|
||||
return jsonify({"message": f"Ungültige Rolle. Erlaubt: {', '.join(_VALID_ROLES)}"}), 400
|
||||
try:
|
||||
cfg = load_config()
|
||||
conn = connect(**cfg)
|
||||
cur = conn.cursor()
|
||||
_ensure_tables(cur)
|
||||
if role:
|
||||
cur.execute("UPDATE users SET role=%s WHERE id=%s", (role, user_id))
|
||||
if password:
|
||||
cur.execute(
|
||||
"UPDATE users SET password=%s WHERE id=%s",
|
||||
(generate_password_hash(password), user_id)
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
if role:
|
||||
cur.execute("UPDATE users SET role=%s WHERE id=%s", (role, user_id))
|
||||
if password:
|
||||
cur.execute(
|
||||
"UPDATE users SET password=%s WHERE id=%s",
|
||||
(generate_password_hash(password), user_id)
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
finally:
|
||||
conn.close()
|
||||
logger.info(f"✏️ User aktualisiert (id={user_id}) durch {admin['username']}")
|
||||
return jsonify({"message": "Aktualisiert"}), 200
|
||||
except Exception as e:
|
||||
@@ -135,26 +116,23 @@ def delete_user(user_id):
|
||||
if err:
|
||||
return err
|
||||
try:
|
||||
cfg = load_config()
|
||||
conn = connect(**cfg)
|
||||
cur = conn.cursor()
|
||||
_ensure_tables(cur)
|
||||
# Schutz: Admin darf sich nicht selbst löschen
|
||||
cur.execute("SELECT username FROM users WHERE id=%s", (user_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT username FROM users WHERE id=%s", (user_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
return jsonify({"message": "Nicht gefunden"}), 404
|
||||
username = row[0]
|
||||
if username == admin["username"]:
|
||||
cur.close()
|
||||
return jsonify({"message": "Du kannst dich nicht selbst löschen"}), 400
|
||||
cur.execute("DELETE FROM users WHERE id=%s", (user_id,))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify({"message": "Nicht gefunden"}), 404
|
||||
username = row[0]
|
||||
if username == admin["username"]:
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({"message": "Du kannst dich nicht selbst löschen"}), 400
|
||||
cur.execute("DELETE FROM users WHERE id=%s", (user_id,))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
logger.info(f"🗑️ User gelöscht (id={user_id}) durch {admin['username']}")
|
||||
return jsonify({"message": "Gelöscht"}), 200
|
||||
except Exception as e:
|
||||
@@ -170,14 +148,14 @@ def list_websites_admin():
|
||||
if err:
|
||||
return err
|
||||
try:
|
||||
cfg = load_config()
|
||||
conn = connect(**cfg)
|
||||
cur = conn.cursor(dictionary=True)
|
||||
_ensure_tables(cur)
|
||||
cur.execute("SELECT id, name, url, description FROM websites ORDER BY name ASC")
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
conn.close()
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.cursor(dictionary=True)
|
||||
cur.execute("SELECT id, name, url, description FROM websites ORDER BY name ASC")
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify(rows)
|
||||
except Exception as e:
|
||||
logger.error(f"[Admin list_websites] {e}")
|
||||
@@ -189,25 +167,25 @@ def create_website():
|
||||
_, err = _require_admin()
|
||||
if err:
|
||||
return err
|
||||
data = request.get_json() or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
name = data.get("name", "").strip()
|
||||
url = data.get("url", "").strip()
|
||||
description = data.get("description", "").strip()
|
||||
if not name or not url:
|
||||
return jsonify({"message": "Name und URL erforderlich"}), 400
|
||||
try:
|
||||
cfg = load_config()
|
||||
conn = connect(**cfg)
|
||||
cur = conn.cursor()
|
||||
_ensure_tables(cur)
|
||||
cur.execute(
|
||||
"INSERT INTO websites (name, url, description) VALUES (%s, %s, %s)",
|
||||
(name, url, description),
|
||||
)
|
||||
conn.commit()
|
||||
new_id = cur.lastrowid
|
||||
cur.close()
|
||||
conn.close()
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"INSERT INTO websites (name, url, description) VALUES (%s, %s, %s)",
|
||||
(name, url, description),
|
||||
)
|
||||
conn.commit()
|
||||
new_id = cur.lastrowid
|
||||
cur.close()
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify({"id": new_id, "name": name, "url": url, "description": description}), 201
|
||||
except Exception as e:
|
||||
logger.error(f"[Admin create_website] {e}")
|
||||
@@ -219,19 +197,27 @@ def update_website(item_id):
|
||||
_, err = _require_admin()
|
||||
if err:
|
||||
return err
|
||||
data = request.get_json() or {}
|
||||
data = request.get_json(silent=True) or {}
|
||||
name = data.get("name", "").strip()
|
||||
url = data.get("url", "").strip()
|
||||
if not name or not url:
|
||||
return jsonify({"message": "Name und URL erforderlich"}), 400
|
||||
description = data.get("description", "").strip()
|
||||
try:
|
||||
cfg = load_config()
|
||||
conn = connect(**cfg)
|
||||
cur = conn.cursor()
|
||||
_ensure_tables(cur)
|
||||
cur.execute(
|
||||
"UPDATE websites SET name=%s, url=%s, description=%s WHERE id=%s",
|
||||
(data.get("name"), data.get("url"), data.get("description", ""), item_id),
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"UPDATE websites SET name=%s, url=%s, description=%s WHERE id=%s",
|
||||
(name, url, description, item_id),
|
||||
)
|
||||
conn.commit()
|
||||
affected = cur.rowcount
|
||||
cur.close()
|
||||
finally:
|
||||
conn.close()
|
||||
if affected == 0:
|
||||
return jsonify({"message": "Nicht gefunden"}), 404
|
||||
return jsonify({"message": "Aktualisiert"}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"[Admin update_website] {e}")
|
||||
@@ -244,14 +230,17 @@ def delete_website(item_id):
|
||||
if err:
|
||||
return err
|
||||
try:
|
||||
cfg = load_config()
|
||||
conn = connect(**cfg)
|
||||
cur = conn.cursor()
|
||||
_ensure_tables(cur)
|
||||
cur.execute("DELETE FROM websites WHERE id=%s", (item_id,))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM websites WHERE id=%s", (item_id,))
|
||||
conn.commit()
|
||||
affected = cur.rowcount
|
||||
cur.close()
|
||||
finally:
|
||||
conn.close()
|
||||
if affected == 0:
|
||||
return jsonify({"message": "Nicht gefunden"}), 404
|
||||
return jsonify({"message": "Gelöscht"}), 200
|
||||
except Exception as e:
|
||||
logger.error(f"[Admin delete_website] {e}")
|
||||
@@ -266,20 +255,15 @@ def list_websites_public():
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
cfg = load_config()
|
||||
conn = connect(**cfg)
|
||||
cur = conn.cursor(dictionary=True)
|
||||
_ensure_tables(cur)
|
||||
cur.execute("SELECT id, name, url, description FROM websites ORDER BY name ASC")
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
conn.close()
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.cursor(dictionary=True)
|
||||
cur.execute("SELECT id, name, url, description FROM websites ORDER BY name ASC")
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify(rows)
|
||||
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
|
||||
|
||||
+85
-12
@@ -1,43 +1,116 @@
|
||||
import os
|
||||
import sys
|
||||
import time as _time
|
||||
|
||||
if __name__ != '__main__':
|
||||
import sys
|
||||
sys.path.append(os.path.dirname(__file__))
|
||||
|
||||
from flask import Flask, send_from_directory, redirect
|
||||
from flask import Flask, send_from_directory, redirect, abort
|
||||
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 tools import (
|
||||
md5_blueprint,
|
||||
hasher_blueprint,
|
||||
base64_blueprint,
|
||||
jwt_decoder_blueprint,
|
||||
passwordgen_blueprint,
|
||||
timestamp_blueprint,
|
||||
textdiff_blueprint,
|
||||
qrcode_blueprint,
|
||||
markdown_blueprint,
|
||||
color_blueprint,
|
||||
json_formatter_blueprint,
|
||||
regex_blueprint,
|
||||
hashverifier_blueprint,
|
||||
url_blueprint,
|
||||
stringutils_blueprint,
|
||||
cron_blueprint,
|
||||
ipcalc_blueprint,
|
||||
lorem_blueprint,
|
||||
csv_blueprint,
|
||||
notes_blueprint,
|
||||
)
|
||||
from admin import admin_bp
|
||||
|
||||
app = Flask(__name__, template_folder="templates")
|
||||
limiter.init_app(app)
|
||||
|
||||
|
||||
# 📦 Blueprints registrieren
|
||||
app.register_blueprint(setup_blueprint)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(md5_blueprint)
|
||||
app.register_blueprint(hasher_blueprint)
|
||||
app.register_blueprint(base64_blueprint)
|
||||
app.register_blueprint(jwt_decoder_blueprint)
|
||||
app.register_blueprint(passwordgen_blueprint)
|
||||
app.register_blueprint(timestamp_blueprint)
|
||||
app.register_blueprint(textdiff_blueprint)
|
||||
app.register_blueprint(qrcode_blueprint)
|
||||
app.register_blueprint(markdown_blueprint)
|
||||
app.register_blueprint(color_blueprint)
|
||||
app.register_blueprint(json_formatter_blueprint)
|
||||
app.register_blueprint(regex_blueprint)
|
||||
app.register_blueprint(hashverifier_blueprint)
|
||||
app.register_blueprint(url_blueprint)
|
||||
app.register_blueprint(stringutils_blueprint)
|
||||
app.register_blueprint(cron_blueprint)
|
||||
app.register_blueprint(ipcalc_blueprint)
|
||||
app.register_blueprint(lorem_blueprint)
|
||||
app.register_blueprint(csv_blueprint)
|
||||
app.register_blueprint(notes_blueprint)
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
# 🌐 React-Frontend ausliefern
|
||||
# Cache DB liveness check so we don't open a new TCP connection on every page load.
|
||||
_db_check = {"ok": False, "ts": 0.0}
|
||||
_DB_CHECK_TTL = 30.0 # seconds
|
||||
|
||||
|
||||
def _is_db_ready():
|
||||
now = _time.monotonic()
|
||||
if now - _db_check["ts"] > _DB_CHECK_TTL:
|
||||
_db_check["ok"] = is_configured() and bool(test_connection(load_config()))
|
||||
_db_check["ts"] = now
|
||||
return _db_check["ok"]
|
||||
|
||||
|
||||
@app.route('/', defaults={'path': ''})
|
||||
@app.route('/<path:path>')
|
||||
def serve_frontend(path):
|
||||
if not is_configured() or not test_connection(load_config()):
|
||||
return redirect('/setup')
|
||||
# Unmatched API / setup paths get a clean 404 before any DB work.
|
||||
if path.startswith('api') or path.startswith('setup'):
|
||||
abort(404)
|
||||
|
||||
if path.startswith('setup') or path.startswith('api'):
|
||||
return redirect(f'/{path}')
|
||||
if not _is_db_ready():
|
||||
return redirect('/setup')
|
||||
|
||||
dist_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'frontend', 'dist'))
|
||||
file_path = os.path.join(dist_dir, path)
|
||||
|
||||
if path and os.path.exists(file_path):
|
||||
return send_from_directory(dist_dir, path)
|
||||
else:
|
||||
return send_from_directory(dist_dir, 'index.html')
|
||||
return send_from_directory(dist_dir, 'index.html')
|
||||
|
||||
|
||||
def _run_startup_migrations():
|
||||
"""Run pending DB migrations if the database is already configured."""
|
||||
if not is_configured():
|
||||
logger.info("[startup] DB not yet configured — skipping migrations.")
|
||||
return
|
||||
try:
|
||||
from util.db_pool import get_connection
|
||||
from util.migrations import run_migrations
|
||||
conn = get_connection()
|
||||
try:
|
||||
run_migrations(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error(f"[startup] Migration error: {e}")
|
||||
|
||||
|
||||
_run_startup_migrations()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
+15
-10
@@ -1,25 +1,29 @@
|
||||
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')
|
||||
password = data.get('password')
|
||||
data = request.get_json(silent=True) or {}
|
||||
username = data.get('username', '').strip()
|
||||
password = data.get('password', '')
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({"message": "Username und Passwort erforderlich"}), 400
|
||||
|
||||
if not SECRET_KEY:
|
||||
logger.error("Login blocked: SECRET_KEY is not configured.")
|
||||
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 +31,13 @@ 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 = {
|
||||
"id": user['id'],
|
||||
"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")
|
||||
|
||||
|
||||
@@ -18,9 +18,14 @@ def verify_token():
|
||||
logger.warning("🔐 Invalid Bearer header")
|
||||
return None
|
||||
|
||||
token = auth_header.replace("Bearer ", "")
|
||||
token = auth_header[7:] # len("Bearer ") == 7; safe because startswith is verified above
|
||||
try:
|
||||
decoded = decode(token, SECRET_KEY, algorithms=["HS256"])
|
||||
# Reject tokens that are missing required fields (e.g. issued before
|
||||
# 'id' was added to the payload) so callers never get a KeyError.
|
||||
if not all(k in decoded for k in ("id", "username", "role")):
|
||||
logger.warning("🔐 Token missing required fields — forcing re-login")
|
||||
return None
|
||||
return decoded
|
||||
except ExpiredSignatureError:
|
||||
logger.warning("🔐 Token expired")
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
RETRY_INTERVAL=3
|
||||
|
||||
# If no db_config.json exists yet, skip the wait (first-run setup mode).
|
||||
python - <<'EOF'
|
||||
import sys, os
|
||||
config_path = os.environ.get("DB_CONFIG_PATH", "/config/db_config.json")
|
||||
if not os.path.exists(config_path):
|
||||
sys.exit(1)
|
||||
EOF
|
||||
DB_CONFIGURED=$?
|
||||
|
||||
if [ "$DB_CONFIGURED" -eq 1 ]; then
|
||||
echo "[entrypoint] Keine DB-Konfiguration gefunden – starte im Setup-Modus."
|
||||
else
|
||||
echo "[entrypoint] DB-Konfiguration gefunden – warte auf MariaDB..."
|
||||
while true; do
|
||||
python - <<'EOF'
|
||||
import sys
|
||||
from util.db_config import load_config, test_connection
|
||||
config = load_config()
|
||||
sys.exit(0 if config and test_connection(config) else 1)
|
||||
EOF
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "[entrypoint] MariaDB erreichbar – starte Flask."
|
||||
break
|
||||
fi
|
||||
echo "[entrypoint] MariaDB noch nicht bereit – neuer Versuch in ${RETRY_INTERVAL}s..."
|
||||
sleep "$RETRY_INTERVAL"
|
||||
done
|
||||
fi
|
||||
|
||||
exec python app.py
|
||||
@@ -1,6 +1,10 @@
|
||||
flask
|
||||
flask-cors
|
||||
flask-limiter
|
||||
mysql-connector-python
|
||||
werkzeug>=2.3
|
||||
PyJWT
|
||||
bcrypt
|
||||
python-dotenv
|
||||
qrcode[pil]
|
||||
markdown
|
||||
|
||||
@@ -1 +1,20 @@
|
||||
from .md5 import md5_blueprint
|
||||
from .hasher import hasher_blueprint
|
||||
from .base64tool import base64_blueprint
|
||||
from .jwtdecoder import jwt_decoder_blueprint
|
||||
from .passwordgen import passwordgen_blueprint
|
||||
from .timestamp import timestamp_blueprint
|
||||
from .textdiff import textdiff_blueprint
|
||||
from .qrcode_gen import qrcode_blueprint
|
||||
from .markdown_tool import markdown_blueprint
|
||||
from .colorconverter import color_blueprint
|
||||
from .jsonformatter import json_formatter_blueprint
|
||||
from .regextester import regex_blueprint
|
||||
from .hashverifier import hashverifier_blueprint
|
||||
from .urltool import url_blueprint
|
||||
from .stringutils import stringutils_blueprint
|
||||
from .cronexplainer import cron_blueprint
|
||||
from .ipcalc import ipcalc_blueprint
|
||||
from .loremipsum import lorem_blueprint
|
||||
from .csvviewer import csv_blueprint
|
||||
from .notes import notes_blueprint
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
import base64
|
||||
from util.logger import logger
|
||||
from auth.token import verify_token
|
||||
|
||||
base64_blueprint = Blueprint('base64_tool', __name__)
|
||||
|
||||
|
||||
@base64_blueprint.route('/api/base64/encode', methods=['POST'])
|
||||
def encode():
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
data = request.get_json()
|
||||
text = data.get("text", "")
|
||||
result = base64.b64encode(text.encode()).decode()
|
||||
logger.info(f"Base64 kodiert von {user['username']}")
|
||||
return jsonify({"result": result})
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler Base64 encode: {e}")
|
||||
return jsonify({"message": "Fehler beim Kodieren"}), 500
|
||||
|
||||
|
||||
@base64_blueprint.route('/api/base64/decode', methods=['POST'])
|
||||
def decode():
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
data = request.get_json()
|
||||
text = data.get("text", "").strip()
|
||||
result = base64.b64decode(text.encode()).decode('utf-8')
|
||||
logger.info(f"Base64 dekodiert von {user['username']}")
|
||||
return jsonify({"result": result})
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler Base64 decode: {e}")
|
||||
return jsonify({"message": "Ungültiger Base64-Input"}), 400
|
||||
@@ -0,0 +1,88 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
import colorsys
|
||||
import re
|
||||
from util.logger import logger
|
||||
from auth.token import verify_token
|
||||
|
||||
color_blueprint = Blueprint('color_tool', __name__)
|
||||
|
||||
|
||||
def hex_to_rgb(hex_str):
|
||||
hex_str = hex_str.strip().lstrip('#')
|
||||
if len(hex_str) == 3:
|
||||
hex_str = ''.join(c * 2 for c in hex_str)
|
||||
if len(hex_str) != 6:
|
||||
raise ValueError("Ungültiger HEX-Wert")
|
||||
return int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16)
|
||||
|
||||
|
||||
def rgb_to_hex(r, g, b):
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
|
||||
|
||||
def rgb_to_hsl(r, g, b):
|
||||
# colorsys uses HLS order (hue, lightness, saturation)
|
||||
h, l, s = colorsys.rgb_to_hls(r / 255, g / 255, b / 255)
|
||||
return round(h * 360), round(s * 100), round(l * 100)
|
||||
|
||||
|
||||
def hsl_to_rgb(h, s, l):
|
||||
# CSS HSL: hue 0-360, saturation 0-100, lightness 0-100
|
||||
r, g, b = colorsys.hls_to_rgb(h / 360, l / 100, s / 100)
|
||||
return round(r * 255), round(g * 255), round(b * 255)
|
||||
|
||||
|
||||
def parse_rgb(value):
|
||||
nums = re.findall(r'\d+', value)
|
||||
if len(nums) < 3:
|
||||
raise ValueError("Ungültiger RGB-Wert")
|
||||
r, g, b = int(nums[0]), int(nums[1]), int(nums[2])
|
||||
if not all(0 <= x <= 255 for x in (r, g, b)):
|
||||
raise ValueError("RGB-Werte müssen zwischen 0 und 255 liegen")
|
||||
return r, g, b
|
||||
|
||||
|
||||
def parse_hsl(value):
|
||||
nums = re.findall(r'[\d.]+', value)
|
||||
if len(nums) < 3:
|
||||
raise ValueError("Ungültiger HSL-Wert")
|
||||
h, s, l = float(nums[0]), float(nums[1]), float(nums[2])
|
||||
if not (0 <= h <= 360 and 0 <= s <= 100 and 0 <= l <= 100):
|
||||
raise ValueError("HSL-Werte außerhalb des gültigen Bereichs")
|
||||
return h, s, l
|
||||
|
||||
|
||||
@color_blueprint.route('/api/color/convert', methods=['POST'])
|
||||
def convert_color():
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
value = data.get("value", "").strip()
|
||||
from_format = data.get("from_format", "hex").lower()
|
||||
|
||||
if from_format == "hex":
|
||||
r, g, b = hex_to_rgb(value)
|
||||
elif from_format == "rgb":
|
||||
r, g, b = parse_rgb(value)
|
||||
elif from_format == "hsl":
|
||||
h, s, l = parse_hsl(value)
|
||||
r, g, b = hsl_to_rgb(h, s, l)
|
||||
else:
|
||||
return jsonify({"message": "Unbekanntes Format"}), 400
|
||||
|
||||
hex_val = rgb_to_hex(r, g, b)
|
||||
hue, sat, lig = rgb_to_hsl(r, g, b)
|
||||
|
||||
logger.info(f"Farbe konvertiert von {user['username']}")
|
||||
return jsonify({
|
||||
"hex": hex_val,
|
||||
"rgb": f"rgb({r}, {g}, {b})",
|
||||
"hsl": f"hsl({hue}, {sat}%, {lig}%)",
|
||||
})
|
||||
except ValueError as e:
|
||||
return jsonify({"message": str(e)}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler Farbkonverter: {e}")
|
||||
return jsonify({"message": "Fehler bei der Konvertierung"}), 500
|
||||
@@ -0,0 +1,236 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from datetime import datetime, timedelta
|
||||
from util.logger import logger
|
||||
from auth.token import verify_token
|
||||
|
||||
cron_blueprint = Blueprint('cron_tool', __name__)
|
||||
|
||||
MONTH_NAMES = {
|
||||
'1': 'Januar', '2': 'Februar', '3': 'März', '4': 'April',
|
||||
'5': 'Mai', '6': 'Juni', '7': 'Juli', '8': 'August',
|
||||
'9': 'September', '10': 'Oktober', '11': 'November', '12': 'Dezember',
|
||||
}
|
||||
WEEKDAY_NAMES = {
|
||||
'0': 'Sonntag', '1': 'Montag', '2': 'Dienstag', '3': 'Mittwoch',
|
||||
'4': 'Donnerstag', '5': 'Freitag', '6': 'Samstag', '7': 'Sonntag',
|
||||
}
|
||||
|
||||
|
||||
def parse_field(field, min_val, max_val):
|
||||
values = set()
|
||||
for part in field.split(','):
|
||||
part = part.strip()
|
||||
if part == '*':
|
||||
values.update(range(min_val, max_val + 1))
|
||||
elif '/' in part:
|
||||
base, step = part.split('/', 1)
|
||||
step = int(step)
|
||||
if base == '*':
|
||||
start, end = min_val, max_val
|
||||
elif '-' in base:
|
||||
s, e = base.split('-')
|
||||
start, end = int(s), int(e)
|
||||
else:
|
||||
start, end = int(base), max_val
|
||||
values.update(range(start, end + 1, step))
|
||||
elif '-' in part:
|
||||
s, e = part.split('-')
|
||||
values.update(range(int(s), int(e) + 1))
|
||||
else:
|
||||
values.add(int(part))
|
||||
return sorted(v for v in values if min_val <= v <= max_val)
|
||||
|
||||
|
||||
def explain_field_text(field, unit, plural, labels=None):
|
||||
if field == '*':
|
||||
return f"Jede(n) {unit}"
|
||||
parts = field.split(',')
|
||||
described = []
|
||||
for p in parts:
|
||||
if p.startswith('*/'):
|
||||
described.append(f"alle {p[2:]} {plural}")
|
||||
elif '-' in p and '/' not in p:
|
||||
a, b = p.split('-', 1)
|
||||
a_l = labels.get(a, a) if labels else a
|
||||
b_l = labels.get(b, b) if labels else b
|
||||
described.append(f"{a_l} bis {b_l}")
|
||||
elif '/' in p:
|
||||
base, step = p.split('/', 1)
|
||||
if '-' in base:
|
||||
a, b = base.split('-', 1)
|
||||
described.append(f"{a}–{b} alle {step}")
|
||||
else:
|
||||
described.append(f"ab {base} alle {step}")
|
||||
else:
|
||||
described.append(labels.get(p, p) if labels else p)
|
||||
return ', '.join(described)
|
||||
|
||||
|
||||
def build_summary(minute, hour, day, month, weekday):
|
||||
parts = []
|
||||
|
||||
if minute == '*' and hour == '*':
|
||||
parts.append("Jede Minute")
|
||||
elif minute.startswith('*/') and hour == '*':
|
||||
parts.append(f"Alle {minute[2:]} Minuten")
|
||||
elif hour != '*' and minute == '0':
|
||||
h = explain_field_text(hour, 'Stunde', 'Stunden')
|
||||
parts.append(f"Um {h}:00 Uhr")
|
||||
else:
|
||||
parts.append(f"Minute: {minute}, Stunde: {hour}")
|
||||
|
||||
if weekday != '*' and day == '*':
|
||||
wd = explain_field_text(weekday, 'Wochentag', 'Wochentage', WEEKDAY_NAMES)
|
||||
parts.append(f"jeden {wd}")
|
||||
elif day != '*' and weekday == '*':
|
||||
parts.append(f"am {day}. des Monats")
|
||||
elif day != '*' and weekday != '*':
|
||||
wd = explain_field_text(weekday, 'Wochentag', 'Wochentage', WEEKDAY_NAMES)
|
||||
parts.append(f"am {day}. oder {wd}")
|
||||
|
||||
if month != '*':
|
||||
m = explain_field_text(month, 'Monat', 'Monate', MONTH_NAMES)
|
||||
parts.append(f"im {m}")
|
||||
|
||||
return ', '.join(parts)
|
||||
|
||||
|
||||
def get_next_runs(minute_vals, hour_vals, day_vals, month_vals, weekday_vals):
|
||||
"""Return up to 5 upcoming run times.
|
||||
|
||||
Uses targeted jumps (skip month, day, hour) instead of iterating every
|
||||
minute, so performance is acceptable even for rare expressions like
|
||||
'0 0 29 2 *' (Feb 29 at midnight).
|
||||
"""
|
||||
# cron weekday 0=Sun..6=Sat,7=Sun → Python weekday 0=Mon..6=Sun
|
||||
py_weekdays = set((wd + 6) % 7 for wd in weekday_vals)
|
||||
|
||||
minute_set = set(minute_vals)
|
||||
hour_set = set(hour_vals)
|
||||
day_set = set(day_vals)
|
||||
month_set = set(month_vals)
|
||||
|
||||
minute_list = sorted(minute_vals)
|
||||
hour_list = sorted(hour_vals)
|
||||
month_list = sorted(month_vals)
|
||||
|
||||
if not minute_list or not hour_list or not month_list:
|
||||
return []
|
||||
|
||||
now = datetime.now().replace(second=0, microsecond=0) + timedelta(minutes=1)
|
||||
max_date = now + timedelta(days=4 * 366)
|
||||
results = []
|
||||
current = now
|
||||
|
||||
def _advance_from_results():
|
||||
"""Move current to the next candidate after a match."""
|
||||
nonlocal current
|
||||
next_mins = [m for m in minute_list if m > current.minute]
|
||||
if next_mins:
|
||||
current = current.replace(minute=next_mins[0])
|
||||
return
|
||||
next_hours = [h for h in hour_list if h > current.hour]
|
||||
if next_hours:
|
||||
current = current.replace(hour=next_hours[0], minute=minute_list[0])
|
||||
return
|
||||
current = (current + timedelta(days=1)).replace(hour=hour_list[0], minute=minute_list[0])
|
||||
|
||||
while current <= max_date and len(results) < 5:
|
||||
# ── month ──────────────────────────────────────────────────────────
|
||||
if current.month not in month_set:
|
||||
next_months = [m for m in month_list if m > current.month]
|
||||
if next_months:
|
||||
current = current.replace(
|
||||
month=next_months[0], day=1,
|
||||
hour=hour_list[0], minute=minute_list[0]
|
||||
)
|
||||
else:
|
||||
current = current.replace(
|
||||
year=current.year + 1, month=month_list[0], day=1,
|
||||
hour=hour_list[0], minute=minute_list[0]
|
||||
)
|
||||
continue
|
||||
|
||||
# ── day + weekday ──────────────────────────────────────────────────
|
||||
if current.day not in day_set or current.weekday() not in py_weekdays:
|
||||
current = (current + timedelta(days=1)).replace(
|
||||
hour=hour_list[0], minute=minute_list[0]
|
||||
)
|
||||
continue
|
||||
|
||||
# ── hour ───────────────────────────────────────────────────────────
|
||||
if current.hour not in hour_set:
|
||||
next_hours = [h for h in hour_list if h > current.hour]
|
||||
if next_hours:
|
||||
current = current.replace(hour=next_hours[0], minute=minute_list[0])
|
||||
else:
|
||||
current = (current + timedelta(days=1)).replace(
|
||||
hour=hour_list[0], minute=minute_list[0]
|
||||
)
|
||||
continue
|
||||
|
||||
# ── minute ─────────────────────────────────────────────────────────
|
||||
if current.minute not in minute_set:
|
||||
next_mins = [m for m in minute_list if m > current.minute]
|
||||
if next_mins:
|
||||
current = current.replace(minute=next_mins[0])
|
||||
else:
|
||||
next_hours = [h for h in hour_list if h > current.hour]
|
||||
if next_hours:
|
||||
current = current.replace(hour=next_hours[0], minute=minute_list[0])
|
||||
else:
|
||||
current = (current + timedelta(days=1)).replace(
|
||||
hour=hour_list[0], minute=minute_list[0]
|
||||
)
|
||||
continue
|
||||
|
||||
# ── match ──────────────────────────────────────────────────────────
|
||||
results.append(current.isoformat())
|
||||
_advance_from_results()
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@cron_blueprint.route('/api/cron/explain', methods=['POST'])
|
||||
def explain_cron():
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
expression = data.get("expression", "").strip()
|
||||
|
||||
fields = expression.split()
|
||||
if len(fields) != 5:
|
||||
return jsonify({"error": "Cron-Ausdruck muss genau 5 Felder haben (Minute Stunde Tag Monat Wochentag)"}), 400
|
||||
|
||||
minute_f, hour_f, day_f, month_f, weekday_f = fields
|
||||
|
||||
try:
|
||||
minute_vals = parse_field(minute_f, 0, 59)
|
||||
hour_vals = parse_field(hour_f, 0, 23)
|
||||
day_vals = parse_field(day_f, 1, 31)
|
||||
month_vals = parse_field(month_f, 1, 12)
|
||||
weekday_vals = parse_field(weekday_f, 0, 7)
|
||||
except (ValueError, ZeroDivisionError) as e:
|
||||
return jsonify({"error": f"Ungültiger Cron-Ausdruck: {e}"}), 400
|
||||
|
||||
field_descriptions = {
|
||||
"minute": explain_field_text(minute_f, 'Minute', 'Minuten'),
|
||||
"hour": explain_field_text(hour_f, 'Stunde', 'Stunden'),
|
||||
"day": explain_field_text(day_f, 'Tag', 'Tage'),
|
||||
"month": explain_field_text(month_f, 'Monat', 'Monate', MONTH_NAMES),
|
||||
"weekday": explain_field_text(weekday_f, 'Wochentag', 'Wochentage', WEEKDAY_NAMES),
|
||||
}
|
||||
|
||||
next_runs = get_next_runs(minute_vals, hour_vals, day_vals, month_vals, weekday_vals)
|
||||
|
||||
return jsonify({
|
||||
"explanation": build_summary(minute_f, hour_f, day_f, month_f, weekday_f),
|
||||
"fields": field_descriptions,
|
||||
"next_runs": next_runs,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler cron explain: {e}")
|
||||
return jsonify({"error": "Fehler beim Verarbeiten des Ausdrucks"}), 500
|
||||
@@ -0,0 +1,52 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
import csv
|
||||
import io
|
||||
from util.logger import logger
|
||||
from auth.token import verify_token
|
||||
|
||||
csv_blueprint = Blueprint('csv_tool', __name__)
|
||||
|
||||
MAX_ROWS = 500
|
||||
|
||||
|
||||
@csv_blueprint.route('/api/csv/parse', methods=['POST'])
|
||||
def parse_csv():
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
text = data.get("text", "")
|
||||
delimiter = data.get("delimiter", ",")
|
||||
|
||||
# Handle escaped tab
|
||||
if delimiter == "\\t" or delimiter == "\t":
|
||||
delimiter = "\t"
|
||||
if not delimiter:
|
||||
delimiter = ","
|
||||
if len(delimiter) != 1:
|
||||
return jsonify({"message": "Delimiter muss genau ein Zeichen sein"}), 400
|
||||
|
||||
reader = csv.reader(io.StringIO(text), delimiter=delimiter)
|
||||
all_rows = list(reader)
|
||||
|
||||
if not all_rows:
|
||||
return jsonify({"headers": [], "rows": [], "total_rows": 0, "truncated": False})
|
||||
|
||||
headers = all_rows[0]
|
||||
data_rows = all_rows[1:]
|
||||
total_rows = len(data_rows)
|
||||
truncated = total_rows > MAX_ROWS
|
||||
|
||||
return jsonify({
|
||||
"headers": headers,
|
||||
"rows": data_rows[:MAX_ROWS],
|
||||
"total_rows": total_rows,
|
||||
"truncated": truncated,
|
||||
})
|
||||
|
||||
except csv.Error as e:
|
||||
return jsonify({"message": f"Ungültiges CSV: {e}"}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler csvviewer: {e}")
|
||||
return jsonify({"message": "Fehler beim Verarbeiten des CSV"}), 500
|
||||
@@ -0,0 +1,39 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
import hashlib
|
||||
import bcrypt
|
||||
from util.logger import logger
|
||||
from auth.token import verify_token
|
||||
|
||||
hasher_blueprint = Blueprint('hasher_tool', __name__)
|
||||
|
||||
|
||||
@hasher_blueprint.route('/api/hash/sha256', methods=['POST'])
|
||||
def hash_sha256():
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
text = data.get("text", "")
|
||||
result = hashlib.sha256(text.encode()).hexdigest()
|
||||
logger.info(f"SHA256 erstellt von {user['username']}")
|
||||
return jsonify({"sha256": result, "by": user['username']})
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler SHA256: {e}")
|
||||
return jsonify({"message": "Fehler beim Hashen"}), 500
|
||||
|
||||
|
||||
@hasher_blueprint.route('/api/hash/bcrypt', methods=['POST'])
|
||||
def hash_bcrypt():
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
text = data.get("text", "")
|
||||
hashed = bcrypt.hashpw(text.encode(), bcrypt.gensalt()).decode()
|
||||
logger.info(f"bcrypt erstellt von {user['username']}")
|
||||
return jsonify({"bcrypt": hashed, "by": user['username']})
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler bcrypt: {e}")
|
||||
return jsonify({"message": "Fehler beim Hashen"}), 500
|
||||
@@ -0,0 +1,39 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
import hashlib
|
||||
import bcrypt
|
||||
from util.logger import logger
|
||||
from auth.token import verify_token
|
||||
|
||||
hashverifier_blueprint = Blueprint('hashverifier_tool', __name__)
|
||||
|
||||
|
||||
@hashverifier_blueprint.route('/api/hash/verify', methods=['POST'])
|
||||
def verify_hash():
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
text = data.get("text", "")
|
||||
hash_val = data.get("hash", "").strip()
|
||||
algorithm = data.get("algorithm", "sha256")
|
||||
|
||||
if algorithm == "md5":
|
||||
computed = hashlib.md5(text.encode()).hexdigest()
|
||||
match = computed.lower() == hash_val.lower()
|
||||
elif algorithm == "sha256":
|
||||
computed = hashlib.sha256(text.encode()).hexdigest()
|
||||
match = computed.lower() == hash_val.lower()
|
||||
elif algorithm == "bcrypt":
|
||||
try:
|
||||
match = bcrypt.checkpw(text.encode(), hash_val.encode())
|
||||
except Exception:
|
||||
return jsonify({"message": "Ungültiger bcrypt-Hash"}), 400
|
||||
else:
|
||||
return jsonify({"message": "Unbekannter Algorithmus"}), 400
|
||||
|
||||
logger.info(f"Hash verify ({algorithm}) von {user['username']}: {match}")
|
||||
return jsonify({"match": match})
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler bei Hash-Verifikation: {e}")
|
||||
return jsonify({"message": "Fehler bei der Verifikation"}), 500
|
||||
@@ -0,0 +1,64 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
import ipaddress
|
||||
from util.logger import logger
|
||||
from auth.token import verify_token
|
||||
|
||||
ipcalc_blueprint = Blueprint('ipcalc_tool', __name__)
|
||||
|
||||
|
||||
@ipcalc_blueprint.route('/api/ip/calculate', methods=['POST'])
|
||||
def ip_calculate():
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
cidr = data.get("cidr", "").strip()
|
||||
|
||||
try:
|
||||
network = ipaddress.IPv4Network(cidr, strict=False)
|
||||
except ValueError as e:
|
||||
return jsonify({"message": f"Ungültige CIDR-Notation: {e}"}), 400
|
||||
|
||||
prefix = network.prefixlen
|
||||
net_int = int(network.network_address)
|
||||
bcast_int = int(network.broadcast_address)
|
||||
|
||||
# Avoid materialising millions of host objects for large networks.
|
||||
if prefix == 32:
|
||||
# Single-host route: the address is both network and host.
|
||||
total_hosts = 1
|
||||
first_host = str(network.network_address)
|
||||
last_host = str(network.network_address)
|
||||
elif prefix == 31:
|
||||
# RFC 3021 point-to-point: both addresses are usable hosts,
|
||||
# there is no dedicated network or broadcast address.
|
||||
total_hosts = 2
|
||||
first_host = str(network.network_address)
|
||||
last_host = str(network.broadcast_address)
|
||||
else:
|
||||
total_hosts = network.num_addresses - 2
|
||||
first_host = str(ipaddress.IPv4Address(net_int + 1))
|
||||
last_host = str(ipaddress.IPv4Address(bcast_int - 1))
|
||||
|
||||
netmask_int = int(network.netmask)
|
||||
# Python's ~ on an int yields a negative arbitrary-precision value;
|
||||
# mask to 32 bits to get the correct unsigned wildcard address.
|
||||
wildcard = str(ipaddress.IPv4Address((~netmask_int) & 0xFFFFFFFF))
|
||||
ip_class = "Privat" if network.is_private else "Öffentlich"
|
||||
|
||||
return jsonify({
|
||||
"network": str(network.network_address),
|
||||
"broadcast": str(network.broadcast_address),
|
||||
"netmask": str(network.netmask),
|
||||
"wildcard": wildcard,
|
||||
"first_host": first_host,
|
||||
"last_host": last_host,
|
||||
"total_hosts": total_hosts,
|
||||
"prefix_length": prefix,
|
||||
"ip_class": ip_class,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler ipcalc: {e}")
|
||||
return jsonify({"message": "Fehler bei der Berechnung"}), 500
|
||||
@@ -0,0 +1,26 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
import json
|
||||
from util.logger import logger
|
||||
from auth.token import verify_token
|
||||
|
||||
json_formatter_blueprint = Blueprint('json_formatter_tool', __name__)
|
||||
|
||||
|
||||
@json_formatter_blueprint.route('/api/json/format', methods=['POST'])
|
||||
def format_json():
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
text = data.get("text", "")
|
||||
indent = int(data.get("indent", 2))
|
||||
|
||||
parsed = json.loads(text)
|
||||
formatted = json.dumps(parsed, indent=indent, ensure_ascii=False)
|
||||
return jsonify({"result": formatted})
|
||||
except json.JSONDecodeError as e:
|
||||
return jsonify({"message": f"JSON-Fehler in Zeile {e.lineno}, Spalte {e.colno}: {e.msg}"}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler JSON-Formatter: {e}")
|
||||
return jsonify({"message": "Fehler beim Formatieren"}), 500
|
||||
@@ -0,0 +1,29 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
import jwt
|
||||
from datetime import datetime, timezone
|
||||
from util.logger import logger
|
||||
from auth.token import verify_token
|
||||
|
||||
jwt_decoder_blueprint = Blueprint('jwt_decoder_tool', __name__)
|
||||
|
||||
|
||||
@jwt_decoder_blueprint.route('/api/jwt/decode', methods=['POST'])
|
||||
def decode_jwt():
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
token = data.get("token", "").strip()
|
||||
header = jwt.get_unverified_header(token)
|
||||
payload = jwt.decode(token, options={"verify_signature": False}, algorithms=["HS256", "HS384", "HS512", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512"])
|
||||
|
||||
expired = False
|
||||
if "exp" in payload:
|
||||
expired = payload["exp"] < datetime.now(timezone.utc).timestamp()
|
||||
|
||||
logger.info(f"JWT dekodiert von {user['username']}")
|
||||
return jsonify({"header": header, "payload": payload, "expired": expired})
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler JWT decode: {e}")
|
||||
return jsonify({"message": "Ungültiger JWT Token"}), 400
|
||||
@@ -0,0 +1,66 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
import random
|
||||
from util.logger import logger
|
||||
from auth.token import verify_token
|
||||
|
||||
lorem_blueprint = Blueprint('lorem_tool', __name__)
|
||||
|
||||
WORDS = [
|
||||
"lorem", "ipsum", "dolor", "sit", "amet", "consectetur", "adipiscing", "elit",
|
||||
"sed", "do", "eiusmod", "tempor", "incididunt", "ut", "labore", "et", "dolore",
|
||||
"magna", "aliqua", "enim", "ad", "minim", "veniam", "quis", "nostrud",
|
||||
"exercitation", "ullamco", "laboris", "nisi", "aliquip", "ex", "ea", "commodo",
|
||||
"consequat", "duis", "aute", "irure", "reprehenderit", "voluptate", "velit",
|
||||
"esse", "cillum", "eu", "fugiat", "nulla", "pariatur", "excepteur", "sint",
|
||||
"occaecat", "cupidatat", "non", "proident", "sunt", "culpa", "qui", "officia",
|
||||
"deserunt", "mollit", "anim", "id", "est", "laborum", "perspiciatis", "unde",
|
||||
"omnis", "iste", "natus", "error", "voluptatem", "accusantium", "doloremque",
|
||||
"laudantium", "totam", "rem", "aperiam", "eaque", "ipsa", "quae", "ab", "illo",
|
||||
"inventore", "veritatis", "quasi", "architecto", "beatae", "vitae", "dicta",
|
||||
"explicabo", "nemo", "ipsam", "quia", "voluptas", "aspernatur", "odit",
|
||||
"fugit", "magni", "dolores", "ratione", "sequi", "nesciunt", "neque", "porro",
|
||||
"quisquam", "adipisci", "numquam", "eius", "modi", "tempora", "incidunt",
|
||||
"soluta", "nobis", "eligendi", "optio", "cumque", "nihil", "impedit", "minus",
|
||||
"maxime", "placeat", "facere", "possimus", "omnis", "assumenda", "repellendus",
|
||||
]
|
||||
|
||||
|
||||
def make_sentence():
|
||||
word_count = random.randint(8, 15)
|
||||
words = [random.choice(WORDS) for _ in range(word_count)]
|
||||
return words[0].capitalize() + ' ' + ' '.join(words[1:]) + '.'
|
||||
|
||||
|
||||
def make_paragraph():
|
||||
sentence_count = random.randint(4, 6)
|
||||
return ' '.join(make_sentence() for _ in range(sentence_count))
|
||||
|
||||
|
||||
@lorem_blueprint.route('/api/lorem/generate', methods=['POST'])
|
||||
def generate_lorem():
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
gen_type = data.get("type", "sentences")
|
||||
try:
|
||||
count = int(data.get("count", 3))
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({"message": "count muss eine ganze Zahl sein"}), 400
|
||||
count = max(1, min(20, count))
|
||||
|
||||
if gen_type == "words":
|
||||
text = ' '.join(random.choice(WORDS) for _ in range(count))
|
||||
elif gen_type == "sentences":
|
||||
text = ' '.join(make_sentence() for _ in range(count))
|
||||
elif gen_type == "paragraphs":
|
||||
text = '\n\n'.join(make_paragraph() for _ in range(count))
|
||||
else:
|
||||
return jsonify({"message": "Ungültiger Typ"}), 400
|
||||
|
||||
return jsonify({"text": text})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler lorem ipsum: {e}")
|
||||
return jsonify({"message": "Fehler bei der Generierung"}), 500
|
||||
@@ -0,0 +1,21 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
import markdown
|
||||
from util.logger import logger
|
||||
from auth.token import verify_token
|
||||
|
||||
markdown_blueprint = Blueprint('markdown_tool', __name__)
|
||||
|
||||
|
||||
@markdown_blueprint.route('/api/markdown/render', methods=['POST'])
|
||||
def render_markdown():
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
text = data.get("text", "")
|
||||
html = markdown.markdown(text, extensions=["tables", "fenced_code", "nl2br"])
|
||||
return jsonify({"html": html})
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler Markdown: {e}")
|
||||
return jsonify({"message": "Fehler beim Rendern"}), 500
|
||||
@@ -15,8 +15,7 @@ def hash_md5():
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
logger.debug(f"📩 Payload: {data}")
|
||||
data = request.get_json(silent=True) or {}
|
||||
password = data.get("password", "")
|
||||
|
||||
result = hashlib.md5(password.encode()).hexdigest()
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from datetime import datetime, timezone
|
||||
from util.logger import logger
|
||||
from util.db_pool import get_connection
|
||||
from auth.token import verify_token
|
||||
|
||||
notes_blueprint = Blueprint('notes_tool', __name__)
|
||||
|
||||
|
||||
@notes_blueprint.route('/api/notes', methods=['GET'])
|
||||
def get_notes():
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
cursor.execute(
|
||||
"SELECT id, title, content, language, created_at, updated_at "
|
||||
"FROM notes WHERE user_id = %s ORDER BY updated_at DESC",
|
||||
(user['id'],)
|
||||
)
|
||||
notes = cursor.fetchall()
|
||||
cursor.close()
|
||||
finally:
|
||||
conn.close()
|
||||
for n in notes:
|
||||
if n.get('created_at'):
|
||||
n['created_at'] = n['created_at'].isoformat()
|
||||
if n.get('updated_at'):
|
||||
n['updated_at'] = n['updated_at'].isoformat()
|
||||
return jsonify(notes)
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler notes GET: {e}")
|
||||
return jsonify({"message": "Fehler beim Laden"}), 500
|
||||
|
||||
|
||||
@notes_blueprint.route('/api/notes', methods=['POST'])
|
||||
def create_note():
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
title = data.get("title", "Neue Notiz").strip() or "Neue Notiz"
|
||||
content = data.get("content", "")
|
||||
language = data.get("language", "text")
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO notes (user_id, title, content, language) VALUES (%s, %s, %s, %s)",
|
||||
(user['id'], title, content, language)
|
||||
)
|
||||
conn.commit()
|
||||
note_id = cursor.lastrowid
|
||||
cursor.close()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info(f"Notiz erstellt von {user['username']}: id={note_id}")
|
||||
return jsonify({"id": note_id, "title": title, "content": content, "language": language})
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler notes POST: {e}")
|
||||
return jsonify({"message": "Fehler beim Erstellen"}), 500
|
||||
|
||||
|
||||
@notes_blueprint.route('/api/notes/<int:note_id>', methods=['PUT'])
|
||||
def update_note(note_id):
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
title = data.get("title", "").strip() or "Neue Notiz"
|
||||
content = data.get("content", "")
|
||||
language = data.get("language", "text")
|
||||
# mysql-connector expects a naive datetime for DATETIME columns;
|
||||
# strip tzinfo after converting to UTC to avoid driver warnings.
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE notes SET title=%s, content=%s, language=%s, updated_at=%s "
|
||||
"WHERE id=%s AND user_id=%s",
|
||||
(title, content, language, now, note_id, user['id'])
|
||||
)
|
||||
conn.commit()
|
||||
affected = cursor.rowcount
|
||||
cursor.close()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if affected == 0:
|
||||
return jsonify({"message": "Notiz nicht gefunden"}), 404
|
||||
return jsonify({"ok": True})
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler notes PUT: {e}")
|
||||
return jsonify({"message": "Fehler beim Speichern"}), 500
|
||||
|
||||
|
||||
@notes_blueprint.route('/api/notes/<int:note_id>', methods=['DELETE'])
|
||||
def delete_note(note_id):
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"DELETE FROM notes WHERE id=%s AND user_id=%s",
|
||||
(note_id, user['id'])
|
||||
)
|
||||
conn.commit()
|
||||
affected = cursor.rowcount
|
||||
cursor.close()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if affected == 0:
|
||||
return jsonify({"message": "Notiz nicht gefunden"}), 404
|
||||
return jsonify({"ok": True})
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler notes DELETE: {e}")
|
||||
return jsonify({"message": "Fehler beim Löschen"}), 500
|
||||
@@ -0,0 +1,41 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
import secrets
|
||||
import string
|
||||
from util.logger import logger
|
||||
from auth.token import verify_token
|
||||
|
||||
passwordgen_blueprint = Blueprint('passwordgen_tool', __name__)
|
||||
|
||||
|
||||
@passwordgen_blueprint.route('/api/password/generate', methods=['POST'])
|
||||
def generate_password():
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
length = min(max(int(data.get("length", 16)), 8), 64)
|
||||
use_uppercase = data.get("uppercase", True)
|
||||
use_lowercase = data.get("lowercase", True)
|
||||
use_numbers = data.get("numbers", True)
|
||||
use_symbols = data.get("symbols", False)
|
||||
|
||||
charset = ""
|
||||
if use_uppercase:
|
||||
charset += string.ascii_uppercase
|
||||
if use_lowercase:
|
||||
charset += string.ascii_lowercase
|
||||
if use_numbers:
|
||||
charset += string.digits
|
||||
if use_symbols:
|
||||
charset += string.punctuation
|
||||
|
||||
if not charset:
|
||||
return jsonify({"message": "Mindestens ein Zeichensatz muss ausgewählt sein"}), 400
|
||||
|
||||
password = ''.join(secrets.choice(charset) for _ in range(length))
|
||||
logger.info(f"Passwort generiert von {user['username']}")
|
||||
return jsonify({"password": password})
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler Passwortgenerator: {e}")
|
||||
return jsonify({"message": "Fehler beim Generieren"}), 500
|
||||
@@ -0,0 +1,35 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
import qrcode
|
||||
import io
|
||||
import base64
|
||||
from util.logger import logger
|
||||
from auth.token import verify_token
|
||||
|
||||
qrcode_blueprint = Blueprint('qrcode_tool', __name__)
|
||||
|
||||
|
||||
@qrcode_blueprint.route('/api/qrcode/generate', methods=['POST'])
|
||||
def generate_qrcode():
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
text = data.get("text", "").strip()
|
||||
if not text:
|
||||
return jsonify({"message": "Text darf nicht leer sein"}), 400
|
||||
|
||||
qr = qrcode.QRCode(box_size=10, border=4)
|
||||
qr.add_data(text)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
b64 = base64.b64encode(buffer.getvalue()).decode()
|
||||
|
||||
logger.info(f"QR-Code generiert von {user['username']}")
|
||||
return jsonify({"image": f"data:image/png;base64,{b64}"})
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler QR-Code: {e}")
|
||||
return jsonify({"message": "Fehler beim Generieren"}), 500
|
||||
@@ -0,0 +1,41 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
import re
|
||||
from util.logger import logger
|
||||
from auth.token import verify_token
|
||||
|
||||
regex_blueprint = Blueprint('regex_tool', __name__)
|
||||
|
||||
_FLAG_MAP = {"i": re.IGNORECASE, "m": re.MULTILINE, "s": re.DOTALL}
|
||||
|
||||
|
||||
@regex_blueprint.route('/api/regex/test', methods=['POST'])
|
||||
def test_regex():
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
pattern = data.get("pattern", "")
|
||||
text = data.get("text", "")
|
||||
flags_list = data.get("flags", [])
|
||||
|
||||
combined = 0
|
||||
for f in flags_list:
|
||||
if f in _FLAG_MAP:
|
||||
combined |= _FLAG_MAP[f]
|
||||
|
||||
try:
|
||||
compiled = re.compile(pattern, combined)
|
||||
except re.error as e:
|
||||
return jsonify({"matches": [], "count": 0, "error": str(e)})
|
||||
|
||||
matches = [
|
||||
{"match": m.group(), "start": m.start(), "end": m.end(), "groups": list(m.groups())}
|
||||
for m in compiled.finditer(text)
|
||||
]
|
||||
|
||||
logger.info(f"Regex getestet von {user['username']}")
|
||||
return jsonify({"matches": matches, "count": len(matches), "error": None})
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler Regex-Tester: {e}")
|
||||
return jsonify({"message": "Fehler beim Testen"}), 500
|
||||
@@ -0,0 +1,53 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from collections import Counter
|
||||
import re
|
||||
from util.logger import logger
|
||||
from auth.token import verify_token
|
||||
|
||||
stringutils_blueprint = Blueprint('stringutils_tool', __name__)
|
||||
|
||||
|
||||
@stringutils_blueprint.route('/api/string/analyze', methods=['POST'])
|
||||
def string_analyze():
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
text = data.get("text", "")
|
||||
operation = data.get("operation", "stats")
|
||||
|
||||
if operation == "stats":
|
||||
words = text.split() if text.strip() else []
|
||||
lines = text.split('\n')
|
||||
return jsonify({
|
||||
"operation": "stats",
|
||||
"chars": len(text),
|
||||
"chars_no_spaces": len(text.replace(' ', '')),
|
||||
"words": len(words),
|
||||
"lines": len(lines),
|
||||
"spaces": text.count(' '),
|
||||
})
|
||||
elif operation == "uppercase":
|
||||
return jsonify({"operation": "uppercase", "result": text.upper()})
|
||||
elif operation == "lowercase":
|
||||
return jsonify({"operation": "lowercase", "result": text.lower()})
|
||||
elif operation == "titlecase":
|
||||
return jsonify({"operation": "titlecase", "result": text.title()})
|
||||
elif operation == "reverse":
|
||||
return jsonify({"operation": "reverse", "result": text[::-1]})
|
||||
elif operation == "trim":
|
||||
return jsonify({"operation": "trim", "result": text.strip()})
|
||||
elif operation == "remove_spaces":
|
||||
return jsonify({"operation": "remove_spaces", "result": text.replace(' ', '')})
|
||||
elif operation == "count_words":
|
||||
words = re.findall(r'\b\w+\b', text.lower())
|
||||
counter = Counter(words)
|
||||
top10 = [{"word": w, "count": c} for w, c in counter.most_common(10)]
|
||||
return jsonify({"operation": "count_words", "words": top10})
|
||||
else:
|
||||
return jsonify({"message": "Unbekannte Operation"}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler stringutils: {e}")
|
||||
return jsonify({"message": "Fehler bei der Verarbeitung"}), 500
|
||||
@@ -0,0 +1,37 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
import difflib
|
||||
from util.logger import logger
|
||||
from auth.token import verify_token
|
||||
|
||||
textdiff_blueprint = Blueprint('textdiff_tool', __name__)
|
||||
|
||||
|
||||
@textdiff_blueprint.route('/api/text/diff', methods=['POST'])
|
||||
def text_diff():
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
text1 = data.get("text1", "")
|
||||
text2 = data.get("text2", "")
|
||||
|
||||
lines1 = text1.splitlines(keepends=True)
|
||||
lines2 = text2.splitlines(keepends=True)
|
||||
|
||||
result = []
|
||||
for line in difflib.unified_diff(lines1, lines2, lineterm=''):
|
||||
if line.startswith('+++') or line.startswith('---') or line.startswith('@@'):
|
||||
continue
|
||||
if line.startswith('+'):
|
||||
result.append({"type": "added", "text": line[1:]})
|
||||
elif line.startswith('-'):
|
||||
result.append({"type": "removed", "text": line[1:]})
|
||||
else:
|
||||
result.append({"type": "unchanged", "text": line[1:] if line.startswith(' ') else line})
|
||||
|
||||
logger.info(f"Text-Diff erstellt von {user['username']}")
|
||||
return jsonify({"diff": result})
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler Text-Diff: {e}")
|
||||
return jsonify({"message": "Fehler beim Vergleich"}), 500
|
||||
@@ -0,0 +1,46 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from datetime import datetime, timezone
|
||||
from util.logger import logger
|
||||
from auth.token import verify_token
|
||||
|
||||
timestamp_blueprint = Blueprint('timestamp_tool', __name__)
|
||||
|
||||
_DATE_FORMATS = [
|
||||
"%Y-%m-%dT%H:%M:%S",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
"%Y-%m-%d",
|
||||
"%d.%m.%Y %H:%M:%S",
|
||||
"%d.%m.%Y",
|
||||
]
|
||||
|
||||
|
||||
@timestamp_blueprint.route('/api/timestamp/convert', methods=['POST'])
|
||||
def convert_timestamp():
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
value = data.get("value", "").strip()
|
||||
direction = data.get("direction", "unix_to_date")
|
||||
|
||||
if direction == "unix_to_date":
|
||||
ts = float(value)
|
||||
dt_utc = datetime.fromtimestamp(ts, tz=timezone.utc)
|
||||
return jsonify({"utc": dt_utc.isoformat(), "unix": int(ts)})
|
||||
|
||||
dt = None
|
||||
for fmt in _DATE_FORMATS:
|
||||
try:
|
||||
dt = datetime.strptime(value, fmt).replace(tzinfo=timezone.utc)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
if dt is None:
|
||||
return jsonify({"message": "Ungültiges Datumsformat"}), 400
|
||||
|
||||
logger.info(f"Timestamp konvertiert von {user['username']}")
|
||||
return jsonify({"unix": int(dt.timestamp()), "utc": dt.isoformat()})
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler Timestamp: {e}")
|
||||
return jsonify({"message": "Ungültiger Wert"}), 400
|
||||
@@ -0,0 +1,36 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from urllib.parse import quote, unquote
|
||||
from util.logger import logger
|
||||
from auth.token import verify_token
|
||||
|
||||
url_blueprint = Blueprint('url_tool', __name__)
|
||||
|
||||
|
||||
@url_blueprint.route('/api/url/encode', methods=['POST'])
|
||||
def url_encode():
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
text = data.get("text", "")
|
||||
result = quote(text, safe='')
|
||||
return jsonify({"result": result})
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler URL encode: {e}")
|
||||
return jsonify({"message": "Fehler beim Encoding"}), 500
|
||||
|
||||
|
||||
@url_blueprint.route('/api/url/decode', methods=['POST'])
|
||||
def url_decode():
|
||||
user = verify_token()
|
||||
if not user:
|
||||
return jsonify({"message": "Nicht autorisiert"}), 401
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
text = data.get("text", "")
|
||||
result = unquote(text)
|
||||
return jsonify({"result": result})
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler URL decode: {e}")
|
||||
return jsonify({"message": "Fehler beim Decoding"}), 500
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
+30
-9
@@ -1,19 +1,40 @@
|
||||
import logging
|
||||
import os
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
# Ensure logs directory exists
|
||||
os.makedirs("logs", exist_ok=True)
|
||||
# Absolute path so the log dir is always next to this file, regardless of CWD.
|
||||
_LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "logs")
|
||||
os.makedirs(_LOG_DIR, exist_ok=True)
|
||||
|
||||
_FMT = "%(asctime)s [%(levelname)s] %(message)s"
|
||||
_formatter = logging.Formatter(_FMT)
|
||||
|
||||
_MAX_BYTES = 5 * 1024 * 1024 # 5 MB per file
|
||||
_BACKUP_COUNT = 3
|
||||
|
||||
|
||||
def _rotating(filename, level):
|
||||
h = RotatingFileHandler(
|
||||
os.path.join(_LOG_DIR, filename),
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
h.setLevel(level)
|
||||
h.setFormatter(_formatter)
|
||||
return h
|
||||
|
||||
|
||||
_console = logging.StreamHandler()
|
||||
_console.setFormatter(_formatter)
|
||||
|
||||
# Configure logger
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler("logs/app.log"),
|
||||
logging.FileHandler("logs/error.log"),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
_rotating("app.log", logging.INFO),
|
||||
_rotating("error.log", logging.ERROR),
|
||||
_console,
|
||||
],
|
||||
)
|
||||
|
||||
# Hauptlogger, wird von anderen Modulen importiert
|
||||
logger = logging.getLogger("main")
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Schema migration system.
|
||||
|
||||
Rules for maintainers
|
||||
---------------------
|
||||
- NEVER modify or delete an existing entry in MIGRATIONS.
|
||||
- ALWAYS append new entries at the end with an incremented version number.
|
||||
- Use ALTER TABLE to change an existing table, not a new CREATE TABLE.
|
||||
- Each entry must be a single, complete SQL statement.
|
||||
|
||||
How it works
|
||||
------------
|
||||
On startup (and after the initial setup form) run_migrations() is called.
|
||||
It creates a `schema_migrations` table the first time, checks which versions
|
||||
have already been applied, and runs any that are missing. A full JSON backup
|
||||
of all user-data tables is written to the backups/ directory before any
|
||||
schema changes are made so data can be recovered if something goes wrong.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from util.logger import logger
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Migration registry — append-only
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MIGRATIONS = [
|
||||
(1, "Create users table", """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL
|
||||
)
|
||||
"""),
|
||||
(2, "Create websites table", """
|
||||
CREATE TABLE IF NOT EXISTS websites (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
url VARCHAR(255) NOT NULL,
|
||||
description VARCHAR(255) DEFAULT ''
|
||||
)
|
||||
"""),
|
||||
(3, "Create notes table", """
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT,
|
||||
language VARCHAR(50) DEFAULT 'text',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
)
|
||||
"""),
|
||||
# ── add new migrations below this line ──────────────────────────────────
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BACKUP_DIR = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "..", "backups"
|
||||
)
|
||||
|
||||
|
||||
def _ensure_migration_table(conn):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INT PRIMARY KEY,
|
||||
description VARCHAR(255) NOT NULL,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
def _applied_versions(conn):
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT version FROM schema_migrations")
|
||||
versions = {row[0] for row in cur.fetchall()}
|
||||
cur.close()
|
||||
return versions
|
||||
|
||||
|
||||
def backup(conn):
|
||||
"""
|
||||
Dump every user-data table to a timestamped JSON file.
|
||||
Returns the path of the written file.
|
||||
Datetime values are serialised as ISO-8601 strings.
|
||||
"""
|
||||
os.makedirs(_BACKUP_DIR, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
path = os.path.join(_BACKUP_DIR, f"backup_{ts}.json")
|
||||
|
||||
cur = conn.cursor(dictionary=True)
|
||||
cur.execute("SHOW TABLES")
|
||||
tables = [list(row.values())[0] for row in cur.fetchall()]
|
||||
|
||||
snapshot = {}
|
||||
for table in tables:
|
||||
if table == "schema_migrations":
|
||||
continue
|
||||
cur.execute(f"SELECT * FROM `{table}`")
|
||||
snapshot[table] = [
|
||||
{
|
||||
k: v.isoformat() if hasattr(v, "isoformat") else v
|
||||
for k, v in row.items()
|
||||
}
|
||||
for row in cur.fetchall()
|
||||
]
|
||||
cur.close()
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(snapshot, f, indent=2, ensure_ascii=False)
|
||||
|
||||
logger.info(f"[migrations] Backup written → {path}")
|
||||
return path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_migrations(conn):
|
||||
"""
|
||||
Apply every pending migration in version order.
|
||||
A backup is created automatically before the first schema change.
|
||||
Safe to call on every startup — already-applied migrations are skipped.
|
||||
"""
|
||||
_ensure_migration_table(conn)
|
||||
applied = _applied_versions(conn)
|
||||
pending = [(v, d, sql) for v, d, sql in MIGRATIONS if v not in applied]
|
||||
|
||||
if not pending:
|
||||
logger.info("[migrations] Schema is up to date.")
|
||||
return
|
||||
|
||||
backup_path = backup(conn)
|
||||
logger.info(
|
||||
f"[migrations] {len(pending)} pending migration(s). "
|
||||
f"Backup: {backup_path}"
|
||||
)
|
||||
|
||||
cur = conn.cursor()
|
||||
for version, description, sql in pending:
|
||||
logger.info(f"[migrations] Applying v{version}: {description}")
|
||||
cur.execute(sql.strip())
|
||||
cur.execute(
|
||||
"INSERT INTO schema_migrations (version, description) VALUES (%s, %s)",
|
||||
(version, description),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
logger.info("[migrations] All migrations applied.")
|
||||
@@ -2,6 +2,7 @@ import time
|
||||
import os
|
||||
from flask import Blueprint, request, render_template, redirect, jsonify, send_from_directory
|
||||
from util.db_config import load_config, save_config, test_connection, is_configured
|
||||
from util.db_pool import reset_pool
|
||||
from auth.setup_admin import initialize_admin_user
|
||||
from util.logger import logger
|
||||
|
||||
@@ -39,8 +40,21 @@ def setup():
|
||||
"database": request.form['database']
|
||||
}
|
||||
save_config(db_config)
|
||||
reset_pool()
|
||||
if test_connection(db_config):
|
||||
initialize_admin_user(db_config)
|
||||
# Apply schema migrations immediately so all tables exist before
|
||||
# the user lands on the main page.
|
||||
try:
|
||||
from util.db_pool import get_connection
|
||||
from util.migrations import run_migrations
|
||||
conn = get_connection()
|
||||
try:
|
||||
run_migrations(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"[setup] Post-setup migration error: {e}")
|
||||
return redirect('/')
|
||||
else:
|
||||
return "Verbindung fehlgeschlagen. Bitte zurück und prüfen.", 500
|
||||
|
||||
@@ -5,14 +5,15 @@ 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
|
||||
- backups-data:/app/backend/backups
|
||||
working_dir: /app/backend
|
||||
command: python app.py
|
||||
volumes:
|
||||
config-data:
|
||||
logs-data:
|
||||
backups-data:
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
tools-app:
|
||||
image: git.nirodan.de/Nirdoan/tools-app:latest
|
||||
container_name: tools-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5000:5000"
|
||||
env_file:
|
||||
- .env
|
||||
networks:
|
||||
- tools-net
|
||||
|
||||
networks:
|
||||
tools-net:
|
||||
driver: bridge
|
||||
+70
-20
@@ -1,11 +1,31 @@
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
//import AdminDashboard from './components/AdminDashboard';
|
||||
import { useState, useEffect } from 'react';
|
||||
import LoginForm from './components/LoginForm';
|
||||
//import RegisterForm from './components/RegisterForm';
|
||||
import Md5Tool from './components/Md5Tool';
|
||||
import HasherTool from './components/HasherTool';
|
||||
import Base64Tool from './components/Base64Tool';
|
||||
import JwtDecoderTool from './components/JwtDecoderTool';
|
||||
import PasswordGenTool from './components/PasswordGenTool';
|
||||
import TimestampTool from './components/TimestampTool';
|
||||
import TextDiffTool from './components/TextDiffTool';
|
||||
import QrCodeTool from './components/QrCodeTool';
|
||||
import MarkdownTool from './components/MarkdownTool';
|
||||
import ColorConverterTool from './components/ColorConverterTool';
|
||||
import JsonFormatterTool from './components/JsonFormatterTool';
|
||||
import RegexTesterTool from './components/RegexTesterTool';
|
||||
import HashVerifierTool from './components/HashVerifierTool';
|
||||
import UrlTool from './components/UrlTool';
|
||||
import StringUtilsTool from './components/StringUtilsTool';
|
||||
import CronExplainerTool from './components/CronExplainerTool';
|
||||
import IpCalcTool from './components/IpCalcTool';
|
||||
import LoremIpsumTool from './components/LoremIpsumTool';
|
||||
import CsvViewerTool from './components/CsvViewerTool';
|
||||
import NotesTool from './components/NotesTool';
|
||||
import NavBar from './components/NavBar';
|
||||
import ToolOverview from './components/ToolOverview';
|
||||
import AdminDashboard from './components/AdminDashboard';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
|
||||
|
||||
import './css/base.css';
|
||||
@@ -17,27 +37,57 @@ import './css/admin.css';
|
||||
|
||||
|
||||
function App() {
|
||||
const isLoggedIn = localStorage.getItem('token') !== null;
|
||||
const role = localStorage.getItem('role');
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(localStorage.getItem('token') !== null);
|
||||
const [role, setRole] = useState(localStorage.getItem('role'));
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => {
|
||||
setIsLoggedIn(localStorage.getItem('token') !== null);
|
||||
setRole(localStorage.getItem('role'));
|
||||
};
|
||||
window.addEventListener('storage', sync);
|
||||
return () => window.removeEventListener('storage', sync);
|
||||
}, []);
|
||||
|
||||
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="/tools/hasher" element={isLoggedIn ? <HasherTool /> : <Navigate to="/login" />} />
|
||||
<Route path="/tools/base64" element={isLoggedIn ? <Base64Tool /> : <Navigate to="/login" />} />
|
||||
<Route path="/tools/jwt" element={isLoggedIn ? <JwtDecoderTool /> : <Navigate to="/login" />} />
|
||||
<Route path="/tools/password" element={isLoggedIn ? <PasswordGenTool />: <Navigate to="/login" />} />
|
||||
<Route path="/tools/timestamp" element={isLoggedIn ? <TimestampTool /> : <Navigate to="/login" />} />
|
||||
<Route path="/tools/textdiff" element={isLoggedIn ? <TextDiffTool /> : <Navigate to="/login" />} />
|
||||
<Route path="/tools/qrcode" element={isLoggedIn ? <QrCodeTool /> : <Navigate to="/login" />} />
|
||||
<Route path="/tools/markdown" element={isLoggedIn ? <MarkdownTool /> : <Navigate to="/login" />} />
|
||||
<Route path="/tools/color" element={isLoggedIn ? <ColorConverterTool /> : <Navigate to="/login" />} />
|
||||
<Route path="/tools/json" element={isLoggedIn ? <JsonFormatterTool /> : <Navigate to="/login" />} />
|
||||
<Route path="/tools/regex" element={isLoggedIn ? <RegexTesterTool /> : <Navigate to="/login" />} />
|
||||
<Route path="/tools/hashverify" element={isLoggedIn ? <HashVerifierTool /> : <Navigate to="/login" />} />
|
||||
<Route path="/tools/url" element={isLoggedIn ? <UrlTool /> : <Navigate to="/login" />} />
|
||||
<Route path="/tools/string" element={isLoggedIn ? <StringUtilsTool /> : <Navigate to="/login" />} />
|
||||
<Route path="/tools/cron" element={isLoggedIn ? <CronExplainerTool /> : <Navigate to="/login" />} />
|
||||
<Route path="/tools/ipcalc" element={isLoggedIn ? <IpCalcTool /> : <Navigate to="/login" />} />
|
||||
<Route path="/tools/lorem" element={isLoggedIn ? <LoremIpsumTool /> : <Navigate to="/login" />} />
|
||||
<Route path="/tools/csv" element={isLoggedIn ? <CsvViewerTool /> : <Navigate to="/login" />} />
|
||||
<Route path="/tools/notes" element={isLoggedIn ? <NotesTool /> : <Navigate to="/login" />} />
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
isLoggedIn && role === 'admin'
|
||||
? <AdminDashboard />
|
||||
: <Navigate to="/" />
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
const resultBox = {
|
||||
marginTop: '12px',
|
||||
padding: '12px 14px',
|
||||
background: 'var(--surface-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
justifyContent: 'space-between',
|
||||
};
|
||||
|
||||
function Base64Tool() {
|
||||
const [input, setInput] = useState('');
|
||||
const [result, setResult] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const request = async (endpoint) => {
|
||||
setError('');
|
||||
setResult('');
|
||||
try {
|
||||
const res = await axios.post(endpoint, { text: input });
|
||||
setResult(res.data.result);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Fehler');
|
||||
}
|
||||
};
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(result);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h2>Base64 Encoder / Decoder</h2>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => { setInput(e.target.value); setResult(''); setError(''); }}
|
||||
placeholder="Text eingeben"
|
||||
/>
|
||||
<button onClick={() => request('/api/base64/encode')}>Encode</button>
|
||||
<button onClick={() => request('/api/base64/decode')}>Decode</button>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{result && (
|
||||
<div style={resultBox}>
|
||||
<span style={{ wordBreak: 'break-all', color: 'var(--text)', fontFamily: 'monospace', fontSize: '0.9rem' }}>
|
||||
{result}
|
||||
</span>
|
||||
<button className="ghost" onClick={copy} style={{ flexShrink: 0, margin: 0 }}>
|
||||
{copied ? 'Kopiert!' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Base64Tool;
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
const resultBox = {
|
||||
marginTop: '8px',
|
||||
padding: '10px 14px',
|
||||
background: 'var(--surface-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px',
|
||||
};
|
||||
|
||||
function CopyBtn({ text }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
return (
|
||||
<button className="ghost" onClick={copy} style={{ flexShrink: 0, margin: 0 }}>
|
||||
{copied ? 'Kopiert!' : 'Kopieren'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorConverterTool() {
|
||||
const [value, setValue] = useState('');
|
||||
const [format, setFormat] = useState('hex');
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const convert = async () => {
|
||||
setError('');
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await axios.post('/api/color/convert', { value, from_format: format });
|
||||
setResult(res.data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Fehler bei der Konvertierung');
|
||||
}
|
||||
};
|
||||
|
||||
const placeholders = { hex: '#ff6600', rgb: '255, 102, 0', hsl: '24, 100%, 50%' };
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h2>Farb-Konverter</h2>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => { setValue(e.target.value); setResult(null); setError(''); }}
|
||||
placeholder={placeholders[format]}
|
||||
/>
|
||||
<select value={format} onChange={(e) => { setFormat(e.target.value); setResult(null); }} style={{ marginTop: '8px' }}>
|
||||
<option value="hex">HEX</option>
|
||||
<option value="rgb">RGB</option>
|
||||
<option value="hsl">HSL</option>
|
||||
</select>
|
||||
<button onClick={convert}>Konvertieren</button>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{result && (
|
||||
<>
|
||||
<div style={{ marginTop: '12px', height: '80px', borderRadius: '12px', background: result.hex, border: '1px solid var(--border)' }} />
|
||||
{[
|
||||
{ label: 'HEX', val: result.hex },
|
||||
{ label: 'RGB', val: result.rgb },
|
||||
{ label: 'HSL', val: result.hsl },
|
||||
].map(({ label, val }) => (
|
||||
<div key={label} style={resultBox}>
|
||||
<span style={{ color: 'var(--muted)', fontWeight: 600, minWidth: '40px' }}>{label}</span>
|
||||
<span style={{ fontFamily: 'monospace', color: 'var(--text)', flex: 1 }}>{val}</span>
|
||||
<CopyBtn text={val} />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ColorConverterTool;
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useState } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
const sectionBox = {
|
||||
marginTop: '14px',
|
||||
padding: '14px',
|
||||
background: 'var(--surface-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
};
|
||||
|
||||
const EXAMPLES = [
|
||||
{ label: '* * * * *', value: '* * * * *' },
|
||||
{ label: '0 9 * * 1-5', value: '0 9 * * 1-5' },
|
||||
{ label: '0 0 1 * *', value: '0 0 1 * *' },
|
||||
{ label: '*/15 * * * *', value: '*/15 * * * *' },
|
||||
];
|
||||
|
||||
const FIELD_LABELS = [
|
||||
{ key: 'minute', label: 'Minute' },
|
||||
{ key: 'hour', label: 'Stunde' },
|
||||
{ key: 'day', label: 'Tag' },
|
||||
{ key: 'month', label: 'Monat' },
|
||||
{ key: 'weekday', label: 'Wochentag' },
|
||||
];
|
||||
|
||||
function CronExplainerTool() {
|
||||
const [expression, setExpression] = useState('');
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const explain = async (expr) => {
|
||||
const val = expr !== undefined ? expr : expression;
|
||||
setError('');
|
||||
setResult(null);
|
||||
if (!val.trim()) {
|
||||
setError('Bitte Cron-Ausdruck eingeben.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await axios.post('/api/cron/explain', { expression: val });
|
||||
setResult(res.data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || err.response?.data?.message || 'Ungültiger Cron-Ausdruck');
|
||||
}
|
||||
};
|
||||
|
||||
const useExample = (val) => {
|
||||
setExpression(val);
|
||||
setError('');
|
||||
setResult(null);
|
||||
explain(val);
|
||||
};
|
||||
|
||||
const formatDate = (iso) => {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('de-DE', {
|
||||
weekday: 'short', day: '2-digit', month: '2-digit',
|
||||
year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h2>Cron Erklärer</h2>
|
||||
<p style={{ color: 'var(--muted)', marginBottom: '16px', fontSize: '0.95rem' }}>
|
||||
Cron-Ausdrücke (5 Felder) analysieren und auf Deutsch erklären.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: '10px' }}>
|
||||
{EXAMPLES.map(({ label, value }) => (
|
||||
<button
|
||||
key={value}
|
||||
className="ghost"
|
||||
onClick={() => useExample(value)}
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={expression}
|
||||
onChange={(e) => { setExpression(e.target.value); setResult(null); setError(''); }}
|
||||
placeholder="z.B. 0 9 * * 1-5"
|
||||
style={{ fontFamily: 'monospace', flex: 1 }}
|
||||
onKeyDown={(e) => e.key === 'Enter' && explain()}
|
||||
/>
|
||||
<button onClick={() => explain()} style={{ flexShrink: 0 }}>Erklären</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="error" style={{ marginTop: '12px' }}>{error}</p>}
|
||||
|
||||
{result && (
|
||||
<>
|
||||
<div style={{ ...sectionBox, borderColor: 'var(--accent)' }}>
|
||||
<p style={{ color: 'var(--muted)', fontSize: '0.8rem', marginBottom: '4px', fontWeight: 600 }}>Erklärung</p>
|
||||
<p style={{ color: 'var(--text)', fontWeight: 600, fontSize: '1.05rem' }}>{result.explanation}</p>
|
||||
</div>
|
||||
|
||||
<div style={sectionBox}>
|
||||
<p style={{ color: 'var(--muted)', fontSize: '0.8rem', marginBottom: '10px', fontWeight: 600 }}>Felder</p>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: '6px 10px', color: 'var(--muted)', fontSize: '0.8rem', fontWeight: 600, borderBottom: '1px solid var(--border)' }}>Feld</th>
|
||||
<th style={{ textAlign: 'left', padding: '6px 10px', color: 'var(--muted)', fontSize: '0.8rem', fontWeight: 600, borderBottom: '1px solid var(--border)' }}>Ausdruck</th>
|
||||
<th style={{ textAlign: 'left', padding: '6px 10px', color: 'var(--muted)', fontSize: '0.8rem', fontWeight: 600, borderBottom: '1px solid var(--border)' }}>Bedeutung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{FIELD_LABELS.map(({ key, label }, i) => (
|
||||
<tr key={key} style={{ background: i % 2 === 0 ? 'transparent' : 'rgba(255,255,255,0.02)' }}>
|
||||
<td style={{ padding: '8px 10px', color: 'var(--accent)', fontWeight: 600, fontSize: '0.9rem' }}>{label}</td>
|
||||
<td style={{ padding: '8px 10px', fontFamily: 'monospace', color: 'var(--text)', fontSize: '0.9rem' }}>
|
||||
{expression.split(/\s+/)[i] || '—'}
|
||||
</td>
|
||||
<td style={{ padding: '8px 10px', color: 'var(--text)', fontSize: '0.9rem' }}>
|
||||
{result.fields[key]}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{result.next_runs && result.next_runs.length > 0 && (
|
||||
<div style={sectionBox}>
|
||||
<p style={{ color: 'var(--muted)', fontSize: '0.8rem', marginBottom: '10px', fontWeight: 600 }}>Nächste 5 Ausführungen</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{result.next_runs.map((run, i) => (
|
||||
<div key={run} style={{
|
||||
display: 'flex', alignItems: 'center', gap: '10px',
|
||||
padding: '8px 12px',
|
||||
background: i === 0 ? 'rgba(34,211,238,0.06)' : 'transparent',
|
||||
borderRadius: '8px',
|
||||
border: i === 0 ? '1px solid rgba(34,211,238,0.2)' : '1px solid transparent',
|
||||
}}>
|
||||
<span style={{ color: 'var(--muted)', width: '20px', fontSize: '0.85rem' }}>#{i + 1}</span>
|
||||
<span style={{ fontFamily: 'monospace', color: i === 0 ? 'var(--accent)' : 'var(--text)', fontSize: '0.9rem' }}>
|
||||
{formatDate(run)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CronExplainerTool;
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
const DELIMITERS = [
|
||||
{ label: 'Komma (,)', value: ',' },
|
||||
{ label: 'Semikolon (;)', value: ';' },
|
||||
{ label: 'Tab', value: '\\t' },
|
||||
{ label: 'Pipe (|)', value: '|' },
|
||||
];
|
||||
|
||||
function CsvViewerTool() {
|
||||
const [text, setText] = useState('');
|
||||
const [delimiter, setDelimiter] = useState(',');
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [sortCol, setSortCol] = useState(null);
|
||||
const [sortAsc, setSortAsc] = useState(true);
|
||||
|
||||
const parse = async () => {
|
||||
setError('');
|
||||
setResult(null);
|
||||
setSortCol(null);
|
||||
if (!text.trim()) {
|
||||
setError('Bitte CSV-Inhalt eingeben.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await axios.post('/api/csv/parse', { text, delimiter });
|
||||
setResult(res.data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Fehler beim Verarbeiten des CSV');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = (colIdx) => {
|
||||
if (sortCol === colIdx) {
|
||||
setSortAsc(!sortAsc);
|
||||
} else {
|
||||
setSortCol(colIdx);
|
||||
setSortAsc(true);
|
||||
}
|
||||
};
|
||||
|
||||
const getSortedRows = () => {
|
||||
if (!result) return [];
|
||||
if (sortCol === null) return result.rows;
|
||||
return [...result.rows].sort((a, b) => {
|
||||
const av = a[sortCol] ?? '';
|
||||
const bv = b[sortCol] ?? '';
|
||||
const numA = parseFloat(av);
|
||||
const numB = parseFloat(bv);
|
||||
if (!isNaN(numA) && !isNaN(numB)) {
|
||||
return sortAsc ? numA - numB : numB - numA;
|
||||
}
|
||||
return sortAsc ? av.localeCompare(bv) : bv.localeCompare(av);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h2>CSV Viewer</h2>
|
||||
<p style={{ color: 'var(--muted)', marginBottom: '16px', fontSize: '0.95rem' }}>
|
||||
CSV-Daten einfügen und als Tabelle anzeigen.
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
rows={6}
|
||||
value={text}
|
||||
onChange={(e) => { setText(e.target.value); setResult(null); setError(''); }}
|
||||
placeholder="CSV-Inhalt hier einfügen..."
|
||||
style={{ fontFamily: 'monospace', resize: 'vertical' }}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', marginTop: '8px', flexWrap: 'wrap' }}>
|
||||
<select value={delimiter} onChange={(e) => setDelimiter(e.target.value)} style={{ margin: 0 }}>
|
||||
{DELIMITERS.map(({ label, value }) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={parse}>Anzeigen</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="error" style={{ marginTop: '12px' }}>{error}</p>}
|
||||
|
||||
{result && (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
{result.truncated && (
|
||||
<div style={{
|
||||
padding: '8px 14px', background: 'rgba(245,158,11,0.1)',
|
||||
border: '1px solid rgba(245,158,11,0.3)', borderRadius: '10px',
|
||||
color: '#f59e0b', fontSize: '0.85rem', marginBottom: '10px',
|
||||
}}>
|
||||
Hinweis: Nur die ersten 500 von {result.total_rows} Zeilen werden angezeigt.
|
||||
</div>
|
||||
)}
|
||||
<p style={{ color: 'var(--muted)', fontSize: '0.85rem', marginBottom: '8px' }}>
|
||||
{result.rows.length} Zeilen · {result.headers.length} Spalten
|
||||
{!result.truncated && result.total_rows > 0 && ` · ${result.total_rows} gesamt`}
|
||||
</p>
|
||||
<div style={{ overflowX: 'auto', borderRadius: '12px', border: '1px solid var(--border)' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.88rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--surface-2)' }}>
|
||||
<th style={{
|
||||
padding: '8px 10px', textAlign: 'right', color: 'var(--muted)',
|
||||
fontWeight: 600, borderBottom: '1px solid var(--border)',
|
||||
fontSize: '0.78rem', width: '40px',
|
||||
}}>#</th>
|
||||
{result.headers.map((h, i) => (
|
||||
<th
|
||||
key={i}
|
||||
onClick={() => handleSort(i)}
|
||||
style={{
|
||||
padding: '8px 12px', textAlign: 'left', color: 'var(--text)',
|
||||
fontWeight: 600, borderBottom: '1px solid var(--border)',
|
||||
cursor: 'pointer', userSelect: 'none',
|
||||
background: sortCol === i ? 'rgba(34,211,238,0.06)' : 'transparent',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{h || `Spalte ${i + 1}`}
|
||||
{sortCol === i && (
|
||||
<span style={{ marginLeft: '4px', color: 'var(--accent)' }}>
|
||||
{sortAsc ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{getSortedRows().map((row, ri) => (
|
||||
<tr key={ri} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={{
|
||||
padding: '6px 10px', textAlign: 'right',
|
||||
color: 'var(--muted)', fontSize: '0.78rem',
|
||||
}}>{ri + 1}</td>
|
||||
{result.headers.map((_, ci) => (
|
||||
<td key={ci} style={{
|
||||
padding: '6px 12px', color: 'var(--text)',
|
||||
fontFamily: 'monospace', maxWidth: '300px',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{row[ci] ?? ''}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CsvViewerTool;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
function HashVerifierTool() {
|
||||
const [text, setText] = useState('');
|
||||
const [hash, setHash] = useState('');
|
||||
const [algo, setAlgo] = useState('sha256');
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const verify = async () => {
|
||||
setError('');
|
||||
setResult(null);
|
||||
if (!text || !hash) {
|
||||
setError('Bitte Text und Hash eingeben.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.post('/api/hash/verify', { text, hash, algorithm: algo });
|
||||
setResult(res.data.match);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Fehler bei der Verifikation');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h2>Hash Verifier</h2>
|
||||
<p style={{ color: 'var(--muted)', marginBottom: '16px', fontSize: '0.95rem' }}>
|
||||
Prüft, ob ein Text mit einem gegebenen Hash übereinstimmt.
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => { setText(e.target.value); setResult(null); setError(''); }}
|
||||
placeholder="Originaltext"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={hash}
|
||||
onChange={(e) => { setHash(e.target.value); setResult(null); setError(''); }}
|
||||
placeholder="Hash-Wert"
|
||||
style={{ marginTop: '8px', fontFamily: 'monospace' }}
|
||||
/>
|
||||
|
||||
<select
|
||||
value={algo}
|
||||
onChange={(e) => { setAlgo(e.target.value); setResult(null); }}
|
||||
style={{ marginTop: '8px' }}
|
||||
>
|
||||
<option value="md5">MD5</option>
|
||||
<option value="sha256">SHA256</option>
|
||||
<option value="bcrypt">bcrypt</option>
|
||||
</select>
|
||||
|
||||
<button onClick={verify} disabled={loading} style={{ marginTop: '8px' }}>
|
||||
{loading ? 'Prüfen...' : 'Prüfen'}
|
||||
</button>
|
||||
|
||||
{error && <p className="error" style={{ marginTop: '12px' }}>{error}</p>}
|
||||
|
||||
{result !== null && (
|
||||
<div style={{
|
||||
marginTop: '24px',
|
||||
padding: '32px',
|
||||
background: 'var(--surface-2)',
|
||||
border: `2px solid ${result ? '#22c55e' : '#ef4444'}`,
|
||||
borderRadius: '16px',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{ fontSize: '3rem', marginBottom: '8px' }}>
|
||||
{result ? '✓' : '✗'}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '1.4rem',
|
||||
fontWeight: 700,
|
||||
color: result ? '#22c55e' : '#ef4444',
|
||||
}}>
|
||||
{result ? 'Übereinstimmung' : 'Kein Match'}
|
||||
</div>
|
||||
<div style={{ color: 'var(--muted)', marginTop: '6px', fontSize: '0.9rem' }}>
|
||||
Algorithmus: {algo.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HashVerifierTool;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
const resultBox = {
|
||||
marginTop: '12px',
|
||||
padding: '12px 14px',
|
||||
background: 'var(--surface-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
justifyContent: 'space-between',
|
||||
};
|
||||
|
||||
function HasherTool() {
|
||||
const [input, setInput] = useState('');
|
||||
const [algo, setAlgo] = useState('sha256');
|
||||
const [result, setResult] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleHash = async () => {
|
||||
try {
|
||||
const res = await axios.post(`/api/hash/${algo}`, { text: input });
|
||||
setResult(res.data[algo]);
|
||||
} catch {
|
||||
alert('Fehler beim Hashen');
|
||||
}
|
||||
};
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(result);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h2>SHA256 / bcrypt Hasher</h2>
|
||||
<p style={{ color: 'var(--accent)', fontSize: '0.875rem', marginBottom: '0.75rem' }}>
|
||||
<strong>Hinweis:</strong> bcrypt ist sicher für Passwörter – SHA256 für Datenintegrität.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => { setInput(e.target.value); setResult(''); }}
|
||||
placeholder="Text eingeben"
|
||||
/>
|
||||
<select value={algo} onChange={(e) => { setAlgo(e.target.value); setResult(''); }} style={{ marginTop: '8px' }}>
|
||||
<option value="sha256">SHA256</option>
|
||||
<option value="bcrypt">bcrypt</option>
|
||||
</select>
|
||||
<button onClick={handleHash}>Hash berechnen</button>
|
||||
{result && (
|
||||
<div style={resultBox}>
|
||||
<span style={{ wordBreak: 'break-all', color: 'var(--text)', fontFamily: 'monospace', fontSize: '0.9rem' }}>
|
||||
{result}
|
||||
</span>
|
||||
<button className="ghost" onClick={copy} style={{ flexShrink: 0, margin: 0 }}>
|
||||
{copied ? 'Kopiert!' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HasherTool;
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
const EXAMPLES = ['192.168.0.0/24', '10.0.0.0/8', '172.16.0.0/12'];
|
||||
|
||||
const INFO_FIELDS = [
|
||||
{ key: 'network', label: 'Netzwerkadresse' },
|
||||
{ key: 'broadcast', label: 'Broadcast-Adresse' },
|
||||
{ key: 'netmask', label: 'Subnetzmaske' },
|
||||
{ key: 'wildcard', label: 'Wildcard-Maske' },
|
||||
{ key: 'first_host', label: 'Erste nutzbare IP' },
|
||||
{ key: 'last_host', label: 'Letzte nutzbare IP' },
|
||||
{ key: 'total_hosts', label: 'Nutzbare Hosts' },
|
||||
{ key: 'prefix_length', label: 'Präfixlänge', format: (v) => `/${v}` },
|
||||
{ key: 'ip_class', label: 'IP-Klasse' },
|
||||
];
|
||||
|
||||
function IpCalcTool() {
|
||||
const [cidr, setCidr] = useState('');
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [copiedKey, setCopiedKey] = useState('');
|
||||
|
||||
const calculate = async (val) => {
|
||||
const input = val !== undefined ? val : cidr;
|
||||
setError('');
|
||||
setResult(null);
|
||||
if (!input.trim()) {
|
||||
setError('Bitte CIDR eingeben.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await axios.post('/api/ip/calculate', { cidr: input });
|
||||
setResult(res.data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Fehler bei der Berechnung');
|
||||
}
|
||||
};
|
||||
|
||||
const useExample = (val) => {
|
||||
setCidr(val);
|
||||
setError('');
|
||||
setResult(null);
|
||||
calculate(val);
|
||||
};
|
||||
|
||||
const copy = (key, text) => {
|
||||
navigator.clipboard.writeText(String(text));
|
||||
setCopiedKey(key);
|
||||
setTimeout(() => setCopiedKey(''), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h2>IP / Subnetz Rechner</h2>
|
||||
<p style={{ color: 'var(--muted)', marginBottom: '16px', fontSize: '0.95rem' }}>
|
||||
CIDR-Netzwerke berechnen und analysieren.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: '10px' }}>
|
||||
{EXAMPLES.map((ex) => (
|
||||
<button key={ex} className="ghost" onClick={() => useExample(ex)}
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
|
||||
{ex}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={cidr}
|
||||
onChange={(e) => { setCidr(e.target.value); setResult(null); setError(''); }}
|
||||
placeholder="192.168.1.0/24"
|
||||
style={{ fontFamily: 'monospace', flex: 1 }}
|
||||
onKeyDown={(e) => e.key === 'Enter' && calculate()}
|
||||
/>
|
||||
<button onClick={() => calculate()} style={{ flexShrink: 0 }}>Berechnen</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="error" style={{ marginTop: '12px' }}>{error}</p>}
|
||||
|
||||
{result && (
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||
gap: '10px',
|
||||
}}>
|
||||
{INFO_FIELDS.map(({ key, label, format }) => {
|
||||
const value = format ? format(result[key]) : result[key];
|
||||
return (
|
||||
<div key={key} style={{
|
||||
padding: '12px 14px',
|
||||
background: 'var(--surface-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
}}>
|
||||
<span style={{ fontSize: '0.78rem', color: 'var(--muted)', fontWeight: 600 }}>{label}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' }}>
|
||||
<span style={{ fontFamily: 'monospace', color: 'var(--text)', fontSize: '0.95rem', wordBreak: 'break-all' }}>
|
||||
{String(value)}
|
||||
</span>
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={() => copy(key, value)}
|
||||
style={{ flexShrink: 0, margin: 0, padding: '4px 10px', fontSize: '0.78rem' }}
|
||||
>
|
||||
{copiedKey === key ? '✓' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default IpCalcTool;
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
function JsonFormatterTool() {
|
||||
const [input, setInput] = useState('');
|
||||
const [indent, setIndent] = useState(2);
|
||||
const [result, setResult] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const format = async () => {
|
||||
setError('');
|
||||
setResult('');
|
||||
try {
|
||||
const res = await axios.post('/api/json/format', { text: input, indent });
|
||||
setResult(res.data.result);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Ungültiges JSON');
|
||||
}
|
||||
};
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(result);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h2>JSON Formatter</h2>
|
||||
<textarea
|
||||
rows={10}
|
||||
value={input}
|
||||
onChange={(e) => { setInput(e.target.value); setResult(''); setError(''); }}
|
||||
placeholder='{"key": "value"}'
|
||||
style={{ resize: 'vertical', fontFamily: 'monospace', fontSize: '0.875rem' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '4px' }}>
|
||||
{[2, 4].map((n) => (
|
||||
<button key={n} className={indent === n ? '' : 'ghost'} onClick={() => setIndent(n)} style={{ margin: 0 }}>
|
||||
{n} Spaces
|
||||
</button>
|
||||
))}
|
||||
<button onClick={format} style={{ marginLeft: 'auto', margin: 0 }}>Formatieren</button>
|
||||
</div>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{result && (
|
||||
<div style={{ marginTop: '12px', position: 'relative' }}>
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={copy}
|
||||
style={{ position: 'absolute', top: '8px', right: '8px', margin: 0, zIndex: 1 }}
|
||||
>
|
||||
{copied ? 'Kopiert!' : 'Kopieren'}
|
||||
</button>
|
||||
<pre style={{
|
||||
margin: 0,
|
||||
padding: '14px',
|
||||
paddingTop: '40px',
|
||||
background: 'var(--surface-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
color: 'var(--text)',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.875rem',
|
||||
overflowX: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}>
|
||||
{result}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JsonFormatterTool;
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
const sectionBox = {
|
||||
marginTop: '12px',
|
||||
padding: '14px',
|
||||
background: 'var(--surface-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
};
|
||||
|
||||
function JwtDecoderTool() {
|
||||
const [input, setInput] = useState('');
|
||||
const [decoded, setDecoded] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleDecode = async () => {
|
||||
setError('');
|
||||
setDecoded(null);
|
||||
try {
|
||||
const res = await axios.post('/api/jwt/decode', { token: input });
|
||||
setDecoded(res.data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Ungültiger JWT');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h2>JWT Decoder</h2>
|
||||
<p style={{ color: 'var(--accent)', fontSize: '0.875rem', marginBottom: '0.75rem' }}>
|
||||
<strong>Hinweis:</strong> Dieser Decoder verifiziert keine Signatur – nur zur Analyse.
|
||||
</p>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={input}
|
||||
onChange={(e) => { setInput(e.target.value); setDecoded(null); setError(''); }}
|
||||
placeholder="JWT Token einfügen"
|
||||
style={{ resize: 'vertical', fontFamily: 'monospace', fontSize: '0.85rem' }}
|
||||
/>
|
||||
<button onClick={handleDecode}>Dekodieren</button>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{decoded && (
|
||||
<>
|
||||
<div style={sectionBox}>
|
||||
<p className="eyebrow" style={{ marginBottom: '6px' }}>Header</p>
|
||||
<pre style={{ margin: 0, color: 'var(--text)', fontSize: '0.875rem', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||
{JSON.stringify(decoded.header, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div style={sectionBox}>
|
||||
<p className="eyebrow" style={{ marginBottom: '6px' }}>Payload</p>
|
||||
<pre style={{ margin: 0, color: 'var(--text)', fontSize: '0.875rem', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||
{JSON.stringify(decoded.payload, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div style={{ ...sectionBox, borderColor: decoded.expired ? '#ef4444' : '#22c55e' }}>
|
||||
<p className="eyebrow" style={{ marginBottom: '4px' }}>Status</p>
|
||||
<span style={{ fontWeight: 700, color: decoded.expired ? '#ef4444' : '#22c55e' }}>
|
||||
{decoded.expired ? 'Abgelaufen' : 'Gültig'}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JwtDecoderTool;
|
||||
@@ -12,8 +12,6 @@ function LoginForm() {
|
||||
const res = await axios.post('/api/login', { username, password });
|
||||
localStorage.setItem('token', res.data.token);
|
||||
localStorage.setItem('role', res.data.role);
|
||||
navigate('/', { replace: true });
|
||||
// ensure nav + route state reflect the new token immediately
|
||||
window.location.href = '/';
|
||||
} catch (err) {
|
||||
alert('Login fehlgeschlagen');
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
const TYPES = [
|
||||
{ value: 'words', label: 'Wörter' },
|
||||
{ value: 'sentences', label: 'Sätze' },
|
||||
{ value: 'paragraphs', label: 'Absätze' },
|
||||
];
|
||||
|
||||
function LoremIpsumTool() {
|
||||
const [type, setType] = useState('sentences');
|
||||
const [count, setCount] = useState(3);
|
||||
const [result, setResult] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const generate = async () => {
|
||||
setError('');
|
||||
setResult('');
|
||||
try {
|
||||
const res = await axios.post('/api/lorem/generate', { type, count });
|
||||
setResult(res.data.text);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Fehler bei der Generierung');
|
||||
}
|
||||
};
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(result);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h2>Lorem Ipsum Generator</h2>
|
||||
<p style={{ color: 'var(--muted)', marginBottom: '16px', fontSize: '0.95rem' }}>
|
||||
Blindtext für Designs und Mockups generieren.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: '6px', marginBottom: '12px' }}>
|
||||
{TYPES.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
className={type === value ? '' : 'ghost'}
|
||||
onClick={() => setType(value)}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<label style={{ color: 'var(--text)', fontWeight: 600, fontSize: '0.9rem', whiteSpace: 'nowrap' }}>
|
||||
Anzahl:
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={count}
|
||||
onChange={(e) => setCount(Math.max(1, Math.min(20, Number(e.target.value))))}
|
||||
style={{ width: '80px' }}
|
||||
/>
|
||||
<span style={{ color: 'var(--muted)', fontSize: '0.85rem' }}>(1–20)</span>
|
||||
</div>
|
||||
|
||||
<button onClick={generate}>Generieren</button>
|
||||
|
||||
{error && <p className="error" style={{ marginTop: '12px' }}>{error}</p>}
|
||||
|
||||
{result && (
|
||||
<div style={{ marginTop: '14px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '6px' }}>
|
||||
<button className="ghost" onClick={copy} style={{ margin: 0, padding: '6px 14px', fontSize: '0.85rem' }}>
|
||||
{copied ? 'Kopiert!' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
rows={10}
|
||||
readOnly
|
||||
value={result}
|
||||
style={{ resize: 'vertical', color: 'var(--muted)', fontFamily: 'inherit', lineHeight: '1.6' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoremIpsumTool;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
function MarkdownTool() {
|
||||
const [text, setText] = useState('');
|
||||
const [html, setHtml] = useState('');
|
||||
const timer = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
clearTimeout(timer.current);
|
||||
if (!text) { setHtml(''); return; }
|
||||
timer.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await axios.post('/api/markdown/render', { text });
|
||||
setHtml(res.data.html);
|
||||
} catch {
|
||||
setHtml('<p style="color:red">Fehler beim Rendern</p>');
|
||||
}
|
||||
}, 500);
|
||||
return () => clearTimeout(timer.current);
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h2>Markdown Editor</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<div>
|
||||
<p className="muted" style={{ marginBottom: '6px', fontWeight: 600 }}>Editor</p>
|
||||
<textarea
|
||||
rows={20}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Markdown eingeben..."
|
||||
style={{ resize: 'vertical', fontFamily: 'monospace', fontSize: '0.875rem' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="muted" style={{ marginBottom: '6px', fontWeight: 600 }}>Vorschau</p>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: html || '<span style="color:#999">Vorschau erscheint hier...</span>' }}
|
||||
style={{
|
||||
minHeight: '460px',
|
||||
padding: '16px',
|
||||
background: '#ffffff',
|
||||
color: '#0f172a',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
overflowY: 'auto',
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MarkdownTool;
|
||||
@@ -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>}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
const LANGUAGES = [
|
||||
{ value: 'text', label: 'Text' },
|
||||
{ value: 'python', label: 'Python' },
|
||||
{ value: 'javascript', label: 'JavaScript' },
|
||||
{ value: 'sql', label: 'SQL' },
|
||||
{ value: 'bash', label: 'Bash' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
];
|
||||
|
||||
function NotesTool() {
|
||||
const [notes, setNotes] = useState([]);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [title, setTitle] = useState('Neue Notiz');
|
||||
const [content, setContent] = useState('');
|
||||
const [language, setLanguage] = useState('text');
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const debounceRef = useRef(null);
|
||||
// Tracks whether the user has actually edited the current note.
|
||||
// Prevents auto-save from firing just because a note was selected.
|
||||
const isDirtyRef = useRef(false);
|
||||
const isNew = selected === null;
|
||||
|
||||
const loadNotes = useCallback(async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/notes');
|
||||
setNotes(res.data);
|
||||
} catch {
|
||||
setError('Fehler beim Laden der Notizen');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadNotes(); }, [loadNotes]);
|
||||
|
||||
const selectNote = (note) => {
|
||||
isDirtyRef.current = false;
|
||||
setSelected(note.id);
|
||||
setTitle(note.title);
|
||||
setContent(note.content || '');
|
||||
setLanguage(note.language || 'text');
|
||||
setSaved(false);
|
||||
setConfirmDelete(false);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const newNote = () => {
|
||||
isDirtyRef.current = false;
|
||||
setSelected(null);
|
||||
setTitle('Neue Notiz');
|
||||
setContent('');
|
||||
setLanguage('text');
|
||||
setSaved(false);
|
||||
setConfirmDelete(false);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setError('');
|
||||
try {
|
||||
if (isNew) {
|
||||
const res = await axios.post('/api/notes', { title, content, language });
|
||||
isDirtyRef.current = false;
|
||||
setSelected(res.data.id);
|
||||
await loadNotes();
|
||||
} else {
|
||||
await axios.put(`/api/notes/${selected}`, { title, content, language });
|
||||
isDirtyRef.current = false;
|
||||
await loadNotes();
|
||||
}
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Fehler beim Speichern');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteNote = async () => {
|
||||
if (!confirmDelete) { setConfirmDelete(true); return; }
|
||||
try {
|
||||
await axios.delete(`/api/notes/${selected}`);
|
||||
setNotes(prev => prev.filter(n => n.id !== selected));
|
||||
newNote();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Fehler beim Löschen');
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-save for existing notes — only fires when the user actually edited content.
|
||||
// selected is in deps so the closure is never stale when switching between notes.
|
||||
useEffect(() => {
|
||||
if (isNew || !isDirtyRef.current) return;
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
if (!isDirtyRef.current) return;
|
||||
try {
|
||||
await axios.put(`/api/notes/${selected}`, { title, content, language });
|
||||
isDirtyRef.current = false;
|
||||
await loadNotes();
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 1500);
|
||||
} catch { /* silent — manual save still available */ }
|
||||
}, 1000);
|
||||
return () => clearTimeout(debounceRef.current);
|
||||
}, [title, content, language, selected, isNew, loadNotes]);
|
||||
|
||||
const formatDate = (iso) => {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main-content" style={{ maxWidth: '1100px' }}>
|
||||
<h2>Notizen & Snippets</h2>
|
||||
|
||||
<div className="notes-layout">
|
||||
{/* Sidebar */}
|
||||
<div style={{
|
||||
background: 'var(--surface-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ padding: '10px', borderBottom: '1px solid var(--border)' }}>
|
||||
<button onClick={newNote} style={{ width: '100%', margin: 0, padding: '8px', fontSize: '0.85rem' }}>
|
||||
+ Neue Notiz
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ maxHeight: '500px', overflowY: 'auto' }}>
|
||||
{notes.length === 0 ? (
|
||||
<p style={{ color: 'var(--muted)', padding: '16px', fontSize: '0.85rem', textAlign: 'center' }}>
|
||||
Keine Notizen vorhanden
|
||||
</p>
|
||||
) : notes.map((note) => (
|
||||
<div
|
||||
key={note.id}
|
||||
onClick={() => selectNote(note)}
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
cursor: 'pointer',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: selected === note.id ? 'rgba(34,211,238,0.08)' : 'transparent',
|
||||
borderLeft: selected === note.id ? '3px solid var(--accent)' : '3px solid transparent',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontWeight: 600, color: 'var(--text)', fontSize: '0.9rem',
|
||||
marginBottom: '2px', overflow: 'hidden',
|
||||
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{note.title}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--muted)' }}>
|
||||
{formatDate(note.updated_at)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div style={{
|
||||
background: 'var(--surface-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '10px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => { isDirtyRef.current = true; setTitle(e.target.value); setSaved(false); }}
|
||||
placeholder="Titel"
|
||||
style={{ flex: 1, margin: 0, minWidth: '150px' }}
|
||||
/>
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => { isDirtyRef.current = true; setLanguage(e.target.value); setSaved(false); }}
|
||||
>
|
||||
{LANGUAGES.map(({ value, label }) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
rows={16}
|
||||
value={content}
|
||||
onChange={(e) => { isDirtyRef.current = true; setContent(e.target.value); setSaved(false); }}
|
||||
placeholder="Inhalt..."
|
||||
style={{ fontFamily: 'monospace', resize: 'vertical', fontSize: '0.88rem', margin: 0 }}
|
||||
/>
|
||||
|
||||
{error && <p className="error" style={{ marginTop: '8px', marginBottom: 0 }}>{error}</p>}
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '10px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={save} style={{ margin: 0 }}>
|
||||
{isNew ? 'Erstellen' : 'Speichern'}
|
||||
</button>
|
||||
{!isNew && (
|
||||
<button
|
||||
className={confirmDelete ? 'ghost danger' : 'ghost'}
|
||||
onClick={deleteNote}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
{confirmDelete ? 'Wirklich löschen?' : 'Löschen'}
|
||||
</button>
|
||||
)}
|
||||
{confirmDelete && (
|
||||
<button className="ghost" onClick={() => setConfirmDelete(false)} style={{ margin: 0 }}>
|
||||
Abbrechen
|
||||
</button>
|
||||
)}
|
||||
{saved && (
|
||||
<span style={{ color: '#22c55e', fontSize: '0.85rem', fontWeight: 600 }}>✓ Gespeichert</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotesTool;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
const resultBox = {
|
||||
marginTop: '12px',
|
||||
padding: '12px 14px',
|
||||
background: 'var(--surface-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
justifyContent: 'space-between',
|
||||
};
|
||||
|
||||
function getStrength(length, charsets) {
|
||||
const score = (charsets >= 3 ? 2 : charsets >= 2 ? 1 : 0) + (length >= 16 ? 2 : length >= 12 ? 1 : 0);
|
||||
if (score >= 4) return { label: 'Stark', color: '#22c55e' };
|
||||
if (score >= 2) return { label: 'Mittel', color: '#f59e0b' };
|
||||
return { label: 'Schwach', color: '#ef4444' };
|
||||
}
|
||||
|
||||
function PasswordGenTool() {
|
||||
const [length, setLength] = useState(16);
|
||||
const [uppercase, setUppercase] = useState(true);
|
||||
const [lowercase, setLowercase] = useState(true);
|
||||
const [numbers, setNumbers] = useState(true);
|
||||
const [symbols, setSymbols] = useState(false);
|
||||
const [result, setResult] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const charsets = [uppercase, lowercase, numbers, symbols].filter(Boolean).length;
|
||||
const strength = getStrength(length, charsets);
|
||||
|
||||
const generate = async () => {
|
||||
setError('');
|
||||
try {
|
||||
const res = await axios.post('/api/password/generate', { length, uppercase, lowercase, numbers, symbols });
|
||||
setResult(res.data.password);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Fehler beim Generieren');
|
||||
}
|
||||
};
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(result);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h2>Passwort-Generator</h2>
|
||||
|
||||
<label style={{ display: 'block', color: 'var(--muted)', fontWeight: 600, marginBottom: '6px' }}>
|
||||
Länge: {length}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={8}
|
||||
max={64}
|
||||
value={length}
|
||||
onChange={(e) => setLength(Number(e.target.value))}
|
||||
style={{ accentColor: 'var(--accent)', padding: 0, border: 'none', background: 'transparent', marginBottom: '12px' }}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '16px', margin: '8px 0 12px' }}>
|
||||
{[
|
||||
{ label: 'Großbuchstaben', value: uppercase, set: setUppercase },
|
||||
{ label: 'Kleinbuchstaben', value: lowercase, set: setLowercase },
|
||||
{ label: 'Zahlen', value: numbers, set: setNumbers },
|
||||
{ label: 'Sonderzeichen', value: symbols, set: setSymbols },
|
||||
].map(({ label, value, set }) => (
|
||||
<label key={label} style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer', color: 'var(--text)', fontWeight: 500 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={(e) => set(e.target.checked)}
|
||||
style={{ width: 'auto', accentColor: 'var(--accent)' }}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '4px' }}>
|
||||
<span className="muted" style={{ fontSize: '0.875rem' }}>Stärke:</span>
|
||||
<span style={{ fontWeight: 700, color: strength.color }}>{strength.label}</span>
|
||||
</div>
|
||||
|
||||
<button onClick={generate}>Generieren</button>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{result && (
|
||||
<div style={resultBox}>
|
||||
<span style={{ wordBreak: 'break-all', color: 'var(--text)', fontFamily: 'monospace', fontSize: '0.95rem' }}>
|
||||
{result}
|
||||
</span>
|
||||
<button className="ghost" onClick={copy} style={{ flexShrink: 0, margin: 0 }}>
|
||||
{copied ? 'Kopiert!' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PasswordGenTool;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
function QrCodeTool() {
|
||||
const [input, setInput] = useState('');
|
||||
const [image, setImage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const generate = async () => {
|
||||
setError('');
|
||||
try {
|
||||
const res = await axios.post('/api/qrcode/generate', { text: input });
|
||||
setImage(res.data.image);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Fehler beim Generieren');
|
||||
}
|
||||
};
|
||||
|
||||
const download = () => {
|
||||
const a = document.createElement('a');
|
||||
a.href = image;
|
||||
a.download = 'qrcode.png';
|
||||
a.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h2>QR-Code Generator</h2>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => { setInput(e.target.value); setImage(''); setError(''); }}
|
||||
placeholder="Text oder URL eingeben"
|
||||
/>
|
||||
<button onClick={generate}>Generieren</button>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{image && (
|
||||
<div style={{ marginTop: '16px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
|
||||
<img src={image} alt="QR-Code" style={{ width: 220, height: 220, borderRadius: '12px', background: '#fff', padding: '8px' }} />
|
||||
<button className="ghost" onClick={download} style={{ margin: 0 }}>Herunterladen</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QrCodeTool;
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
function highlightText(text, matches) {
|
||||
const parts = [];
|
||||
let last = 0;
|
||||
for (const m of matches) {
|
||||
if (m.start > last) parts.push({ text: text.slice(last, m.start), highlight: false });
|
||||
parts.push({ text: text.slice(m.start, m.end), highlight: true });
|
||||
last = m.end;
|
||||
}
|
||||
if (last < text.length) parts.push({ text: text.slice(last), highlight: false });
|
||||
return parts;
|
||||
}
|
||||
|
||||
function RegexTesterTool() {
|
||||
const [pattern, setPattern] = useState('');
|
||||
const [flags, setFlags] = useState({ i: false, m: false, s: false });
|
||||
const [text, setText] = useState('');
|
||||
const [result, setResult] = useState(null);
|
||||
const [patternError, setPatternError] = useState('');
|
||||
const timer = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
clearTimeout(timer.current);
|
||||
if (!pattern || !text) { setResult(null); setPatternError(''); return; }
|
||||
timer.current = setTimeout(async () => {
|
||||
try {
|
||||
const activeFlags = Object.entries(flags).filter(([, v]) => v).map(([k]) => k);
|
||||
const res = await axios.post('/api/regex/test', { pattern, text, flags: activeFlags });
|
||||
if (res.data.error) {
|
||||
setPatternError(res.data.error);
|
||||
setResult(null);
|
||||
} else {
|
||||
setPatternError('');
|
||||
setResult(res.data);
|
||||
}
|
||||
} catch {
|
||||
setPatternError('Fehler beim Testen');
|
||||
}
|
||||
}, 400);
|
||||
return () => clearTimeout(timer.current);
|
||||
}, [pattern, text, flags]);
|
||||
|
||||
const parts = result?.matches?.length && text ? highlightText(text, result.matches) : null;
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h2>Regex Tester</h2>
|
||||
<input
|
||||
type="text"
|
||||
value={pattern}
|
||||
onChange={(e) => setPattern(e.target.value)}
|
||||
placeholder={String.raw`Regex Pattern, z.B. \d+`}
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
{patternError && <p className="error" style={{ marginTop: '4px' }}>{patternError}</p>}
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '16px', margin: '10px 0 12px' }}>
|
||||
{[
|
||||
{ key: 'i', label: 'Case Insensitive (i)' },
|
||||
{ key: 'm', label: 'Multiline (m)' },
|
||||
{ key: 's', label: 'Dotall (s)' },
|
||||
].map(({ key, label }) => (
|
||||
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer', color: 'var(--text)', fontWeight: 500 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={flags[key]}
|
||||
onChange={(e) => setFlags(prev => ({ ...prev, [key]: e.target.checked }))}
|
||||
style={{ width: 'auto', accentColor: 'var(--accent)' }}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
rows={8}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Testtext eingeben..."
|
||||
style={{ resize: 'vertical', fontFamily: 'monospace', fontSize: '0.875rem' }}
|
||||
/>
|
||||
|
||||
{result !== null && (
|
||||
<>
|
||||
<p style={{ marginTop: '8px', color: result.count > 0 ? 'var(--accent)' : 'var(--muted)', fontWeight: 600 }}>
|
||||
{result.count} {result.count === 1 ? 'Match' : 'Matches'}
|
||||
</p>
|
||||
{parts && (
|
||||
<div style={{
|
||||
marginTop: '8px',
|
||||
padding: '12px 14px',
|
||||
background: 'var(--surface-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.875rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
color: 'var(--text)',
|
||||
}}>
|
||||
{parts.map((p, i) =>
|
||||
p.highlight
|
||||
? <mark key={i} style={{ background: '#fbbf24', color: '#0f172a', borderRadius: '3px', padding: '0 1px' }}>{p.text}</mark>
|
||||
: <span key={i}>{p.text}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RegexTesterTool;
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useState } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
const resultBox = {
|
||||
marginTop: '12px',
|
||||
padding: '12px 14px',
|
||||
background: 'var(--surface-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '10px',
|
||||
justifyContent: 'space-between',
|
||||
};
|
||||
|
||||
const statCard = {
|
||||
padding: '12px 16px',
|
||||
background: 'var(--surface-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
textAlign: 'center',
|
||||
flex: '1 1 120px',
|
||||
};
|
||||
|
||||
const TRANSFORMS = [
|
||||
{ op: 'uppercase', label: 'Großbuchstaben' },
|
||||
{ op: 'lowercase', label: 'Kleinbuchstaben' },
|
||||
{ op: 'titlecase', label: 'Titelschreibung' },
|
||||
{ op: 'reverse', label: 'Umkehren' },
|
||||
{ op: 'trim', label: 'Leerzeichen trim' },
|
||||
{ op: 'remove_spaces', label: 'Leerzeichen entfernen' },
|
||||
];
|
||||
|
||||
function StringUtilsTool() {
|
||||
const [input, setInput] = useState('');
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const run = async (op) => {
|
||||
setError('');
|
||||
setResult(null);
|
||||
if (!input) {
|
||||
setError('Bitte Text eingeben.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await axios.post('/api/string/analyze', { text: input, operation: op });
|
||||
setResult(res.data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Fehler bei der Verarbeitung');
|
||||
}
|
||||
};
|
||||
|
||||
const copy = (text) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h2>String Utilities</h2>
|
||||
|
||||
<textarea
|
||||
rows={6}
|
||||
value={input}
|
||||
onChange={(e) => { setInput(e.target.value); setResult(null); setError(''); }}
|
||||
placeholder="Text eingeben..."
|
||||
style={{ resize: 'vertical' }}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
<p style={{ color: 'var(--muted)', fontWeight: 600, marginBottom: '8px', fontSize: '0.9rem' }}>Analyse</p>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button onClick={() => run('stats')}>Statistiken</button>
|
||||
<button onClick={() => run('count_words')}>Worthäufigkeit</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
<p style={{ color: 'var(--muted)', fontWeight: 600, marginBottom: '8px', fontSize: '0.9rem' }}>Transformation</p>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{TRANSFORMS.map(({ op, label }) => (
|
||||
<button key={op} className="ghost" onClick={() => run(op)}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="error" style={{ marginTop: '12px' }}>{error}</p>}
|
||||
|
||||
{result && result.operation === 'stats' && (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<p style={{ color: 'var(--muted)', fontWeight: 600, marginBottom: '10px', fontSize: '0.9rem' }}>Statistiken</p>
|
||||
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ label: 'Zeichen', value: result.chars },
|
||||
{ label: 'Ohne Leerzeichen', value: result.chars_no_spaces },
|
||||
{ label: 'Wörter', value: result.words },
|
||||
{ label: 'Zeilen', value: result.lines },
|
||||
{ label: 'Leerzeichen', value: result.spaces },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} style={statCard}>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--accent)' }}>{value}</div>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--muted)', marginTop: '4px' }}>{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && result.operation === 'count_words' && (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<p style={{ color: 'var(--muted)', fontWeight: 600, marginBottom: '10px', fontSize: '0.9rem' }}>Top-Wörter</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{result.words.map(({ word, count }, i) => (
|
||||
<div key={word} style={{
|
||||
display: 'flex', alignItems: 'center', gap: '10px',
|
||||
padding: '8px 12px',
|
||||
background: 'var(--surface-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '10px',
|
||||
}}>
|
||||
<span style={{ color: 'var(--muted)', width: '20px', fontSize: '0.85rem' }}>#{i + 1}</span>
|
||||
<span style={{ fontFamily: 'monospace', color: 'var(--text)', flex: 1 }}>{word}</span>
|
||||
<span style={{
|
||||
background: 'var(--accent)', color: '#0b1224',
|
||||
borderRadius: '20px', padding: '2px 10px',
|
||||
fontSize: '0.8rem', fontWeight: 700,
|
||||
}}>{count}×</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && result.result !== undefined && (
|
||||
<div style={resultBox}>
|
||||
<span style={{ wordBreak: 'break-all', color: 'var(--text)', fontFamily: 'monospace', fontSize: '0.9rem', flex: 1, whiteSpace: 'pre-wrap' }}>
|
||||
{result.result}
|
||||
</span>
|
||||
<button className="ghost" onClick={() => copy(result.result)} style={{ flexShrink: 0, margin: 0 }}>
|
||||
{copied ? 'Kopiert!' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StringUtilsTool;
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
const diffColors = {
|
||||
added: { bg: 'rgba(34, 197, 94, 0.12)', text: '#22c55e', prefix: '+ ' },
|
||||
removed: { bg: 'rgba(239, 68, 68, 0.12)', text: '#ef4444', prefix: '- ' },
|
||||
unchanged: { bg: 'transparent', text: 'var(--muted)', prefix: ' ' },
|
||||
};
|
||||
|
||||
function TextDiffTool() {
|
||||
const [text1, setText1] = useState('');
|
||||
const [text2, setText2] = useState('');
|
||||
const [diff, setDiff] = useState(null);
|
||||
|
||||
const compare = async () => {
|
||||
try {
|
||||
const res = await axios.post('/api/text/diff', { text1, text2 });
|
||||
setDiff(res.data.diff);
|
||||
} catch {
|
||||
alert('Fehler beim Vergleich');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h2>Text Diff</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<div>
|
||||
<p className="muted" style={{ marginBottom: '6px', fontWeight: 600 }}>Text A</p>
|
||||
<textarea
|
||||
rows={10}
|
||||
value={text1}
|
||||
onChange={(e) => { setText1(e.target.value); setDiff(null); }}
|
||||
placeholder="Originaltext eingeben..."
|
||||
style={{ resize: 'vertical', fontFamily: 'monospace', fontSize: '0.875rem' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="muted" style={{ marginBottom: '6px', fontWeight: 600 }}>Text B</p>
|
||||
<textarea
|
||||
rows={10}
|
||||
value={text2}
|
||||
onChange={(e) => { setText2(e.target.value); setDiff(null); }}
|
||||
placeholder="Geänderter Text eingeben..."
|
||||
style={{ resize: 'vertical', fontFamily: 'monospace', fontSize: '0.875rem' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick={compare}>Vergleichen</button>
|
||||
|
||||
{diff !== null && (
|
||||
<div style={{ marginTop: '16px', border: '1px solid var(--border)', borderRadius: '12px', overflow: 'hidden' }}>
|
||||
{diff.length === 0 ? (
|
||||
<p style={{ padding: '12px 14px', color: 'var(--muted)', margin: 0 }}>Keine Unterschiede gefunden.</p>
|
||||
) : (
|
||||
diff.map((line, i) => {
|
||||
const c = diffColors[line.type] || diffColors.unchanged;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
padding: '2px 14px',
|
||||
background: c.bg,
|
||||
color: c.text,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.875rem',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
borderLeft: `3px solid ${line.type !== 'unchanged' ? c.text : 'transparent'}`,
|
||||
}}
|
||||
>
|
||||
{c.prefix}{line.text}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextDiffTool;
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
const resultBox = {
|
||||
marginTop: '12px',
|
||||
padding: '12px 14px',
|
||||
background: 'var(--surface-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
justifyContent: 'space-between',
|
||||
};
|
||||
|
||||
function TimestampTool() {
|
||||
const [value, setValue] = useState('');
|
||||
const [direction, setDirection] = useState('unix_to_date');
|
||||
const [result, setResult] = useState(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [now, setNow] = useState(Math.floor(Date.now() / 1000));
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const convert = async () => {
|
||||
setError('');
|
||||
setResult(null);
|
||||
try {
|
||||
const res = await axios.post('/api/timestamp/convert', { value, direction });
|
||||
setResult(res.data);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'Ungültiger Wert');
|
||||
}
|
||||
};
|
||||
|
||||
const resultText = result
|
||||
? direction === 'unix_to_date' ? result.utc : String(result.unix)
|
||||
: '';
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(resultText);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h2>Timestamp Converter</h2>
|
||||
|
||||
<div style={{ marginBottom: '12px', padding: '10px 14px', background: 'var(--surface-2)', border: '1px solid var(--border)', borderRadius: '12px' }}>
|
||||
<span className="muted" style={{ fontSize: '0.875rem' }}>Aktueller Unix Timestamp: </span>
|
||||
<span style={{ fontFamily: 'monospace', color: 'var(--accent)', fontWeight: 700 }}>{now}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
|
||||
{[
|
||||
{ key: 'unix_to_date', label: 'Unix → Datum' },
|
||||
{ key: 'date_to_unix', label: 'Datum → Unix' },
|
||||
].map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
className={direction === key ? '' : 'ghost'}
|
||||
onClick={() => { setDirection(key); setResult(null); setError(''); }}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => { setValue(e.target.value); setResult(null); setError(''); }}
|
||||
placeholder={direction === 'unix_to_date' ? 'z.B. 1700000000' : 'z.B. 2024-01-15 oder 15.01.2024'}
|
||||
/>
|
||||
<button onClick={convert}>Konvertieren</button>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{result && (
|
||||
<div style={resultBox}>
|
||||
<div>
|
||||
{direction === 'unix_to_date' ? (
|
||||
<span style={{ fontFamily: 'monospace', color: 'var(--text)' }}>{result.utc}</span>
|
||||
) : (
|
||||
<span style={{ fontFamily: 'monospace', color: 'var(--text)' }}>{result.unix}</span>
|
||||
)}
|
||||
</div>
|
||||
<button className="ghost" onClick={copy} style={{ flexShrink: 0, margin: 0 }}>
|
||||
{copied ? 'Kopiert!' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimestampTool;
|
||||
@@ -2,9 +2,31 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
const TOOLS = [
|
||||
{ icon: '🔒', path: '/tools/md5', title: 'MD5 Hasher', desc: 'MD5-Hash berechnen (nur zur Analyse)' },
|
||||
{ icon: '#️⃣', path: '/tools/hasher', title: 'SHA256 / bcrypt', desc: 'Sichere Hash-Algorithmen für Strings' },
|
||||
{ icon: '📦', path: '/tools/base64', title: 'Base64', desc: 'Text kodieren und dekodieren' },
|
||||
{ icon: '🔑', path: '/tools/jwt', title: 'JWT Decoder', desc: 'JWT Token analysieren (ohne Signaturprüfung)' },
|
||||
{ icon: '🔐', path: '/tools/password', title: 'Passwort-Generator', desc: 'Sichere Passwörter generieren' },
|
||||
{ icon: '🕐', path: '/tools/timestamp', title: 'Timestamp Converter', desc: 'Unix Timestamp in Datum umrechnen' },
|
||||
{ icon: '📝', path: '/tools/textdiff', title: 'Text Diff', desc: 'Zwei Texte vergleichen' },
|
||||
{ icon: '📷', path: '/tools/qrcode', title: 'QR-Code Generator', desc: 'Text oder URL als QR-Code generieren' },
|
||||
{ icon: '📄', path: '/tools/markdown', title: 'Markdown Editor', desc: 'Markdown live rendern und vorschauen' },
|
||||
{ icon: '🎨', path: '/tools/color', title: 'Farb-Konverter', desc: 'HEX, RGB und HSL konvertieren' },
|
||||
{ icon: '{ }', path: '/tools/json', title: 'JSON Formatter', desc: 'JSON formatieren und validieren' },
|
||||
{ icon: '🔍', path: '/tools/regex', title: 'Regex Tester', desc: 'Reguläre Ausdrücke live testen' },
|
||||
{ icon: '✅', path: '/tools/hashverify', title: 'Hash Verifier', desc: 'Prüfen ob Text und Hash übereinstimmen' },
|
||||
{ icon: '🔗', path: '/tools/url', title: 'URL Encoder/Decoder', desc: 'URLs kodieren und dekodieren' },
|
||||
{ icon: '✏️', path: '/tools/string', title: 'String Utilities', desc: 'Text analysieren und transformieren' },
|
||||
{ icon: '⏰', path: '/tools/cron', title: 'Cron Erklärer', desc: 'Cron-Ausdrücke auf Deutsch erklären' },
|
||||
{ icon: '🌐', path: '/tools/ipcalc', title: 'IP / Subnetz Rechner', desc: 'CIDR Netzwerke berechnen' },
|
||||
{ icon: '📃', path: '/tools/lorem', title: 'Lorem Ipsum Generator', desc: 'Blindtext generieren' },
|
||||
{ icon: '📊', path: '/tools/csv', title: 'CSV Viewer', desc: 'CSV-Daten als Tabelle anzeigen' },
|
||||
{ icon: '🗒️', path: '/tools/notes', title: 'Notizen & Snippets', desc: 'Code und Texte speichern' },
|
||||
];
|
||||
|
||||
function ToolOverview() {
|
||||
const navigate = useNavigate();
|
||||
const role = localStorage.getItem('role');
|
||||
const [websites, setWebsites] = useState([]);
|
||||
const [loadingWebsites, setLoadingWebsites] = useState(true);
|
||||
|
||||
@@ -13,7 +35,7 @@ function ToolOverview() {
|
||||
try {
|
||||
const res = await axios.get('/api/websites');
|
||||
setWebsites(res.data);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
setWebsites([]);
|
||||
} finally {
|
||||
setLoadingWebsites(false);
|
||||
@@ -27,9 +49,24 @@ function ToolOverview() {
|
||||
<h2>Tool-Übersicht</h2>
|
||||
<p>Wähle ein Tool aus:</p>
|
||||
|
||||
<button onClick={() => navigate('/tools/md5')}>🔒 MD5 Tool</button><br /><br />
|
||||
<div className="card-grid">
|
||||
{TOOLS.map((tool) => (
|
||||
<div
|
||||
key={tool.path}
|
||||
className="link-card"
|
||||
onClick={() => navigate(tool.path)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className="link-card__icon">{tool.icon}</div>
|
||||
<div>
|
||||
<div className="link-card__title">{tool.title}</div>
|
||||
<div className="link-card__desc">{tool.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginTop: '24px' }}>🌐 Externe Webseiten</h3>
|
||||
<h3 style={{ marginTop: '24px' }}>Externe Webseiten</h3>
|
||||
{loadingWebsites ? (
|
||||
<p className="muted">Lade Links...</p>
|
||||
) : websites.length === 0 ? (
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from 'react';
|
||||
import axios from '../services/api';
|
||||
|
||||
const resultBox = {
|
||||
marginTop: '12px',
|
||||
padding: '12px 14px',
|
||||
background: 'var(--surface-2)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '10px',
|
||||
justifyContent: 'space-between',
|
||||
};
|
||||
|
||||
function UrlTool() {
|
||||
const [input, setInput] = useState('');
|
||||
const [result, setResult] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const request = async (action) => {
|
||||
setError('');
|
||||
setResult('');
|
||||
if (!input) {
|
||||
setError('Bitte Text eingeben.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await axios.post(`/api/url/${action}`, { text: input });
|
||||
setResult(res.data.result);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || `Fehler beim ${action === 'encode' ? 'Encoding' : 'Decoding'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(result);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<h2>URL Encoder / Decoder</h2>
|
||||
<p style={{ color: 'var(--muted)', marginBottom: '16px', fontSize: '0.95rem' }}>
|
||||
URLs und Strings kodieren oder dekodieren.
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
rows={4}
|
||||
value={input}
|
||||
onChange={(e) => { setInput(e.target.value); setResult(''); setError(''); }}
|
||||
placeholder="Text oder URL eingeben..."
|
||||
style={{ fontFamily: 'monospace', resize: 'vertical' }}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
|
||||
<button onClick={() => request('encode')}>Encode</button>
|
||||
<button className="ghost" onClick={() => request('decode')}>Decode</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="error" style={{ marginTop: '12px' }}>{error}</p>}
|
||||
|
||||
{result && (
|
||||
<div style={resultBox}>
|
||||
<span style={{ wordBreak: 'break-all', color: 'var(--text)', fontFamily: 'monospace', fontSize: '0.9rem', flex: 1 }}>
|
||||
{result}
|
||||
</span>
|
||||
<button className="ghost" onClick={copy} style={{ flexShrink: 0, margin: 0 }}>
|
||||
{copied ? 'Kopiert!' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UrlTool;
|
||||
@@ -26,7 +26,7 @@ p {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
input, textarea, select {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
@@ -36,10 +36,22 @@ input, textarea {
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus {
|
||||
select {
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%239fb3d8' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px var(--focus-ring);
|
||||
@@ -49,6 +61,25 @@ input::placeholder, textarea::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ef4444;
|
||||
font-size: 0.9rem;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.notes-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.notes-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
|
||||
@@ -29,3 +29,28 @@ button:disabled {
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
button.ghost:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
box-shadow: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
button.ghost.danger {
|
||||
color: #ef4444;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
button.ghost.danger:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './css/base.css';
|
||||
import './css/dark.css';
|
||||
import './css/light.css';
|
||||
import './css/navbar.css';
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user