15 Commits

Author SHA1 Message Date
Nirodan ac31290a87 Reject tokens missing required fields to prevent KeyError crashes
Tokens issued before 'id' was added to the JWT payload pass signature
verification but cause a KeyError when endpoints access user['id'].
verify_token() now returns None for any token missing id/username/role,
triggering a 401 → the frontend interceptor clears localStorage and
redirects to /login so a fresh token is issued automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 10:33:11 +02:00
Nirodan 9db922703b Add versioned DB migration system with automatic backup
migrations.py
- schema_migrations table tracks applied versions (version, description, applied_at)
- MIGRATIONS list is append-only; each entry is (version, description, sql)
- backup() dumps all user-data tables to a timestamped JSON file in backups/
  before any schema changes so data can be recovered if something goes wrong
- run_migrations() is idempotent: already-applied versions are skipped

Integration
- app.py calls _run_startup_migrations() at module load so every restart
  applies any pending migrations (no-op if schema is current)
- setup_routes.py calls run_migrations() after the initial setup form is
  submitted so all tables exist before the user hits the main page for the
  first time
- notes.py and admin.py: removed all per-request CREATE TABLE DDL; schema is
  now owned entirely by the migration system

Docker
- docker-compose.dev.yml: add backups-data volume so JSON backups survive
  container restarts and rebuilds
- Dockerfile: pre-create /app/backend/logs and /app/backend/backups so the
  directories exist even before volumes are mounted

Adding future schema changes
- Append a new (version, description, sql) tuple to MIGRATIONS in migrations.py
- The next restart will detect it as pending, back up first, then apply it

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 10:27:11 +02:00
Nirodan 20cdbd69eb Fix Notes feature: correct auto-save logic and missing CSS
Functional fixes:
- Add isDirtyRef to distinguish user edits from note selection; auto-save now
  only fires after the user actually modifies content, not on every click
- Add selected and isNew to auto-save effect deps so the closure is never stale
  when switching between notes (previously could save to the wrong note)
- Double-check isDirtyRef inside the debounce callback to handle rapid note
  switches within the 1-second window
- Reset isDirtyRef to false after every successful save (manual and auto)

CSS fixes:
- Add button.ghost and button.ghost.danger styles to buttons.css; delete button
  was invisible/identical to the save button because .ghost was never defined
- Add select element styling to base.css to match input/textarea theming;
  language picker was unstyled browser-default in both dark and light mode
- Add .error class (red text) to base.css; error messages had no colour
- Add .notes-layout grid class with responsive media query (stacks below 680px);
  remove inline gridTemplateColumns that overflowed on small screens

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 10:19:40 +02:00
Nirodan 7827cda224 Add targeted comments for non-obvious constraints and invariants
- logger.py: note why log path uses abspath(__file__) instead of a relative path
- token.py: note why [7:] slice is safe (startswith already verified)
- ipcalc.py: explain /32 single-host and /31 RFC-3021 point-to-point special
  cases; explain why (~netmask) must be masked with 0xFFFFFFFF (Python ~int
  returns a negative arbitrary-precision value, not a 32-bit unsigned integer)
- notes.py: document the module-level _table_ready flag lifetime; explain why
  tzinfo is stripped before passing datetime to mysql-connector
- admin.py: document the module-level _tables_initialized flag lifetime

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 10:08:55 +02:00
Nirodan 98bb34f094 Fix bugs, add log rotation, and optimize hot paths
- Fix AttributeError crash on empty request body in md5, hasher, textdiff,
  jwtdecoder, timestamp, passwordgen (get_json without silent=True / or {})
- Fix memory exhaustion in ipcalc: replace list(network.hosts()) with direct
  arithmetic — safe for /8 and larger networks
- Fix O(1M) loop in cronexplainer.get_next_runs: rewrite to skip by
  month/day/hour instead of iterating every minute
- Fix connection leak in notes.ensure_table: add try/finally around conn.close
- Fix admin._ensure_tables / notes._ensure_table running DDL on every request:
  guard with module-level flags (_tables_initialized, _table_ready)
- Fix update_website returning 200 when no row matched; delete_website returning
  success when nothing was deleted; add rowcount checks for both
- Add role validation in admin create_user / update_user (_VALID_ROLES guard)
- Add delimiter length guard in csvviewer (csv.reader requires single char)
- Fix loremipsum: wrap int(count) in try/except ValueError → 400 response
- Fix auth/token: use auth_header[7:] instead of fragile .replace()
- Fix app.py: remove duplicate import sys; cache DB liveness check with 30s TTL
  to avoid a new TCP connection on every frontend page load; move api/setup
  path guard before DB check
