12 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
48 changed files with 2922 additions and 148 deletions
+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
COPY backend/config /config
COPY backend/requirements.txt ./requirements.txt
COPY backend/entrypoint.sh ./entrypoint.sh
# Persistent directories (overridden by Docker volumes at runtime)
RUN mkdir -p /app/backend/logs /app/backend/backups
# Frontend aus Build-Stage übernehmen
COPY --from=frontend-build /app/frontend/dist ./frontend/dist
@@ -26,9 +30,12 @@ COPY --from=frontend-build /app/frontend/dist ./frontend/dist
# Python-Abhängigkeiten installieren
RUN pip install --no-cache-dir -r requirements.txt
# Entrypoint ausführbar machen
RUN chmod +x /app/entrypoint.sh
# Flask starten
WORKDIR /app/backend
ENV PYTHONPATH=/app/backend
ENV DB_CONFIG_PATH=/config/db_config.json
EXPOSE 5000
CMD ["python", "app.py"]
ENTRYPOINT ["/app/entrypoint.sh"]
+110 -111
View File
@@ -6,6 +6,8 @@ from util.logger import logger
admin_bp = Blueprint("admin", __name__)
_VALID_ROLES = {"user", "admin"}
def _require_admin():
user = verify_token()
@@ -17,29 +19,6 @@ def _require_admin():
return user, None
def _ensure_tables(cur):
cur.execute(
"""
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS websites (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
url VARCHAR(255) NOT NULL,
description VARCHAR(255) DEFAULT ''
)
"""
)
@admin_bp.route("/api/admin/users", methods=["GET"])
def list_users():
_, err = _require_admin()
@@ -47,12 +26,13 @@ def list_users():
return err
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
_ensure_tables(cur)
cur.execute("SELECT id, username, role FROM users ORDER BY username ASC")
users = cur.fetchall()
cur.close()
conn.close()
try:
cur = conn.cursor(dictionary=True)
cur.execute("SELECT id, username, role FROM users ORDER BY username ASC")
users = cur.fetchall()
cur.close()
finally:
conn.close()
return jsonify(users)
except Exception as e:
logger.error(f"[Admin list_users] {e}")
@@ -64,29 +44,31 @@ def create_user():
admin, err = _require_admin()
if err:
return err
data = request.get_json() or {}
data = request.get_json(silent=True) or {}
username = data.get("username", "").strip()
password = data.get("password", "")
role = data.get("role", "user")
if not username or not password:
return jsonify({"message": "Username und Passwort erforderlich"}), 400
if role not in _VALID_ROLES:
return jsonify({"message": f"Ungültige Rolle. Erlaubt: {', '.join(_VALID_ROLES)}"}), 400
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
_ensure_tables(cur)
cur.execute("SELECT id FROM users WHERE username=%s", (username,))
if cur.fetchone():
try:
cur = conn.cursor(dictionary=True)
cur.execute("SELECT id FROM users WHERE username=%s", (username,))
if cur.fetchone():
cur.close()
return jsonify({"message": "Nutzer existiert bereits"}), 409
cur.execute(
"INSERT INTO users (username, password, role) VALUES (%s, %s, %s)",
(username, generate_password_hash(password), role)
)
conn.commit()
new_id = cur.lastrowid
cur.close()
finally:
conn.close()
return jsonify({"message": "Nutzer existiert bereits"}), 409
cur.execute(
"INSERT INTO users (username, password, role) VALUES (%s, %s, %s)",
(username, generate_password_hash(password), role)
)
conn.commit()
new_id = cur.lastrowid
cur.close()
conn.close()
logger.info(f"✅ User erstellt: {username} durch {admin['username']}")
return jsonify({"id": new_id, "username": username, "role": role}), 201
except Exception as e:
@@ -99,25 +81,28 @@ def update_user(user_id):
admin, err = _require_admin()
if err:
return err
data = request.get_json() or {}
data = request.get_json(silent=True) or {}
role = data.get("role")
password = data.get("password")
if role is None and password is None:
return jsonify({"message": "Nichts zu aktualisieren"}), 400
if role is not None and role not in _VALID_ROLES:
return jsonify({"message": f"Ungültige Rolle. Erlaubt: {', '.join(_VALID_ROLES)}"}), 400
try:
conn = get_connection()
cur = conn.cursor()
_ensure_tables(cur)
if role:
cur.execute("UPDATE users SET role=%s WHERE id=%s", (role, user_id))
if password:
cur.execute(
"UPDATE users SET password=%s WHERE id=%s",
(generate_password_hash(password), user_id)
)
conn.commit()
cur.close()
conn.close()
try:
cur = conn.cursor()
if role:
cur.execute("UPDATE users SET role=%s WHERE id=%s", (role, user_id))
if password:
cur.execute(
"UPDATE users SET password=%s WHERE id=%s",
(generate_password_hash(password), user_id)
)
conn.commit()
cur.close()
finally:
conn.close()
logger.info(f"✏️ User aktualisiert (id={user_id}) durch {admin['username']}")
return jsonify({"message": "Aktualisiert"}), 200
except Exception as e:
@@ -132,24 +117,22 @@ def delete_user(user_id):
return err
try:
conn = get_connection()
cur = conn.cursor()
_ensure_tables(cur)
# Schutz: Admin darf sich nicht selbst löschen
cur.execute("SELECT username FROM users WHERE id=%s", (user_id,))
row = cur.fetchone()
if not row:
try:
cur = conn.cursor()
cur.execute("SELECT username FROM users WHERE id=%s", (user_id,))
row = cur.fetchone()
if not row:
cur.close()
return jsonify({"message": "Nicht gefunden"}), 404
username = row[0]
if username == admin["username"]:
cur.close()
return jsonify({"message": "Du kannst dich nicht selbst löschen"}), 400
cur.execute("DELETE FROM users WHERE id=%s", (user_id,))
conn.commit()
cur.close()
finally:
conn.close()
return jsonify({"message": "Nicht gefunden"}), 404
username = row[0]
if username == admin["username"]:
cur.close()
conn.close()
return jsonify({"message": "Du kannst dich nicht selbst löschen"}), 400
cur.execute("DELETE FROM users WHERE id=%s", (user_id,))
conn.commit()
cur.close()
conn.close()
logger.info(f"🗑️ User gelöscht (id={user_id}) durch {admin['username']}")
return jsonify({"message": "Gelöscht"}), 200
except Exception as e:
@@ -166,12 +149,13 @@ def list_websites_admin():
return err
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
_ensure_tables(cur)
cur.execute("SELECT id, name, url, description FROM websites ORDER BY name ASC")
rows = cur.fetchall()
cur.close()
conn.close()
try:
cur = conn.cursor(dictionary=True)
cur.execute("SELECT id, name, url, description FROM websites ORDER BY name ASC")
rows = cur.fetchall()
cur.close()
finally:
conn.close()
return jsonify(rows)
except Exception as e:
logger.error(f"[Admin list_websites] {e}")
@@ -183,7 +167,7 @@ def create_website():
_, err = _require_admin()
if err:
return err
data = request.get_json() or {}
data = request.get_json(silent=True) or {}
name = data.get("name", "").strip()
url = data.get("url", "").strip()
description = data.get("description", "").strip()
@@ -191,16 +175,17 @@ def create_website():
return jsonify({"message": "Name und URL erforderlich"}), 400
try:
conn = get_connection()
cur = conn.cursor()
_ensure_tables(cur)
cur.execute(
"INSERT INTO websites (name, url, description) VALUES (%s, %s, %s)",
(name, url, description),
)
conn.commit()
new_id = cur.lastrowid
cur.close()
conn.close()
try:
cur = conn.cursor()
cur.execute(
"INSERT INTO websites (name, url, description) VALUES (%s, %s, %s)",
(name, url, description),
)
conn.commit()
new_id = cur.lastrowid
cur.close()
finally:
conn.close()
return jsonify({"id": new_id, "name": name, "url": url, "description": description}), 201
except Exception as e:
logger.error(f"[Admin create_website] {e}")
@@ -212,18 +197,27 @@ def update_website(item_id):
_, err = _require_admin()
if err:
return err
data = request.get_json() or {}
data = request.get_json(silent=True) or {}
name = data.get("name", "").strip()
url = data.get("url", "").strip()
if not name or not url:
return jsonify({"message": "Name und URL erforderlich"}), 400
description = data.get("description", "").strip()
try:
conn = get_connection()
cur = conn.cursor()
_ensure_tables(cur)
cur.execute(
"UPDATE websites SET name=%s, url=%s, description=%s WHERE id=%s",
(data.get("name"), data.get("url"), data.get("description", ""), item_id),
)
conn.commit()
cur.close()
conn.close()
try:
cur = conn.cursor()
cur.execute(
"UPDATE websites SET name=%s, url=%s, description=%s WHERE id=%s",
(name, url, description, item_id),
)
conn.commit()
affected = cur.rowcount
cur.close()
finally:
conn.close()
if affected == 0:
return jsonify({"message": "Nicht gefunden"}), 404
return jsonify({"message": "Aktualisiert"}), 200
except Exception as e:
logger.error(f"[Admin update_website] {e}")
@@ -237,12 +231,16 @@ def delete_website(item_id):
return err
try:
conn = get_connection()
cur = conn.cursor()
_ensure_tables(cur)
cur.execute("DELETE FROM websites WHERE id=%s", (item_id,))
conn.commit()
cur.close()
conn.close()
try:
cur = conn.cursor()
cur.execute("DELETE FROM websites WHERE id=%s", (item_id,))
conn.commit()
affected = cur.rowcount
cur.close()
finally:
conn.close()
if affected == 0:
return jsonify({"message": "Nicht gefunden"}), 404
return jsonify({"message": "Gelöscht"}), 200
except Exception as e:
logger.error(f"[Admin delete_website] {e}")
@@ -258,12 +256,13 @@ def list_websites_public():
return jsonify({"message": "Nicht autorisiert"}), 401
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
_ensure_tables(cur)
cur.execute("SELECT id, name, url, description FROM websites ORDER BY name ASC")
rows = cur.fetchall()
cur.close()
conn.close()
try:
cur = conn.cursor(dictionary=True)
cur.execute("SELECT id, name, url, description FROM websites ORDER BY name ASC")
rows = cur.fetchall()
cur.close()
finally:
conn.close()
return jsonify(rows)
except Exception as e:
logger.error(f"[Public list_websites] {e}")
+68 -11
View File
@@ -1,10 +1,11 @@
import os
import sys
import time as _time
if __name__ != '__main__':
import sys
sys.path.append(os.path.dirname(__file__))
from flask import Flask, send_from_directory, redirect
from flask import Flask, send_from_directory, redirect, abort
from util.logger import logger
from util.db_config import is_configured, load_config, test_connection
from util.setup_routes import setup_blueprint
@@ -18,13 +19,25 @@ from tools import (
passwordgen_blueprint,
timestamp_blueprint,
textdiff_blueprint,
qrcode_blueprint,
markdown_blueprint,
color_blueprint,
json_formatter_blueprint,
regex_blueprint,
hashverifier_blueprint,
url_blueprint,
stringutils_blueprint,
cron_blueprint,
ipcalc_blueprint,
lorem_blueprint,
csv_blueprint,
notes_blueprint,
)
from admin import admin_bp
app = Flask(__name__, template_folder="templates")
limiter.init_app(app)
# Blueprints registrieren
app.register_blueprint(setup_blueprint)
app.register_blueprint(auth_bp)
app.register_blueprint(md5_blueprint)
@@ -34,26 +47,70 @@ app.register_blueprint(jwt_decoder_blueprint)
app.register_blueprint(passwordgen_blueprint)
app.register_blueprint(timestamp_blueprint)
app.register_blueprint(textdiff_blueprint)
app.register_blueprint(qrcode_blueprint)
app.register_blueprint(markdown_blueprint)
app.register_blueprint(color_blueprint)
app.register_blueprint(json_formatter_blueprint)
app.register_blueprint(regex_blueprint)
app.register_blueprint(hashverifier_blueprint)
app.register_blueprint(url_blueprint)
app.register_blueprint(stringutils_blueprint)
app.register_blueprint(cron_blueprint)
app.register_blueprint(ipcalc_blueprint)
app.register_blueprint(lorem_blueprint)
app.register_blueprint(csv_blueprint)
app.register_blueprint(notes_blueprint)
app.register_blueprint(admin_bp)
# 🌐 React-Frontend ausliefern
# Cache DB liveness check so we don't open a new TCP connection on every page load.
_db_check = {"ok": False, "ts": 0.0}
_DB_CHECK_TTL = 30.0 # seconds
def _is_db_ready():
now = _time.monotonic()
if now - _db_check["ts"] > _DB_CHECK_TTL:
_db_check["ok"] = is_configured() and bool(test_connection(load_config()))
_db_check["ts"] = now
return _db_check["ok"]
@app.route('/', defaults={'path': ''})
@app.route('/<path:path>')
def serve_frontend(path):
if not is_configured() or not test_connection(load_config()):
return redirect('/setup')
if path.startswith('setup') or path.startswith('api'):
from flask import abort
# Unmatched API / setup paths get a clean 404 before any DB work.
if path.startswith('api') or path.startswith('setup'):
abort(404)
if not _is_db_ready():
return redirect('/setup')
dist_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'frontend', 'dist'))
file_path = os.path.join(dist_dir, path)
if path and os.path.exists(file_path):
return send_from_directory(dist_dir, path)
else:
return send_from_directory(dist_dir, 'index.html')
return send_from_directory(dist_dir, 'index.html')
def _run_startup_migrations():
"""Run pending DB migrations if the database is already configured."""
if not is_configured():
logger.info("[startup] DB not yet configured — skipping migrations.")
return
try:
from util.db_pool import get_connection
from util.migrations import run_migrations
conn = get_connection()
try:
run_migrations(conn)
finally:
conn.close()
except Exception as e:
logger.error(f"[startup] Migration error: {e}")
_run_startup_migrations()
if __name__ == '__main__':
+1
View File
@@ -34,6 +34,7 @@ def login_route():
logger.info(f"Login successful: {username}")
payload = {
"id": user['id'],
"username": user['username'],
"role": user['role'],
"exp": datetime.now(timezone.utc) + timedelta(minutes=60)
+6 -1
View File
@@ -18,9 +18,14 @@ def verify_token():
logger.warning("🔐 Invalid Bearer header")
return None
token = auth_header.replace("Bearer ", "")
token = auth_header[7:] # len("Bearer ") == 7; safe because startswith is verified above
try:
decoded = decode(token, SECRET_KEY, algorithms=["HS256"])
# Reject tokens that are missing required fields (e.g. issued before
# 'id' was added to the payload) so callers never get a KeyError.
if not all(k in decoded for k in ("id", "username", "role")):
logger.warning("🔐 Token missing required fields — forcing re-login")
return None
return decoded
except ExpiredSignatureError:
logger.warning("🔐 Token expired")
+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
+2
View File
@@ -6,3 +6,5 @@ werkzeug>=2.3
PyJWT
bcrypt
python-dotenv
qrcode[pil]
markdown
+13
View File
@@ -5,3 +5,16 @@ 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
+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
+2 -2
View File
@@ -13,7 +13,7 @@ def hash_sha256():
if not user:
return jsonify({"message": "Nicht autorisiert"}), 401
try:
data = request.get_json()
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']}")
@@ -29,7 +29,7 @@ def hash_bcrypt():
if not user:
return jsonify({"message": "Nicht autorisiert"}), 401
try:
data = request.get_json()
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']}")
+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
+1 -1
View File
@@ -13,7 +13,7 @@ def decode_jwt():
if not user:
return jsonify({"message": "Nicht autorisiert"}), 401
try:
data = request.get_json()
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"])
+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
try:
data = request.get_json()
logger.debug(f"📩 Payload: {data}")
data = request.get_json(silent=True) or {}
password = data.get("password", "")
result = hashlib.md5(password.encode()).hexdigest()
+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
+1 -1
View File
@@ -13,7 +13,7 @@ def generate_password():
if not user:
return jsonify({"message": "Nicht autorisiert"}), 401
try:
data = request.get_json()
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)
+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
+1 -1
View File
@@ -12,7 +12,7 @@ def text_diff():
if not user:
return jsonify({"message": "Nicht autorisiert"}), 401
try:
data = request.get_json()
data = request.get_json(silent=True) or {}
text1 = data.get("text1", "")
text2 = data.get("text2", "")
+1 -1
View File
@@ -20,7 +20,7 @@ def convert_timestamp():
if not user:
return jsonify({"message": "Nicht autorisiert"}), 401
try:
data = request.get_json()
data = request.get_json(silent=True) or {}
value = data.get("value", "").strip()
direction = data.get("direction", "unix_to_date")
+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
+28 -9
View File
@@ -1,21 +1,40 @@
import logging
import os
from logging.handlers import RotatingFileHandler
os.makedirs("logs", exist_ok=True)
# Absolute path so the log dir is always next to this file, regardless of CWD.
_LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "logs")
os.makedirs(_LOG_DIR, exist_ok=True)
fmt = "%(asctime)s [%(levelname)s] %(message)s"
_FMT = "%(asctime)s [%(levelname)s] %(message)s"
_formatter = logging.Formatter(_FMT)
error_handler = logging.FileHandler("logs/error.log")
error_handler.setLevel(logging.ERROR)
_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)
logging.basicConfig(
level=logging.INFO,
format=fmt,
handlers=[
logging.FileHandler("logs/app.log"),
error_handler,
logging.StreamHandler()
]
_rotating("app.log", logging.INFO),
_rotating("error.log", logging.ERROR),
_console,
],
)
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.")
+12
View File
@@ -43,6 +43,18 @@ def setup():
reset_pool()
if test_connection(db_config):
initialize_admin_user(db_config)
# Apply schema migrations immediately so all tables exist before
# the user lands on the main page.
try:
from util.db_pool import get_connection
from util.migrations import run_migrations
conn = get_connection()
try:
run_migrations(conn)
finally:
conn.close()
except Exception as e:
logger.warning(f"[setup] Post-setup migration error: {e}")
return redirect('/')
else:
return "Verbindung fehlgeschlagen. Bitte zurück und prüfen.", 500
+2
View File
@@ -10,8 +10,10 @@ services:
volumes:
- config-data:/config
- logs-data:/app/backend/logs
- backups-data:/app/backend/backups
working_dir: /app/backend
command: python app.py
volumes:
config-data:
logs-data:
backups-data:
+39 -4
View File
@@ -1,5 +1,5 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
//import AdminDashboard from './components/AdminDashboard';
import { useState, useEffect } from 'react';
import LoginForm from './components/LoginForm';
//import RegisterForm from './components/RegisterForm';
import Md5Tool from './components/Md5Tool';
@@ -9,6 +9,19 @@ import JwtDecoderTool from './components/JwtDecoderTool';
import PasswordGenTool from './components/PasswordGenTool';
import TimestampTool from './components/TimestampTool';
import TextDiffTool from './components/TextDiffTool';
import QrCodeTool from './components/QrCodeTool';
import MarkdownTool from './components/MarkdownTool';
import ColorConverterTool from './components/ColorConverterTool';
import JsonFormatterTool from './components/JsonFormatterTool';
import RegexTesterTool from './components/RegexTesterTool';
import HashVerifierTool from './components/HashVerifierTool';
import UrlTool from './components/UrlTool';
import StringUtilsTool from './components/StringUtilsTool';
import CronExplainerTool from './components/CronExplainerTool';
import IpCalcTool from './components/IpCalcTool';
import LoremIpsumTool from './components/LoremIpsumTool';
import CsvViewerTool from './components/CsvViewerTool';
import NotesTool from './components/NotesTool';
import NavBar from './components/NavBar';
import ToolOverview from './components/ToolOverview';
import AdminDashboard from './components/AdminDashboard';
@@ -24,8 +37,17 @@ import './css/admin.css';
function App() {
const isLoggedIn = localStorage.getItem('token') !== null;
const role = localStorage.getItem('role');
const [isLoggedIn, setIsLoggedIn] = useState(localStorage.getItem('token') !== null);
const [role, setRole] = useState(localStorage.getItem('role'));
useEffect(() => {
const sync = () => {
setIsLoggedIn(localStorage.getItem('token') !== null);
setRole(localStorage.getItem('role'));
};
window.addEventListener('storage', sync);
return () => window.removeEventListener('storage', sync);
}, []);
return (
<ErrorBoundary>
@@ -41,7 +63,20 @@ function App() {
<Route path="/tools/jwt" element={isLoggedIn ? <JwtDecoderTool /> : <Navigate to="/login" />} />
<Route path="/tools/password" element={isLoggedIn ? <PasswordGenTool />: <Navigate to="/login" />} />
<Route path="/tools/timestamp" element={isLoggedIn ? <TimestampTool /> : <Navigate to="/login" />} />
<Route path="/tools/textdiff" element={isLoggedIn ? <TextDiffTool /> : <Navigate to="/login" />} />
<Route path="/tools/textdiff" element={isLoggedIn ? <TextDiffTool /> : <Navigate to="/login" />} />
<Route path="/tools/qrcode" element={isLoggedIn ? <QrCodeTool /> : <Navigate to="/login" />} />
<Route path="/tools/markdown" element={isLoggedIn ? <MarkdownTool /> : <Navigate to="/login" />} />
<Route path="/tools/color" element={isLoggedIn ? <ColorConverterTool /> : <Navigate to="/login" />} />
<Route path="/tools/json" element={isLoggedIn ? <JsonFormatterTool /> : <Navigate to="/login" />} />
<Route path="/tools/regex" element={isLoggedIn ? <RegexTesterTool /> : <Navigate to="/login" />} />
<Route path="/tools/hashverify" element={isLoggedIn ? <HashVerifierTool /> : <Navigate to="/login" />} />
<Route path="/tools/url" element={isLoggedIn ? <UrlTool /> : <Navigate to="/login" />} />
<Route path="/tools/string" element={isLoggedIn ? <StringUtilsTool /> : <Navigate to="/login" />} />
<Route path="/tools/cron" element={isLoggedIn ? <CronExplainerTool /> : <Navigate to="/login" />} />
<Route path="/tools/ipcalc" element={isLoggedIn ? <IpCalcTool /> : <Navigate to="/login" />} />
<Route path="/tools/lorem" element={isLoggedIn ? <LoremIpsumTool /> : <Navigate to="/login" />} />
<Route path="/tools/csv" element={isLoggedIn ? <CsvViewerTool /> : <Navigate to="/login" />} />
<Route path="/tools/notes" element={isLoggedIn ? <NotesTool /> : <Navigate to="/login" />} />
<Route
path="/admin"
element={
@@ -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;
@@ -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;
+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,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;
+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;
+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;
+13
View File
@@ -10,6 +10,19 @@ const TOOLS = [
{ 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() {
+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);
}
input, textarea {
input, textarea, select {
width: 100%;
max-width: 100%;
min-width: 0;
@@ -36,10 +36,22 @@ input, textarea {
border-radius: 12px;
padding: 12px 14px;
font-size: 1rem;
font-family: inherit;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
input:focus, textarea:focus {
select {
width: auto;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%239fb3d8' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 4px var(--focus-ring);
@@ -49,6 +61,25 @@ input::placeholder, textarea::placeholder {
color: var(--muted);
}
.error {
color: #ef4444;
font-size: 0.9rem;
margin: 4px 0 0;
}
.notes-layout {
display: grid;
grid-template-columns: 260px 1fr;
gap: 16px;
align-items: start;
}
@media (max-width: 680px) {
.notes-layout {
grid-template-columns: 1fr;
}
}
.card-grid {
display: grid;
gap: 14px;
+25
View File
@@ -29,3 +29,28 @@ button:disabled {
cursor: not-allowed;
box-shadow: none;
}
button.ghost {
background: transparent;
color: var(--text);
border-color: var(--border);
box-shadow: none;
}
button.ghost:hover {
border-color: var(--accent);
color: var(--accent);
box-shadow: none;
filter: none;
}
button.ghost.danger {
color: #ef4444;
border-color: #ef4444;
}
button.ghost.danger:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
border-color: #ef4444;
}