- Replace FileHandler with RotatingFileHandler (5 MB / 3 backups) in logger;
  fix relative log paths to absolute paths anchored to __file__
- Wrap all DB connections in try/finally conn.close() throughout admin and notes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 10:06:29 +02:00
Nirodan 31494c9dab Fix login: include user id in JWT payload for notes tool
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 09:51:59 +02:00
Nirodan dedde400e1 Add entrypoint.sh: wait for MariaDB before starting Flask
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 09:37:01 +02:00
Nirodan 75062dbf5e Add 8 new tools: Hash Verifier, URL Tool, String Utils, Cron Explainer, IP Calc, Lorem Ipsum, CSV Viewer, Notes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 09:10:25 +02:00
Nirodan ef03e76950 Fix requirements.txt: add trailing newline
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:27:51 +02:00
Nirodan 34c82f3dca Add 5 new tools: QR-Code, Markdown, Color Converter, JSON Formatter, Regex Tester
- backend/tools/qrcode_gen.py: POST /api/qrcode/generate, returns base64 PNG (qrcode[pil])
- backend/tools/markdown_tool.py: POST /api/markdown/render, extensions: tables/fenced_code/nl2br
- backend/tools/colorconverter.py: POST /api/color/convert, HEX/RGB/HSL via colorsys (no deps)
- backend/tools/jsonformatter.py: POST /api/json/format, returns formatted JSON with line/col errors
- backend/tools/regextester.py: POST /api/regex/test, flags i/m/s, returns matches with positions
- QrCodeTool.jsx: generate + download PNG button
- MarkdownTool.jsx: split editor/preview, debounce 500ms, white preview bg
- ColorConverterTool.jsx: color swatch preview, per-format copy buttons
- JsonFormatterTool.jsx: indent toggle 2/4, pre result box with copy
- RegexTesterTool.jsx: debounce 400ms, yellow match highlighting, flag checkboxes
- All blueprints registered in app.py; qrcode[pil] + markdown added to requirements.txt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:19:34 +02:00
Nirodan 45e1934bee Fix App.jsx: remove dead comment, make auth state reactive
- Remove leftover commented-out AdminDashboard import
- Replace static localStorage reads with useState + useEffect
  so isLoggedIn/role update automatically on storage events
  (e.g. token deleted in another tab)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:02:15 +02:00
Nirodan 1a6f476dc8 Merge pull request 'Clean release: drop local startup scripts and agent stub' (#1) from release into main
Reviewed-on: #1
2026-04-24 17:42:00 +02:00
Nirodan 955bc9a7bf Fix 8 bugs found in code review
- auth/login.py: guard against missing JSON body (get_json silent=True, empty-string check)
- app.py: replace infinite redirect with 404 for unknown /api/* and /setup/* paths
- tools/jwtdecoder.py: add algorithms list to jwt.decode() for PyJWT 2.x compatibility
- util/setup_routes.py: call reset_pool() after save_config() so pool re-initialises with new DB credentials
- util/logger.py: set ERROR level on error.log handler so it no longer receives INFO/WARNING messages
- LoginForm.jsx: remove dead navigate() call that was immediately overridden by window.location.href
- main.jsx: remove base.css, dark.css, light.css that were already imported in App.jsx (duplicate imports)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 17:38:51 +02:00
Nirodan 7f9c5c874a Add 6 new tools: Hasher, Base64, JWT Decoder, Password Gen, Timestamp, Text Diff
- backend/tools/hasher.py: POST /api/hash/sha256 and /api/hash/bcrypt (bcrypt added to requirements)
- backend/tools/base64tool.py: POST /api/base64/encode and /api/base64/decode
- backend/tools/jwtdecoder.py: POST /api/jwt/decode (signature verification disabled)
- backend/tools/passwordgen.py: POST /api/password/generate with charset and length options
- backend/tools/timestamp.py: POST /api/timestamp/convert (unix<->date, ISO 8601 + German format)
- backend/tools/textdiff.py: POST /api/text/diff returning structured added/removed/unchanged lines
- All blueprints registered in app.py and tools/__init__.py
- React components with copy button, dark/light mode support via CSS variables
- ToolOverview rebuilt as card grid; App.jsx routes added for all tools

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 14:28:18 +02:00
Nirodan 80ec5eca7b Security, code quality and frontend improvements
- Move SECRET_KEY out of docker-compose into .env (env_file), add .env.example
- Add flask-limiter with 10 req/min on login route; introduce util/limiter.py
- Replace direct mysql.connector.connect() calls with MySQLConnectionPool via util/db_pool.py
- Fix deprecated datetime.utcnow() -> datetime.now(timezone.utc) in auth/login.py
- Remove dead /api/scripts 410 route from admin.py
- Add MD5 security warning in Md5Tool.jsx
- Add ErrorBoundary component and wrap App.jsx
- Expand README with setup guide, screenshot and project structure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 13:52:53 +02:00
63 changed files with 3943 additions and 227 deletions
+13
View File
@@ -0,0 +1,13 @@
# ============================================================
# Umgebungsvariablen für docker-compose.dev.yml
# Kopiere diese Datei zu ".env" und passe die Werte an.
# ============================================================
# JWT Secret Key
# Ersetze diesen Wert in Produktion durch einen langen, zufälligen String,
# z.B. generiert mit: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=dev-change-me
# Pfad zur Datenbankkonfigurationsdatei im Container
# Standardwert für Docker: /config/db_config.json
DB_CONFIG_PATH=/config/db_config.json
+8 -1
View File
@@ -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 # Store DB config in a docker-friendly location (/config), override via DB_CONFIG_PATH env if needed
COPY backend/config /config COPY backend/config /config
COPY backend/requirements.txt ./requirements.txt 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 # Frontend aus Build-Stage übernehmen
COPY --from=frontend-build /app/frontend/dist ./frontend/dist 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 # Python-Abhängigkeiten installieren
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Entrypoint ausführbar machen
RUN chmod +x /app/entrypoint.sh
# Flask starten # Flask starten
WORKDIR /app/backend WORKDIR /app/backend
ENV PYTHONPATH=/app/backend ENV PYTHONPATH=/app/backend
ENV DB_CONFIG_PATH=/config/db_config.json ENV DB_CONFIG_PATH=/config/db_config.json
EXPOSE 5000 EXPOSE 5000
CMD ["python", "app.py"] ENTRYPOINT ["/app/entrypoint.sh"]
+106 -20
View File
@@ -1,34 +1,120 @@
# 🐳 Docker-Webplattform: React + Flask + externe MariaDB # Docker-Webplattform: React + Flask + externe MariaDB
Dieses Projekt ist eine vollständig dockerisierte Webanwendung, die ein React-Frontend und ein Flask-Backend **in einem einzigen Container** vereint. Sie kommuniziert mit einer **externen MariaDB-Datenbank** (z.B. auf einem Unraid-Server) und bietet ein Setup-System, Login, Rollenverwaltung und modulare Tools. Vollständig dockerisierte Webanwendung mit React-Frontend und Flask-Backend in einem einzigen Container. Verbindet sich mit einer externen MariaDB-Datenbank und bietet Setup-System, Login, Rollenverwaltung und modulare Tools.
--- ---
## ⚙️ Was macht der Docker-Container? ## Screenshot
- 🚀 Startet automatisch **Flask + React** in einer Umgebung ![Admin-Dashboard](logs/Screenshot%202026-01-22%20151541.png)
- 🛠 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**. Dieses Projekt steht unter der **Creative Commons BY-NC 4.0 Lizenz**.
➡️ Du darfst ihn verwenden, verändern und teilen **aber nicht kommerziell nutzen.** Du darfst es 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) Volltext: https://creativecommons.org/licenses/by-nc/4.0
💡 Hinweis: Die Urheberschaft erfolgt unter Pseudonym. Author: Nirodan https://github.com/Nirodan
Author: Source page: Nirodan/Github:Nirodan Production
+120 -136
View File
@@ -1,12 +1,13 @@
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from mysql.connector import connect
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from auth.token import verify_token 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 from util.logger import logger
admin_bp = Blueprint("admin", __name__) admin_bp = Blueprint("admin", __name__)
_VALID_ROLES = {"user", "admin"}
def _require_admin(): def _require_admin():
user = verify_token() user = verify_token()
@@ -18,43 +19,20 @@ def _require_admin():
return user, None 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"]) @admin_bp.route("/api/admin/users", methods=["GET"])
def list_users(): def list_users():
_, err = _require_admin() _, err = _require_admin()
if err: if err:
return err return err
try: try:
cfg = load_config() conn = get_connection()
conn = connect(**cfg) try:
cur = conn.cursor(dictionary=True) cur = conn.cursor(dictionary=True)
_ensure_tables(cur) cur.execute("SELECT id, username, role FROM users ORDER BY username ASC")
cur.execute("SELECT id, username, role FROM users ORDER BY username ASC") users = cur.fetchall()
users = cur.fetchall() cur.close()
cur.close() finally:
conn.close() conn.close()
return jsonify(users) return jsonify(users)
except Exception as e: except Exception as e:
logger.error(f"[Admin list_users] {e}") logger.error(f"[Admin list_users] {e}")
@@ -66,30 +44,31 @@ def create_user():
admin, err = _require_admin() admin, err = _require_admin()
if err: if err:
return err return err
data = request.get_json() or {} data = request.get_json(silent=True) or {}
username = data.get("username", "").strip() username = data.get("username", "").strip()
password = data.get("password", "") password = data.get("password", "")
role = data.get("role", "user") role = data.get("role", "user")
if not username or not password: if not username or not password:
return jsonify({"message": "Username und Passwort erforderlich"}), 400 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: try:
cfg = load_config() conn = get_connection()
conn = connect(**cfg) try:
cur = conn.cursor(dictionary=True) cur = conn.cursor(dictionary=True)
_ensure_tables(cur) cur.execute("SELECT id FROM users WHERE username=%s", (username,))
cur.execute("SELECT id FROM users WHERE username=%s", (username,)) if cur.fetchone():
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() cur.close()
finally:
conn.close() 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']}") logger.info(f"✅ User erstellt: {username} durch {admin['username']}")
return jsonify({"id": new_id, "username": username, "role": role}), 201 return jsonify({"id": new_id, "username": username, "role": role}), 201
except Exception as e: except Exception as e:
@@ -102,26 +81,28 @@ def update_user(user_id):
admin, err = _require_admin() admin, err = _require_admin()
if err: if err:
return err return err
data = request.get_json() or {} data = request.get_json(silent=True) or {}
role = data.get("role") role = data.get("role")
password = data.get("password") password = data.get("password")
if role is None and password is None: if role is None and password is None:
return jsonify({"message": "Nichts zu aktualisieren"}), 400 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: try:
cfg = load_config() conn = get_connection()
conn = connect(**cfg) try:
cur = conn.cursor() cur = conn.cursor()
_ensure_tables(cur) if role:
if role: cur.execute("UPDATE users SET role=%s WHERE id=%s", (role, user_id))
cur.execute("UPDATE users SET role=%s WHERE id=%s", (role, user_id)) if password:
if password: cur.execute(
cur.execute( "UPDATE users SET password=%s WHERE id=%s",
"UPDATE users SET password=%s WHERE id=%s", (generate_password_hash(password), user_id)
(generate_password_hash(password), user_id) )
) conn.commit()
conn.commit() cur.close()
cur.close() finally:
conn.close() conn.close()
logger.info(f"✏️ User aktualisiert (id={user_id}) durch {admin['username']}") logger.info(f"✏️ User aktualisiert (id={user_id}) durch {admin['username']}")
return jsonify({"message": "Aktualisiert"}), 200 return jsonify({"message": "Aktualisiert"}), 200
except Exception as e: except Exception as e:
@@ -135,26 +116,23 @@ def delete_user(user_id):
if err: if err:
return err return err
try: try:
cfg = load_config() conn = get_connection()
conn = connect(**cfg) try:
cur = conn.cursor() cur = conn.cursor()
_ensure_tables(cur) cur.execute("SELECT username FROM users WHERE id=%s", (user_id,))
# Schutz: Admin darf sich nicht selbst löschen row = cur.fetchone()
cur.execute("SELECT username FROM users WHERE id=%s", (user_id,)) if not row:
row = cur.fetchone() cur.close()
if not row: 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() cur.close()
finally:
conn.close() 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']}") logger.info(f"🗑️ User gelöscht (id={user_id}) durch {admin['username']}")
return jsonify({"message": "Gelöscht"}), 200 return jsonify({"message": "Gelöscht"}), 200
except Exception as e: except Exception as e:
@@ -170,14 +148,14 @@ def list_websites_admin():
if err: if err:
return err return err
try: try:
cfg = load_config() conn = get_connection()
conn = connect(**cfg) try:
cur = conn.cursor(dictionary=True) cur = conn.cursor(dictionary=True)
_ensure_tables(cur) cur.execute("SELECT id, name, url, description FROM websites ORDER BY name ASC")
cur.execute("SELECT id, name, url, description FROM websites ORDER BY name ASC") rows = cur.fetchall()
rows = cur.fetchall() cur.close()
cur.close() finally:
conn.close() conn.close()
return jsonify(rows) return jsonify(rows)
except Exception as e: except Exception as e:
logger.error(f"[Admin list_websites] {e}") logger.error(f"[Admin list_websites] {e}")
@@ -189,25 +167,25 @@ def create_website():
_, err = _require_admin() _, err = _require_admin()
if err: if err:
return err return err
data = request.get_json() or {} data = request.get_json(silent=True) or {}
name = data.get("name", "").strip() name = data.get("name", "").strip()
url = data.get("url", "").strip() url = data.get("url", "").strip()
description = data.get("description", "").strip() description = data.get("description", "").strip()
if not name or not url: if not name or not url:
return jsonify({"message": "Name und URL erforderlich"}), 400 return jsonify({"message": "Name und URL erforderlich"}), 400
try: try:
cfg = load_config() conn = get_connection()
conn = connect(**cfg) try:
cur = conn.cursor() cur = conn.cursor()
_ensure_tables(cur) cur.execute(
cur.execute( "INSERT INTO websites (name, url, description) VALUES (%s, %s, %s)",
"INSERT INTO websites (name, url, description) VALUES (%s, %s, %s)", (name, url, description),
(name, url, description), )
) conn.commit()
conn.commit() new_id = cur.lastrowid
new_id = cur.lastrowid cur.close()
cur.close() finally:
conn.close() conn.close()
return jsonify({"id": new_id, "name": name, "url": url, "description": description}), 201 return jsonify({"id": new_id, "name": name, "url": url, "description": description}), 201
except Exception as e: except Exception as e:
logger.error(f"[Admin create_website] {e}") logger.error(f"[Admin create_website] {e}")
@@ -219,19 +197,27 @@ def update_website(item_id):
_, err = _require_admin() _, err = _require_admin()
if err: if err:
return 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: try:
cfg = load_config() conn = get_connection()
conn = connect(**cfg) try:
cur = conn.cursor() cur = conn.cursor()
_ensure_tables(cur) cur.execute(
cur.execute( "UPDATE websites SET name=%s, url=%s, description=%s WHERE id=%s",
"UPDATE websites SET name=%s, url=%s, description=%s WHERE id=%s", (name, url, description, item_id),
(data.get("name"), data.get("url"), data.get("description", ""), item_id), )
) conn.commit()
conn.commit() affected = cur.rowcount
cur.close() cur.close()
conn.close() finally:
conn.close()
if affected == 0:
return jsonify({"message": "Nicht gefunden"}), 404
return jsonify({"message": "Aktualisiert"}), 200 return jsonify({"message": "Aktualisiert"}), 200
except Exception as e: except Exception as e:
logger.error(f"[Admin update_website] {e}") logger.error(f"[Admin update_website] {e}")
@@ -244,14 +230,17 @@ def delete_website(item_id):
if err: if err:
return err return err
try: try:
cfg = load_config() conn = get_connection()
conn = connect(**cfg) try:
cur = conn.cursor() cur = conn.cursor()
_ensure_tables(cur) cur.execute("DELETE FROM websites WHERE id=%s", (item_id,))
cur.execute("DELETE FROM websites WHERE id=%s", (item_id,)) conn.commit()
conn.commit() affected = cur.rowcount
cur.close() cur.close()
conn.close() finally:
conn.close()
if affected == 0:
return jsonify({"message": "Nicht gefunden"}), 404
return jsonify({"message": "Gelöscht"}), 200 return jsonify({"message": "Gelöscht"}), 200
except Exception as e: except Exception as e:
logger.error(f"[Admin delete_website] {e}") logger.error(f"[Admin delete_website] {e}")
@@ -266,20 +255,15 @@ def list_websites_public():
if not user: if not user:
return jsonify({"message": "Nicht autorisiert"}), 401 return jsonify({"message": "Nicht autorisiert"}), 401
try: try:
cfg = load_config() conn = get_connection()
conn = connect(**cfg) try:
cur = conn.cursor(dictionary=True) cur = conn.cursor(dictionary=True)
_ensure_tables(cur) cur.execute("SELECT id, name, url, description FROM websites ORDER BY name ASC")
cur.execute("SELECT id, name, url, description FROM websites ORDER BY name ASC") rows = cur.fetchall()
rows = cur.fetchall() cur.close()
cur.close() finally:
conn.close() conn.close()
return jsonify(rows) return jsonify(rows)
except Exception as e: except Exception as e:
logger.error(f"[Public list_websites] {e}") logger.error(f"[Public list_websites] {e}")
return jsonify({"message": "Serverfehler"}), 500 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
View File
@@ -1,43 +1,116 @@
import os import os
import sys import sys
import time as _time
if __name__ != '__main__': if __name__ != '__main__':
import sys
sys.path.append(os.path.dirname(__file__)) 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.logger import logger
from util.db_config import is_configured, load_config, test_connection from util.db_config import is_configured, load_config, test_connection
from util.setup_routes import setup_blueprint from util.setup_routes import setup_blueprint
from util.limiter import limiter
from auth import auth_bp 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 from admin import admin_bp
app = Flask(__name__, template_folder="templates") app = Flask(__name__, template_folder="templates")
limiter.init_app(app)
# 📦 Blueprints registrieren
app.register_blueprint(setup_blueprint) app.register_blueprint(setup_blueprint)
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
app.register_blueprint(md5_blueprint) 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) 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('/', defaults={'path': ''})
@app.route('/<path:path>') @app.route('/<path:path>')
def serve_frontend(path): def serve_frontend(path):
if not is_configured() or not test_connection(load_config()): # Unmatched API / setup paths get a clean 404 before any DB work.
return redirect('/setup') if path.startswith('api') or path.startswith('setup'):
abort(404)
if path.startswith('setup') or path.startswith('api'): if not _is_db_ready():
return redirect(f'/{path}') return redirect('/setup')
dist_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'frontend', 'dist')) dist_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'frontend', 'dist'))
file_path = os.path.join(dist_dir, path) file_path = os.path.join(dist_dir, path)
if path and os.path.exists(file_path): if path and os.path.exists(file_path):
return send_from_directory(dist_dir, 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__': if __name__ == '__main__':
+15 -10
View File
@@ -1,25 +1,29 @@
from flask import request, jsonify from flask import request, jsonify
from mysql.connector import connect
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
import jwt import jwt
from util.logger import logger 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 from auth.token import SECRET_KEY
@limiter.limit("10 per minute")
def login_route(): def login_route():
data = request.get_json() data = request.get_json(silent=True) or {}
username = data.get('username') username = data.get('username', '').strip()
password = data.get('password') password = data.get('password', '')
if not username or not password:
return jsonify({"message": "Username und Passwort erforderlich"}), 400
if not SECRET_KEY: if not SECRET_KEY:
logger.error("Login blocked: SECRET_KEY is not configured.") logger.error("Login blocked: SECRET_KEY is not configured.")
return jsonify({"message": "Server misconfigured"}), 500 return jsonify({"message": "Server misconfigured"}), 500
try: try:
config = load_config() conn = get_connection()
conn = connect(**config)
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM users WHERE username = %s", (username,)) cursor.execute("SELECT * FROM users WHERE username = %s", (username,))
user = cursor.fetchone() user = cursor.fetchone()
@@ -27,12 +31,13 @@ def login_route():
conn.close() conn.close()
if user and check_password_hash(user['password'], password): if user and check_password_hash(user['password'], password):
logger.info(f"Login successful: {username}") logger.info(f"Login successful: {username}")
payload = { payload = {
"id": user['id'],
"username": user['username'], "username": user['username'],
"role": user['role'], "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") token = jwt.encode(payload, SECRET_KEY, algorithm="HS256")
+6 -1
View File
@@ -18,9 +18,14 @@ def verify_token():
logger.warning("🔐 Invalid Bearer header") logger.warning("🔐 Invalid Bearer header")
return None return None
token = auth_header.replace("Bearer ", "") token = auth_header[7:] # len("Bearer ") == 7; safe because startswith is verified above
try: try:
decoded = decode(token, SECRET_KEY, algorithms=["HS256"]) 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 return decoded
except ExpiredSignatureError: except ExpiredSignatureError:
logger.warning("🔐 Token expired") logger.warning("🔐 Token expired")
+35
View File
@@ -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
+4
View File
@@ -1,6 +1,10 @@
flask flask
flask-cors flask-cors
flask-limiter
mysql-connector-python mysql-connector-python
werkzeug>=2.3 werkzeug>=2.3
PyJWT PyJWT
bcrypt
python-dotenv python-dotenv
qrcode[pil]
markdown
+19
View File
@@ -1 +1,20 @@
from .md5 import md5_blueprint 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
+38
View File
@@ -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
+88
View File
@@ -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
+236
View File
@@ -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
+52
View File
@@ -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
+39
View File
@@ -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
+39
View File
@@ -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
+64
View File
@@ -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
+26
View File
@@ -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
+29
View File
@@ -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
+66
View File
@@ -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
+21
View File
@@ -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
+1 -2
View File
@@ -15,8 +15,7 @@ def hash_md5():
return jsonify({"message": "Nicht autorisiert"}), 401 return jsonify({"message": "Nicht autorisiert"}), 401
try: try:
data = request.get_json() data = request.get_json(silent=True) or {}
logger.debug(f"📩 Payload: {data}")
password = data.get("password", "") password = data.get("password", "")
result = hashlib.md5(password.encode()).hexdigest() result = hashlib.md5(password.encode()).hexdigest()
+130
View File
@@ -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
+41
View File
@@ -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
+35
View File
@@ -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
+41
View File
@@ -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
+53
View File
@@ -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
+37
View File
@@ -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
+46
View File
@@ -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
+36
View File
@@ -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
+26
View File
@@ -0,0 +1,26 @@
import mysql.connector.pooling
from util.logger import logger
_pool = None
def get_connection():
global _pool
if _pool is None:
from util.db_config import load_config
config = load_config()
if not config:
raise RuntimeError("DB-Konfiguration nicht verfügbar")
_pool = mysql.connector.pooling.MySQLConnectionPool(
pool_name="tools_pool",
pool_size=5,
**config
)
logger.info("DB-Verbindungspool erstellt (pool_size=5)")
return _pool.get_connection()
def reset_pool():
"""Pool zurücksetzen nach Konfigurationsänderung aufrufen."""
global _pool
_pool = None
+4
View File
@@ -0,0 +1,4 @@
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
+30 -9
View File
@@ -1,19 +1,40 @@
import logging import logging
import os import os
from logging.handlers import RotatingFileHandler
# Ensure logs directory exists # Absolute path so the log dir is always next to this file, regardless of CWD.
os.makedirs("logs", exist_ok=True) _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( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[ handlers=[
logging.FileHandler("logs/app.log"), _rotating("app.log", logging.INFO),
logging.FileHandler("logs/error.log"), _rotating("error.log", logging.ERROR),
logging.StreamHandler() _console,
] ],
) )
# Hauptlogger, wird von anderen Modulen importiert
logger = logging.getLogger("main") logger = logging.getLogger("main")
+161
View File
@@ -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.")
+14
View File
@@ -2,6 +2,7 @@ import time
import os import os
from flask import Blueprint, request, render_template, redirect, jsonify, send_from_directory 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_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 auth.setup_admin import initialize_admin_user
from util.logger import logger from util.logger import logger
@@ -39,8 +40,21 @@ def setup():
"database": request.form['database'] "database": request.form['database']
} }
save_config(db_config) save_config(db_config)
reset_pool()
if test_connection(db_config): if test_connection(db_config):
initialize_admin_user(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('/') return redirect('/')
else: else:
return "Verbindung fehlgeschlagen. Bitte zurück und prüfen.", 500 return "Verbindung fehlgeschlagen. Bitte zurück und prüfen.", 500
+4 -3
View File
@@ -5,14 +5,15 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
ports: ports:
- "5050:5000" - "5050:5000"
environment: env_file:
- SECRET_KEY=dev-change-me - .env
- DB_CONFIG_PATH=/config/db_config.json
volumes: volumes:
- config-data:/config - config-data:/config
- logs-data:/app/backend/logs - logs-data:/app/backend/logs
- backups-data:/app/backend/backups
working_dir: /app/backend working_dir: /app/backend
command: python app.py command: python app.py
volumes: volumes:
config-data: config-data:
logs-data: logs-data:
backups-data:
+70 -20
View File
@@ -1,11 +1,31 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; 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 LoginForm from './components/LoginForm';
//import RegisterForm from './components/RegisterForm'; //import RegisterForm from './components/RegisterForm';
import Md5Tool from './components/Md5Tool'; 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 NavBar from './components/NavBar';
import ToolOverview from './components/ToolOverview'; import ToolOverview from './components/ToolOverview';
import AdminDashboard from './components/AdminDashboard'; import AdminDashboard from './components/AdminDashboard';
import ErrorBoundary from './components/ErrorBoundary';
import './css/base.css'; import './css/base.css';
@@ -17,27 +37,57 @@ import './css/admin.css';
function App() { function App() {
const isLoggedIn = localStorage.getItem('token') !== null; const [isLoggedIn, setIsLoggedIn] = useState(localStorage.getItem('token') !== null);
const role = localStorage.getItem('role'); 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 ( return (
<BrowserRouter> <ErrorBoundary>
<NavBar /> <BrowserRouter>
<Routes> <NavBar />
<Route path="/" element={isLoggedIn ? <ToolOverview /> : <Navigate to="/login" />} /> <Routes>
<Route path="/login" element={<LoginForm />} /> <Route path="/" element={isLoggedIn ? <ToolOverview /> : <Navigate to="/login" />} />
{/*<Route path="/register" element={<RegisterForm />} />*/} <Route path="/login" element={<LoginForm />} />
<Route path="/tools/md5" element={isLoggedIn ? <Md5Tool /> : <Navigate to="/login" />} /> {/*<Route path="/register" element={<RegisterForm />} />*/}
<Route <Route path="/tools/md5" element={isLoggedIn ? <Md5Tool /> : <Navigate to="/login" />} />
path="/admin" <Route path="/tools/hasher" element={isLoggedIn ? <HasherTool /> : <Navigate to="/login" />} />
element={ <Route path="/tools/base64" element={isLoggedIn ? <Base64Tool /> : <Navigate to="/login" />} />
isLoggedIn && role === 'admin' <Route path="/tools/jwt" element={isLoggedIn ? <JwtDecoderTool /> : <Navigate to="/login" />} />
? <AdminDashboard /> <Route path="/tools/password" element={isLoggedIn ? <PasswordGenTool />: <Navigate to="/login" />} />
: <Navigate to="/" /> <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" />} />
</Routes> <Route path="/tools/markdown" element={isLoggedIn ? <MarkdownTool /> : <Navigate to="/login" />} />
</BrowserRouter> <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>
); );
} }
+65
View File
@@ -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;
+158
View File
@@ -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;
+33
View File
@@ -0,0 +1,33 @@
import { Component } from 'react';
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, info) {
console.error('ErrorBoundary caught:', error, info);
}
render() {
if (this.state.hasError) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<h2>Ein unerwarteter Fehler ist aufgetreten.</h2>
<p style={{ opacity: 0.7 }}>{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false, error: null })}>
Erneut versuchen
</button>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
@@ -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;
+68
View File
@@ -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;
+124
View File
@@ -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;
-2
View File
@@ -12,8 +12,6 @@ function LoginForm() {
const res = await axios.post('/api/login', { username, password }); const res = await axios.post('/api/login', { username, password });
localStorage.setItem('token', res.data.token); localStorage.setItem('token', res.data.token);
localStorage.setItem('role', res.data.role); localStorage.setItem('role', res.data.role);
navigate('/', { replace: true });
// ensure nav + route state reflect the new token immediately
window.location.href = '/'; window.location.href = '/';
} catch (err) { } catch (err) {
alert('Login fehlgeschlagen'); 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' }}>(120)</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;
+58
View File
@@ -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;
+6 -1
View File
@@ -18,11 +18,16 @@ function Md5Tool() {
return ( return (
<div className="main-content"> <div className="main-content">
<h2>MD5 Hasher</h2> <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 <input
type="text" type="text"
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
placeholder="Gib ein Passwort ein" placeholder="Eingabe"
/> />
<button onClick={hashPassword}>Hash berechnen</button> <button onClick={hashPassword}>Hash berechnen</button>
{result && <p><strong>MD5:</strong> {result}</p>} {result && <p><strong>MD5:</strong> {result}</p>}
+230
View File
@@ -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 &amp; 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;
+108
View File
@@ -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;
+47
View File
@@ -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;
+116
View File
@@ -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;
+151
View File
@@ -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;
+84
View File
@@ -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;
+101
View File
@@ -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;
+41 -4
View File
@@ -2,9 +2,31 @@ import { useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import axios from '../services/api'; 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() { function ToolOverview() {
const navigate = useNavigate(); const navigate = useNavigate();
const role = localStorage.getItem('role');
const [websites, setWebsites] = useState([]); const [websites, setWebsites] = useState([]);
const [loadingWebsites, setLoadingWebsites] = useState(true); const [loadingWebsites, setLoadingWebsites] = useState(true);
@@ -13,7 +35,7 @@ function ToolOverview() {
try { try {
const res = await axios.get('/api/websites'); const res = await axios.get('/api/websites');
setWebsites(res.data); setWebsites(res.data);
} catch (e) { } catch {
setWebsites([]); setWebsites([]);
} finally { } finally {
setLoadingWebsites(false); setLoadingWebsites(false);
@@ -27,9 +49,24 @@ function ToolOverview() {
<h2>Tool-Übersicht</h2> <h2>Tool-Übersicht</h2>
<p>Wähle ein Tool aus:</p> <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 ? ( {loadingWebsites ? (
<p className="muted">Lade Links...</p> <p className="muted">Lade Links...</p>
) : websites.length === 0 ? ( ) : websites.length === 0 ? (
+79
View File
@@ -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;
+33 -2
View File
@@ -26,7 +26,7 @@ p {
color: var(--muted); color: var(--muted);
} }
input, textarea { input, textarea, select {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
@@ -36,10 +36,22 @@ input, textarea {
border-radius: 12px; border-radius: 12px;
padding: 12px 14px; padding: 12px 14px;
font-size: 1rem; font-size: 1rem;
font-family: inherit;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; 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; outline: none;
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 4px var(--focus-ring); box-shadow: 0 0 0 4px var(--focus-ring);
@@ -49,6 +61,25 @@ input::placeholder, textarea::placeholder {
color: var(--muted); 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 { .card-grid {
display: grid; display: grid;
gap: 14px; gap: 14px;
+25
View File
@@ -29,3 +29,28 @@ button:disabled {
cursor: not-allowed; cursor: not-allowed;
box-shadow: none; 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;
}
-3
View File
@@ -1,9 +1,6 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import App from './App.jsx'; import App from './App.jsx';
import './css/base.css';
import './css/dark.css';
import './css/light.css';
import './css/navbar.css'; import './css/navbar.css';