Compare commits

..

10 commits

Author SHA1 Message Date
Michael
c106384c4e fix: template-Feld im JSON-Editor als Textarea rendern 2026-05-03 15:06:03 +02:00
Michael
9890763f0f refactor: split serve.py and index.html into single-responsibility modules
Backend:
- path_validator.py: PathValidator-Klasse für Pfad-Validierung
- file_ops.py: read_file, write_file, directory_exists, file_exists
- content_types.py: get_content_type mit EXTENSION_MAP
- handler.py: Handler-Klasse mit do_GET/do_PUT, nutzt above modules
- serve.py: Entry-Point (main, find_free_port), setzt Handler.validator/directory

Frontend:
- css/variables.css: CSS-Variablen (--bg-*, --text-*, --accent, etc.)
- css/styles.css: Alle CSS-Regeln (modal, card, template-grid, etc.)
- js/utils.js: esc, showToast, copyContentToClipboard
- js/modal.js: showModal, closeModal, closeEditModal, wasViewModalOpen
- js/editor.js: editModalContent, createJsonEditUI, extractJsonFromForm
- js/api.js: viewTemplate, copyContent, loadTemplates, saveEditedContent
- js/templates.js: renderTemplates, applyFilters, parseTypeFromHash
- js/main.js: Event-Listener, Hash-Filter, Initialisierung
- index.html: Inline-CSS/JS entfernt, <link>/<script src>-Tags hinzugefügt

Smoke test: SO_REUSEADDR für schnelle Port-Wiederverwendung
2026-05-03 14:40:44 +02:00
Michael
070ebc3edd fix: validate.py epilog --type-json, index.html wasViewModalOpen überschreibt nicht
- validate.py: --json durch --type-json im epilog aktualisiert
- index.html: closeModal() setzt wasViewModalOpen nur wenn noch nicht gesetzt
2026-05-03 14:12:36 +02:00
Michael
d808013395 fix: 5 letzte 'niedrig'-Priorität Probleme aus Review behoben
- serve.py: explizite if/else Content-Type (GET), PUT antwortet mit passendem CT
- index.html: Original-Indent speichern/wiederverwenden, View-Modal-Status nach Edit
- smoke_test.sh: $$ statt $PORT für Log-PID, pkill mit TERM+Retry+SIGKILL
- agent_verify.sh: try/except FileNotFoundError für node-Check
- validate.py: re.fullmatch statt re.match für Pattern-Validierung
2026-05-03 14:10:03 +02:00
Michael
f3a91e940e fix: 7 verbleibende Probleme aus Batch-1-Review behoben
- serve.py: TOCTOU in do_GET (urlparse), MAX_BODY vor content_length check
- index.html: Hover-CSS dark-theme, empty-state categories entfernt, extractInputValue JSON.parse safe
- validate.py: enum values-Leercheck, Exit-Code 2 für Validierungsfehler, ALLOWED_DIRS korrigiert
- smoke_test.sh: stderr durchreichen (2>&1), dynamische Endpunkt-Zahl
- README.md: --type-json, Schema-Sektionen bereinigt
2026-05-03 14:05:55 +02:00
Michael
c294336058 fix: 26 kritische + 29 wichtige Sicherheits- und Robustheitsprobleme behoben
- serve.py: URL-Encoding fix, Body-Limit (10MB), Port-Reuse, TOCTOU-Race, Logging
- index.html: XSS in renderTemplates geheilt, Escape-Key für Edit-Modal, Accessibility
- templates.json: Pfade ohne ../, leere Descriptions ergänzt
- validate.py: categories/ entfernt, CLI-Flags umbenannt, zu mutually_exclusive_group
- smoke_test.sh: set -euo pipefail, Port-Validation, Timeout 5s, code-Fallback
- cleanup_server.sh: lsof statt pgrep, Graceful-Term + SIGKILL-Fallback
- agent_verify.sh: set -euo pipefail, ROOT-Pfade, dynamischer Port, grep-Crash fix
- AGENTS.md: history/ entfernt, Pfad-Schema präzisiert
- README.md: categories/ entfernt, Web-Ansicht + API-Endpunkte hinzugefügt
2026-05-03 14:00:55 +02:00
Michael
1845b30992 fix: HEAD-Requests für /templates/* korrekt auflösen
- Implementiere do_HEAD in Handler
- Sende nur Response-Header mit Content-Type und Content-Length
- Kein Body, wie von HTTP/1.1 für HEAD spezifiziert
- Nutze dieselbe Pfadlogik wie do_GET
2026-04-26 23:03:09 +02:00
Michael
625a2b72c7 fix: smoke_test - Pre-Compile-Gate, Connect-Timeout, JSON-Inhalts-Check
Drei Maskierungs-Bugs: kaputtes serve.py liess curl auf localhost
auf einen unbenutzten Port unter WSL2 ~2 min pro Iteration haengen
(Polling-Loop bis 40 min); status-only-Tests sahen ein zerschossenes
templates.json nicht.

- python3 -m py_compile vor Server-Start: Syntax-Fehler scheitern in
  <100ms mit klarer Meldung statt nach 22s Timeout-Polling.
- 127.0.0.1 + --connect-timeout 1 ersetzt localhost-Polling: umgeht
  Resolver/IPv6-Falle, kappt jeden Versuch nach 1s.
- Endpoint-curls bekommen --max-time 5: ein einzelner haengender
  Endpunkt killt nicht mehr die Suite.
- Inhalts-Gate prueft nach den 8 HTTP-Checks, dass /templates.json
  parsable JSON ist - Pseudo-Fix-Schutz (kaputtes Manifest = HTTP 200
  + JSON.parse stirbt im Frontend).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:57:40 +02:00
Michael
1f59b384be feat: scripts/agent_verify.sh fuer dual_agent-Pipeline
Drei Verifikations-Schichten fuer den Planner-Executor-Workflow aus
~/idea/dual_agent:

1. Statisch: node --check auf allen <script>-Bloecken in index.html.
2. Dynamisch: smoke_test.sh auf Port 8088.
3. Semantisch: Funktions-Doppeldeklarationen + Platzhalter-Leichen
   in der Datei und in der letzten Commit-Message.

Die semantischen Checks adressieren konkret die No-Op-Muster, die
frueher als Pseudo-Fixes durchgerutscht sind.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:47:48 +02:00
Michael
a44220c793 fix: Render-Pfad — rekursiv erzeugte Kinder werden nun direkt in die Container innerContainer und innerObjContainer geschrieben, indem buildJsonForm(targetElement) die Kinder in das übergebene Element einfügt.
Konkret werden die Kinder in web/index.html Zeilen 628 bzw. 666 via buildJsonForm(..., innerContainer) bzw. buildJsonForm(..., innerObjContainer) an diese Container angehängt.
2026-04-24 17:26:38 +02:00
24 changed files with 1821 additions and 1192 deletions

3
.aider.chat.history.md Normal file
View file

@ -0,0 +1,3 @@
# aider chat started at 2026-04-27 19:27:46

View file

@ -69,8 +69,8 @@ Ein Commit = eine logische Änderung. Commit-Message im Stil der bestehenden His
### Einstieg ### Einstieg
```bash ```bash
python3 web/serve.py # startet Server auf http://localhost:8081 python3 web/serve.py # startet Server auf http://localhost:8081 (Server-Port)
./scripts/smoke_test.sh # temporärer Server + Endpunkt-Check + Cleanup (fuer R4) ./scripts/smoke_test.sh # temporärer Server + Endpunkt-Check + Cleanup (fuer R4, Standard-Port 8082)
./scripts/cleanup_server.sh # beendet hängende Instanzen ./scripts/cleanup_server.sh # beendet hängende Instanzen
python3 scripts/validate.py # validiert Template-Struktur python3 scripts/validate.py # validiert Template-Struktur
``` ```
@ -85,12 +85,14 @@ python3 scripts/validate.py # validiert Template-Struktur
| `templates/system/*.json` | System-Templates | | `templates/system/*.json` | System-Templates |
| `templates/user/*.md` | User-Templates | | `templates/user/*.md` | User-Templates |
| `scripts/validate.py` | Template-Validierung | | `scripts/validate.py` | Template-Validierung |
| `scripts/cleanup_server.sh` | Beendet serve.py auf Port 8081 (Standard) oder angegebenem Port |
| `docs/` | Weitere technische Dokumentation | | `docs/` | Weitere technische Dokumentation |
| `history/CHANGELOG.md` | Änderungs-Chronik |
### Datenschema `templates.json` und Template-Einträge ### Datenschema `templates.json` und Template-Einträge
`web/templates.json` ist eine **Liste** von Einträgen. Schema jedes Eintrags: `web/templates.json` ist eine **Liste** von Einträgen. `path`-Felder sind **relativ zu ROOT** (Projektstamm), OHNE `../` Präfix.
Schema jedes Eintrags:
```json ```json
{ "path": "…", "type": "…", "name": "…", "description": "…", "version": "…", "tags": [], "format": "md|json" } { "path": "…", "type": "…", "name": "…", "description": "…", "version": "…", "tags": [], "format": "md|json" }

View file

@ -13,11 +13,6 @@ prompt_template/
│ ├── user/ # Benutzer-Prompts (Emails, Texte, etc.) │ ├── user/ # Benutzer-Prompts (Emails, Texte, etc.)
│ └── custom/ # Benutzerdefinierte Templates │ └── custom/ # Benutzerdefinierte Templates
├── categories/ # Optional: Kategorisierte Templates
│ ├── marketing/
│ ├── technical/
│ └── creative/
├── scripts/ # Hilfsskripte ├── scripts/ # Hilfsskripte
│ └── validate.py # Template-Validierung │ └── validate.py # Template-Validierung
@ -27,9 +22,32 @@ prompt_template/
--- ---
## Web-Ansicht
Der Server startet mit:
```bash
python3 web/serve.py # http://localhost:8081
```
### API-Endpunkte
| URL | Methode | Beschreibung |
|-----|---------|--------------|
| `/` | GET | Frontend (index.html) |
| `/templates.json` | GET | Katalog (aus web/templates.json) |
| `/templates/system/*.json` | GET | System-Templates |
| `/templates/user/*.md` | GET | User-Templates |
| `/templates/...` | PUT | Template speichern (Content-Type: text/plain) |
### Validierung
```bash
python3 scripts/validate.py --all
```
## Dateiformate ## Dateiformate
### JSON (empfohlen für strukturierte Templates) ### JSON-Templates (strukturiert)
Ein Template-File (z.B. `templates/system/code_reviewer.json`) hat dieses Schema:
```json ```json
{ {
"name": "Template Name", "name": "Template Name",
@ -45,7 +63,23 @@ prompt_template/
} }
``` ```
### Markdown (für einfache Templates mit Dokumentation) ### Katalog (web/templates.json)
Der Katalog ist eine Liste von Einträgen mit diesem Schema:
```json
[
{
"path": "templates/system/code_reviewer.json",
"type": "system",
"name": "Code Reviewer",
"description": "Analysiert Code auf Qualität",
"version": "1.0",
"tags": ["code", "review"],
"format": "json"
}
]
```
### Markdown-Templates (einfach)
```markdown ```markdown
# Template Name # Template Name
@ -85,7 +119,7 @@ python scripts/validate.py pfad/zum/template.json
python scripts/validate.py --all python scripts/validate.py --all
# Nur JSON-Templates # Nur JSON-Templates
python scripts/validate.py --json python scripts/validate.py --type-json
``` ```
--- ---

1
aider_test/repo Submodule

@ -0,0 +1 @@
Subproject commit 3ec8ec5a7d695b08a6c24fe6c0c235c8f87df9af

1
openhands_test/repo Submodule

@ -0,0 +1 @@
Subproject commit 28d26f817854eb5b5bfce977020e326f64b1e2b5

68
scripts/agent_verify.sh Executable file
View file

@ -0,0 +1,68 @@
#!/bin/bash
# Verifikations-Hook fuer dual_agent-Pipeline.
# Wird nach dem Executor-Lauf aufgerufen; Exit 0 = gruen.
# stdout/stderr landen bei Retry als Kontext beim Planner.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
FAIL=0
# --- 1. Statisch: JS-Syntax in index.html ------------------------------
if [ -f "$ROOT/web/index.html" ]; then
python3 - <<'PY' || FAIL=1
import re, subprocess, tempfile, sys
html = open(sys.argv[1] if len(sys.argv) > 1 else 'web/index.html').read()
for i, s in enumerate(re.findall(r'<script[^>]*>([\s\S]*?)</script>', html)):
with tempfile.NamedTemporaryFile('w', suffix='.js', delete=True) as f:
f.write(s); p = f.name
try:
r = subprocess.run(['node', '--check', p], capture_output=True, text=True)
except FileNotFoundError:
print('WARN: node nicht gefunden — JS-Syntax-Check übersprungen')
sys.exit(0)
if r.returncode != 0:
print(f'JS[{i}] syntax error:\n{r.stderr}')
sys.exit(1)
print('JS syntax OK')
PY
else
echo "FAIL: web/index.html fehlt"
FAIL=1
fi
# --- 2. Dynamisch: Smoke-Test (alle Endpunkte) -------------------------
if [ -x "$ROOT/scripts/smoke_test.sh" ]; then
SMOKE_PORT=$(( $$ % 1000 + 9000 ))
"$ROOT/scripts/smoke_test.sh" "$SMOKE_PORT" || FAIL=1
fi
# --- 3. Semantisch: Pseudo-Fix-Muster verbieten ------------------------
# Diese Patterns haben wir als No-Op-Fixes im JSON-Editor erlebt.
# Ein neuer Bugfix darf sie nicht wieder einfuehren.
if [ -f "$ROOT/web/index.html" ]; then
# Doppeldeklaration einer Funktion
for fn in buildJsonForm extractJsonFromForm createJsonEditUI createTextEditUI applyFilters; do
count=$(grep -cE "function[[:space:]]+${fn}" "$ROOT/web/index.html" 2>/dev/null || echo 0)
if [ "$count" -gt 1 ]; then
echo "FAIL: Funktion '$fn' ist $count mal deklariert (erwartet: 1)"
FAIL=1
fi
done
# Platzhalter-Leichen in geaenderten HTML-Attributen
if grep -qE 'an[[:space:]][[:space:]]+und[[:space:]][[:space:]]+angehaengt' "$ROOT/web/index.html"; then
echo "FAIL: Platzhalter-Leichen in der Datei (Variablen nicht interpoliert)"
FAIL=1
fi
fi
# --- 4. Git-Hygiene: Commit-Message ohne Platzhalter-Leichen ----------
if ! git rev-parse HEAD >/dev/null 2>&1; then
echo "WARN: Kein Commit im Repo"
fi
last_msg=$(git log -1 --pretty=%B 2>/dev/null || true)
if printf '%s\n' "$last_msg" | grep -qE '\b(an|in|mit)[[:space:]][[:space:]]+und'; then
echo "FAIL: letzter Commit hat doppelte Leerzeichen (Platzhalter nicht ersetzt):"
printf '%s\n' "$last_msg" | head -3
FAIL=1
fi
exit $FAIL

View file

@ -1,10 +1,26 @@
#!/bin/bash #!/usr/bin/env bash
# Beendet laufende Instanzen von web/serve.py, um Port-Konflikte auf 8081 zu lösen.
set -euo pipefail set -euo pipefail
if pgrep -f "python3 .*web/serve.py" >/dev/null; then PORT="${1:-8081}"
pkill -f "python3 .*web/serve.py"
echo "Alte serve.py-Prozesse beendet." # Finde Prozess auf Port
PID=$(lsof -ti ":$PORT" 2>/dev/null || true)
if [ -n "$PID" ]; then
# Graceful shutdown attempt
kill -TERM "$PID" 2>/dev/null || true
# Warte bis zu 5 Sekunden
for i in $(seq 1 10); do
if ! kill -0 "$PID" 2>/dev/null; then
break
fi
sleep 0.5
done
# Force kill if still running
if kill -0 "$PID" 2>/dev/null; then
kill -9 "$PID" 2>/dev/null || true
fi
echo "Prozess $PID auf Port $PORT beendet."
else else
echo "Keine laufende serve.py-Instanz gefunden." echo "Keine laufende Instanz auf Port $PORT gefunden."
fi fi

View file

@ -7,25 +7,46 @@
# ./scripts/smoke_test.sh 9000 # anderer Port # ./scripts/smoke_test.sh 9000 # anderer Port
# Exit-Code: 0 = alle Endpunkte 200, sonst 1. # Exit-Code: 0 = alle Endpunkte 200, sonst 1.
set -u set -euo pipefail
PORT="${1:-8082}" PORT="${1:-8082}"
[[ "$PORT" =~ ^[0-9]+$ ]] || { echo "Usage: $0 [port]" >&2; exit 1; }
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
LOG="/tmp/smoke.$PORT.log" LOG="/tmp/smoke.$$.log"
EXIT_CODE=1 EXIT_CODE=1
MARKER="SMOKE_TEST_SERVER_$PORT" MARKER="SMOKE_TEST_SERVER_$PORT"
TIMEOUT=30
START_TIME=$(date +%s)
# Vorherige Instanzen dieses Smoke-Tests beenden # Vorherige Instanzen dieses Smoke-Tests beenden
pkill -f "$MARKER" 2>/dev/null || true # Präziser: nur Prozesse mit exaktem Marker-String killen
sleep 0.1 if pgrep -f "$MARKER" >/dev/null 2>&1; then
pkill -TERM -f "$MARKER" 2>/dev/null || true
sleep 0.5
if pgrep -f "$MARKER" >/dev/null 2>&1; then
pkill -9 -f "$MARKER" 2>/dev/null || true
fi
fi
# Pre-Check: serve.py muss sich kompilieren lassen. Sonst stirbt der
# Server beim Import und das Polling unten wartet umsonst — wir wollen
# in <100ms mit klarer Fehlermeldung scheitern, nicht in 22s mit
# "Server nicht erreichbar".
cd "$ROOT"
if ! python3 -m py_compile web/serve.py 2> "$LOG"; then
echo "FAIL: web/serve.py hat Syntax-/Indent-Fehler"
echo "--- $LOG ---"
cat "$LOG"
exit 1
fi
# Server im Hintergrund starten (exec → $! ist der Python-Prozess) # Server im Hintergrund starten (exec → $! ist der Python-Prozess)
cd "$ROOT"
python3 -c " python3 -c "
import sys; sys.argv[0] = '$MARKER' import sys; sys.argv[0] = '$MARKER'
sys.path.insert(0, 'web') sys.path.insert(0, 'web')
import http.server, socketserver import http.server, socketserver
from serve import Handler from serve import Handler
port = $PORT port = $PORT
socketserver.TCPServer.allow_reuse_address = True
print(f'Serving on http://localhost:{port}', flush=True) print(f'Serving on http://localhost:{port}', flush=True)
with socketserver.TCPServer(('', port), Handler) as httpd: with socketserver.TCPServer(('', port), Handler) as httpd:
httpd.serve_forever() httpd.serve_forever()
@ -35,19 +56,31 @@ cleanup() {
kill "$PID" 2>/dev/null || true kill "$PID" 2>/dev/null || true
wait "$PID" 2>/dev/null || true wait "$PID" 2>/dev/null || true
# Defensiv: falls der Kill-Signal den Python-Prozess nicht trifft # Defensiv: falls der Kill-Signal den Python-Prozess nicht trifft
pkill -f "$MARKER" 2>/dev/null || true if pgrep -f "$MARKER" >/dev/null 2>&1; then
pkill -TERM -f "$MARKER" 2>/dev/null || true
sleep 0.3
if pgrep -f "$MARKER" >/dev/null 2>&1; then
pkill -9 -f "$MARKER" 2>/dev/null || true
fi
fi
exit "$EXIT_CODE" exit "$EXIT_CODE"
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
# Auf Port-Binding warten (max. 2s) # Auf Port-Binding warten (max. 2s).
for i in $(seq 1 20); do # 127.0.0.1 statt localhost umgeht den Resolver — sonst kann unter
if curl -sf -o /dev/null "http://localhost:$PORT/"; then break; fi # WSL2/IPv6 ein TCP-SYN auf einen unbenutzten Port minutenlang ohne
# RST hängen. --connect-timeout 1 ist Defense-in-Depth.
for i in $(seq 1 50); do
if (( $(date +%s) - START_TIME >= TIMEOUT )); then
break
fi
if curl -sf --connect-timeout 1 -o /dev/null "http://127.0.0.1:$PORT/"; then break; fi
sleep 0.1 sleep 0.1
done done
# Wenn nicht erreichbar: Log ausgeben und fail # Wenn nicht erreichbar: Log ausgeben und fail
if ! curl -sf -o /dev/null "http://localhost:$PORT/"; then if ! curl -sf --connect-timeout 1 -o /dev/null "http://127.0.0.1:$PORT/"; then
echo "FAIL: Server nicht erreichbar auf Port $PORT" echo "FAIL: Server nicht erreichbar auf Port $PORT"
echo "--- $LOG ---" echo "--- $LOG ---"
cat "$LOG" cat "$LOG"
@ -63,14 +96,28 @@ for p in "/" "/index.html" "/templates.json" \
"/templates/system/summarizer.json" \ "/templates/system/summarizer.json" \
"/templates/user/email_draft.md" \ "/templates/user/email_draft.md" \
"/templates/user/brainstorming.md"; do "/templates/user/brainstorming.md"; do
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT$p") code=$(curl -s --max-time 5 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT$p")
code="${code:-000}"
status="ok" status="ok"
[ "$code" = "200" ] || { status="FAIL"; FAIL=1; } [ "$code" = "200" ] || { status="FAIL"; FAIL=1; }
printf '%-50s %s %s\n' "$p" "$code" "$status" printf '%-50s %s %s\n' "$p" "$code" "$status"
done done
# Inhalts-Gate: templates.json muss valides JSON sein. Ohne diese
# Pruefung kann ein "Fix" das Manifest zerschiessen, der Server liefert
# es weiterhin mit HTTP 200 aus, das Frontend stirbt aber beim
# JSON.parse. Status-only-Tests sehen das nicht.
if [ "$FAIL" -eq 0 ]; then
if ! curl -s --max-time 5 "http://127.0.0.1:$PORT/templates.json" \
| python3 -c 'import json,sys; json.load(sys.stdin)' 2>&1; then
echo "FAIL: /templates.json ist kein gueltiges JSON"
FAIL=1
fi
fi
EXIT_CODE=$FAIL EXIT_CODE=$FAIL
if [ "$FAIL" -eq 0 ]; then if [ "$FAIL" -eq 0 ]; then
echo "--- alle $( (echo "/" "/index.html" "/templates.json" "/templates/system/commit_analysis.json" "/templates/system/code_reviewer.json" "/templates/system/summarizer.json" "/templates/user/email_draft.md" "/templates/user/brainstorming.md") | wc -w ) Endpunkte OK ---" ENDPOINT_COUNT=8 # Anzahl der getesteten Endpunkte
echo "--- alle $ENDPOINT_COUNT Endpunkte OK + templates.json valide ---"
fi fi
exit $FAIL exit $FAIL

View file

@ -29,7 +29,10 @@ JSON_SCHEMA = {
r"^.+$": { r"^.+$": {
"type": "object", "type": "object",
"properties": { "properties": {
"type": {"type": "string", "enum": ["string", "number", "enum", "boolean"]}, "type": {
"type": "string",
"enum": ["string", "number", "enum", "boolean"],
},
"required": {"type": "boolean"}, "required": {"type": "boolean"},
"default": {}, "default": {},
"description": {"type": "string"}, "description": {"type": "string"},
@ -46,30 +49,29 @@ JSON_SCHEMA = {
# Muster für Markdown-Templates # Muster für Markdown-Templates
MD_REQUIRED_SECTIONS = ["Template", "Variablen"] MD_REQUIRED_SECTIONS = ["Template", "Variablen"]
MD_VARIABLE_PATTERN = re.compile(r"\|\s*(\w+)\s*\|\s*(\w+)\s*\|")
def validate_json_template(filepath: Path) -> Tuple[bool, List[str]]: def validate_json_template(filepath: Path) -> Tuple[bool, List[str]]:
"""Validiert ein JSON-Template.""" """Validiert ein JSON-Template."""
errors = [] errors = []
try: try:
with open(filepath, 'r', encoding='utf-8') as f: with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
return False, [f"❌ JSON Syntax Error: {e}"] return False, [f"❌ JSON Syntax Error: {e}"]
except Exception as e: except Exception as e:
return False, [f"❌ Datei kann nicht gelesen werden: {e}"] return False, [f"❌ Datei kann nicht gelesen werden: {e}"]
# Schemas validieren # Schemas validieren
if not isinstance(data, dict): if not isinstance(data, dict):
return False, ["❌ Root muss ein Object sein"] return False, ["❌ Root muss ein Object sein"]
# Required Felder # Required Felder
for field in JSON_SCHEMA.get("required", []): for field in JSON_SCHEMA.get("required", []):
if field not in data: if field not in data:
errors.append(f"❌ Fehlendes Pflichtfeld: '{field}'") errors.append(f"❌ Fehlendes Pflichtfeld: '{field}'")
# Feld-Typen validieren # Feld-Typen validieren
for field, schema in JSON_SCHEMA.get("properties", {}).items(): for field, schema in JSON_SCHEMA.get("properties", {}).items():
if field in data: if field in data:
@ -80,12 +82,20 @@ def validate_json_template(filepath: Path) -> Tuple[bool, List[str]]:
errors.append(f"❌ Feld '{field}' muss ein Object sein") errors.append(f"❌ Feld '{field}' muss ein Object sein")
elif field_type == "array" and not isinstance(data[field], list): elif field_type == "array" and not isinstance(data[field], list):
errors.append(f"❌ Feld '{field}' muss ein Array sein") errors.append(f"❌ Feld '{field}' muss ein Array sein")
# Pattern validieren # Pattern validieren
if "pattern" in schema and isinstance(data[field], str): if "pattern" in schema and isinstance(data[field], str):
if not re.match(schema["pattern"], data[field]): pattern = schema["pattern"]
errors.append(f"❌ Feld '{field}' entspricht nicht dem Pattern: {schema['pattern']}") # Sicherstellen dass Pattern ^...$ hat für fullmatch
if not (pattern.startswith("^") and pattern.endswith("$")):
fullmatch_pattern = f"^{pattern}$"
else:
fullmatch_pattern = pattern
if not re.fullmatch(fullmatch_pattern, data[field]):
errors.append(
f"❌ Feld '{field}' entspricht nicht dem Pattern: {pattern}"
)
# Template prüfen # Template prüfen
if "template" in data: if "template" in data:
template = data["template"] template = data["template"]
@ -98,8 +108,10 @@ def validate_json_template(filepath: Path) -> Tuple[bool, List[str]]:
defined_vars = set(data["variables"].keys()) defined_vars = set(data["variables"].keys())
undefined_vars = template_vars - defined_vars undefined_vars = template_vars - defined_vars
if undefined_vars: if undefined_vars:
errors.append(f"❌ Undefinierte Variablen im Template: {', '.join(undefined_vars)}") errors.append(
f"❌ Undefinierte Variablen im Template: {', '.join(undefined_vars)}"
)
# Variablen validieren # Variablen validieren
if "variables" in data: if "variables" in data:
variables = data["variables"] variables = data["variables"]
@ -112,58 +124,74 @@ def validate_json_template(filepath: Path) -> Tuple[bool, List[str]]:
continue continue
if "type" not in var_schema: if "type" not in var_schema:
errors.append(f"❌ Variable '{var_name}' benötigt ein 'type' Feld") errors.append(f"❌ Variable '{var_name}' benötigt ein 'type' Feld")
if var_schema.get("type") == "enum" and "values" not in var_schema: if var_schema.get("type") == "enum":
errors.append(f"❌ Enum Variable '{var_name}' benötigt 'values' Array") if "values" not in var_schema:
errors.append(
f"❌ Enum Variable '{var_name}' benötigt 'values' Array"
)
elif not var_schema.get("values"):
errors.append(
f"❌ Enum Variable '{var_name}' hat leeres 'values' Array"
)
return len(errors) == 0, errors return len(errors) == 0, errors
def validate_md_template(filepath: Path) -> Tuple[bool, List[str]]: def validate_md_template(filepath: Path) -> Tuple[bool, List[str]]:
"""Validiert ein Markdown-Template.""" """Validiert ein Markdown-Template."""
errors = [] errors = []
try: try:
with open(filepath, 'r', encoding='utf-8') as f: with open(filepath, "r", encoding="utf-8") as f:
content = f.read() content = f.read()
except Exception as e: except Exception as e:
return False, [f"❌ Datei kann nicht gelesen werden: {e}"] return False, [f"❌ Datei kann nicht gelesen werden: {e}"]
# Mindestlänge # Mindestlänge
if len(content.strip()) < 50: if len(content.strip()) < 50:
errors.append(" Template zu kurz (mind. 50 Zeichen)") errors.append("⚠️ Warnung: Template zu kurz (mind. 50 Zeichen)")
# Titel prüfen # Titel prüfen
if not content.startswith("# "): if not content.startswith("# "):
errors.append("❌ Fehlender Titel (erwartet: # Titel)") errors.append("❌ Fehlender Titel (erwartet: # Titel)")
# Pflichtabschnitte prüfen # Pflichtabschnitte prüfen
for section in MD_REQUIRED_SECTIONS: for section in MD_REQUIRED_SECTIONS:
if f"## {section}" not in content and f"# {section}" not in content: if f"## {section}" not in content and f"# {section}" not in content:
errors.append(f"❌ Fehlender Abschnitt: {section}") errors.append(f"❌ Fehlender Abschnitt: {section}")
# Variablen-Tabelle prüfen # Variablen-Tabelle prüfen
if "Variablen" in content: if "Variablen" in content:
var_section_start = content.find("## Variablen") var_section_start = content.find("## Variablen")
if var_section_start == -1: if var_section_start == -1:
var_section_start = content.find("# Variablen") var_section_start = content.find("# Variablen")
if var_section_start != -1: if var_section_start != -1:
var_section = content[var_section_start:var_section_start + 500] var_section = content[var_section_start : var_section_start + 500]
if "| Variable |" not in var_section: if "| Variable |" not in var_section:
errors.append("❌ Variablen-Tabelle nicht im korrekten Format") errors.append("❌ Variablen-Tabelle nicht im korrekten Format")
# Template-Block prüfen # Template-Block prüfen
if "Template" in content: if "Template" in content:
template_start = content.find("```") template_block_start = content.find("```")
if template_start == -1: if template_block_start != -1:
# Finde den Code-Block nach "## Template" oder "# Template"
template_section = content.find("## Template", content.find("Template"))
if template_section == -1:
template_section = content.find("# Template", content.find("Template"))
if template_section > template_block_start:
template_block_start = template_section
if template_block_start == -1:
errors.append("❌ Kein Code-Block für Template gefunden") errors.append("❌ Kein Code-Block für Template gefunden")
else: else:
# Prüfe ob Variablen im Template sind # Prüfe ob Variablen im Template sind
template_content = content[template_start:] template_content = content[template_block_start:]
if "{" not in template_content or "}" not in template_content: if "{" not in template_content or "}" not in template_content:
errors.append("⚠️ Warnung: Keine Variablen (z.B. {var}) im Template gefunden") errors.append(
"⚠️ Warnung: Keine Variablen (z.B. {var}) im Template gefunden"
return len(errors) == 0, errors )
return len([e for e in errors if e.startswith("")]) == 0, errors
def validate_template(filepath: Path) -> Tuple[bool, List[str]]: def validate_template(filepath: Path) -> Tuple[bool, List[str]]:
@ -176,10 +204,17 @@ def validate_template(filepath: Path) -> Tuple[bool, List[str]]:
return False, [f"❌ Unsupported file type: {filepath.suffix}"] return False, [f"❌ Unsupported file type: {filepath.suffix}"]
ALLOWED_DIRS = {"system", "user", "custom", "categories"}
def find_templates(directory: Path) -> List[Path]: def find_templates(directory: Path) -> List[Path]:
"""Findet alle Template-Dateien in einem Verzeichnis Baum.""" """Findet alle Template-Dateien in einem Verzeichnis Baum."""
templates = [] templates = []
for root, _, files in os.walk(directory): for root, dirs, files in os.walk(directory):
rel = os.path.relpath(root, directory)
if rel != "." and rel.split(os.sep)[0] not in ALLOWED_DIRS:
dirs.clear() # Skip nicht-erlaubte Subdirs
continue
for file in files: for file in files:
if file.endswith((".json", ".md")): if file.endswith((".json", ".md")):
templates.append(Path(root) / file) templates.append(Path(root) / file)
@ -194,36 +229,39 @@ def main():
Beispiele: Beispiele:
python validate.py templates/system/code_reviewer.json python validate.py templates/system/code_reviewer.json
python validate.py --all python validate.py --all
python validate.py --json templates/ python validate.py --type-json
""" """,
) )
parser.add_argument("path", nargs="?", help="Pfad zum Template oder Verzeichnis") parser.add_argument("path", nargs="?", help="Pfad zum Template oder Verzeichnis")
parser.add_argument("--all", action="store_true", help="Alle Templates validieren") parser.add_argument("--all", action="store_true", help="Alle Templates validieren")
parser.add_argument("--json", action="store_true", help="Nur JSON-Templates validieren") type_group = parser.add_mutually_exclusive_group()
parser.add_argument("--md", action="store_true", help="Nur Markdown-Templates validieren") type_group.add_argument(
"--type-json", action="store_true", help="Nur JSON-Templates validieren"
)
type_group.add_argument(
"--type-md", action="store_true", help="Nur Markdown-Templates validieren"
)
args = parser.parse_args() args = parser.parse_args()
base_dir = Path(__file__).parent.parent base_dir = Path(__file__).parent.parent
if args.all: if args.all:
# Alle Templates finden # Alle Templates finden
templates = find_templates(base_dir / "templates") templates = find_templates(base_dir / "templates")
if not args.json:
templates += find_templates(base_dir / "categories") if args.type_json:
if args.json:
templates = [t for t in templates if t.suffix == ".json"] templates = [t for t in templates if t.suffix == ".json"]
if args.md: if args.type_md:
templates = [t for t in templates if t.suffix == ".md"] templates = [t for t in templates if t.suffix == ".md"]
elif args.path: elif args.path:
path = Path(args.path) path = Path(args.path)
if path.is_dir(): if path.is_dir():
templates = find_templates(path) templates = find_templates(path)
if args.json: if args.type_json:
templates = [t for t in templates if t.suffix == ".json"] templates = [t for t in templates if t.suffix == ".json"]
if args.md: if args.type_md:
templates = [t for t in templates if t.suffix == ".md"] templates = [t for t in templates if t.suffix == ".md"]
else: else:
templates = [path] templates = [path]
@ -231,19 +269,19 @@ Beispiele:
print("❌ Bitte Pfad angeben oder --all verwenden") print("❌ Bitte Pfad angeben oder --all verwenden")
print("Beispiel: python validate.py --all") print("Beispiel: python validate.py --all")
sys.exit(1) sys.exit(1)
if not templates: if not templates:
print("❌ Keine Templates gefunden") print("❌ Keine Templates gefunden")
sys.exit(1) sys.exit(1)
# Validierung # Validierung
total = len(templates) total = len(templates)
valid = 0 valid = 0
invalid = 0 invalid = 0
print(f"\n{'='*60}") print(f"\n{'=' * 60}")
print(f"Validiere {total} Template(s)...\n") print(f"Validiere {total} Template(s)...\n")
for template_path in sorted(templates): for template_path in sorted(templates):
is_valid, errors = validate_template(template_path) is_valid, errors = validate_template(template_path)
# Make path relative to base_dir, handling both absolute and relative paths # Make path relative to base_dir, handling both absolute and relative paths
@ -252,7 +290,7 @@ Beispiele:
except ValueError: except ValueError:
# If template_path is not under base_dir, use absolute path # If template_path is not under base_dir, use absolute path
rel_path = str(template_path) rel_path = str(template_path)
if is_valid: if is_valid:
print(f"{rel_path}") print(f"{rel_path}")
valid += 1 valid += 1
@ -261,11 +299,13 @@ Beispiele:
for error in errors: for error in errors:
print(f" {error}") print(f" {error}")
invalid += 1 invalid += 1
print(f"\n{'='*60}") print(f"\n{'=' * 60}")
print(f"Ergebnis: {valid} ✅ | {invalid} ❌ | {total} Total") print(f"Ergebnis: {valid} ✅ | {invalid} ❌ | {total} Total")
sys.exit(0 if invalid == 0 else 1) if invalid > 0:
sys.exit(2) # Validierungsfehler
sys.exit(0)
if __name__ == "__main__": if __name__ == "__main__":

33
web/content_types.py Normal file
View file

@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""
Content-Type (MIME-Type) Mapping für Template-Dateien.
Bestimmt den richtigen Content-Type basierend auf der Dateierweiterung.
"""
import os
# Mapping von Dateierweiterungen zu Content-Types
EXTENSION_MAP = {
".json": "application/json",
".md": "text/plain",
".html": "text/html",
".css": "text/css",
".js": "application/javascript",
".txt": "text/plain",
}
def get_content_type(filepath: str) -> str:
"""
Bestimmt den Content-Type basierend auf der Dateierweiterung.
Args:
filepath: Pfad zur Datei
Returns:
Content-Type String, default "text/plain"
"""
ext = os.path.splitext(filepath)[1].lower()
return EXTENSION_MAP.get(ext, "text/plain")

420
web/css/styles.css Normal file
View file

@ -0,0 +1,420 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: var(--bg-primary);
color: var(--text-primary);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: none;
justify-content: center;
align-items: center;
z-index: 1000;
padding: 20px;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
max-width: 900px;
width: 100%;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-header h2 {
font-size: 16px;
font-weight: 600;
}
/* WCAG-konforme Eingabefelder für Edit-Modal (4.5:1 Kontrastminimum) */
#edit-content-content input,
#edit-content-content textarea,
#edit-modal input,
#edit-modal textarea {
background: #222222;
color: #ffffff; /* Weiß auf Dunkelgrau (#222222) */
border: 1px solid #cccccc;
border-radius: 4px;
padding: 8px;
font-family: var(--mono);
font-size: 13px;
line-height: 1.4;
}
#edit-content-content input:focus,
#edit-content-content textarea:focus,
#edit-modal input:focus,
#edit-modal textarea:focus {
outline: 2px solid #2563eb;
outline-offset: 1px;
border-color: #93c5fd;
}
#edit-content-content input:hover,
#edit-content-content textarea:hover,
#edit-modal input:hover,
#edit-modal textarea:hover {
border-color: #9ca3af;
background: #2d2d2d; /* Dark theme beibehalten */
}
.modal-body {
padding: 20px;
overflow-y: auto;
}
.modal-close {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 24px;
padding: 4px 8px;
border-radius: 4px;
}
.modal-close:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.modal-actions {
padding: 16px 20px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 8px;
}
.header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 16px 24px;
display: flex;
align-items: center;
gap: 16px;
}
.header h1 {
font-size: 18px;
font-weight: 600;
}
/* Edit Modal spezifische Eingabefelder - auch Checkboxen mit Kontrast */
#edit-content-content input[type="checkbox"],
#edit-modal input[type="checkbox"] {
width: auto;
margin-right: 8px;
accent-color: #2563eb;
}
#edit-content-content input[type="checkbox"] + label,
#edit-modal input[type="checkbox"] + label {
color: #e0e0e0;
font-weight: normal;
}
.header .badge {
background: var(--accent-light);
color: var(--accent);
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.header .actions {
margin-left: auto;
display: flex;
gap: 8px;
}
.btn {
background: var(--bg-input);
color: var(--text-primary);
border: 1px solid var(--border);
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn:hover {
background: var(--bg-hover);
border-color: var(--border-light);
}
.btn-primary {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.btn-primary:hover {
background: var(--accent-hover);
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
.nav {
display: flex;
gap: 8px;
margin-bottom: 24px;
border-bottom: 1px solid var(--border);
padding-bottom: 16px;
}
.nav a {
color: var(--text-secondary);
padding: 8px 16px;
border-radius: 4px;
font-size: 13px;
text-decoration: none;
}
.nav a:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.nav a.active {
color: var(--accent);
background: var(--accent-light);
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header h2 {
font-size: 15px;
font-weight: 600;
}
.card-body {
padding: 20px;
}
.template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.template-item {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
transition: border-color 0.15s;
}
.template-item:hover {
border-color: var(--border-light);
}
.template-item h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
color: var(--accent-hover);
}
.template-item .meta {
display: flex;
gap: 12px;
margin-bottom: 12px;
font-size: 12px;
color: var(--text-secondary);
}
.template-item .meta span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.template-item p {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 12px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.template-item .tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.template-item .tag {
background: var(--bg-input);
color: #b0b0b0;
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
}
.template-item .actions {
display: flex;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.template-item .btn-icon {
padding: 6px 12px;
font-size: 12px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
}
.empty-state h3 {
font-size: 16px;
font-weight: 500;
margin-bottom: 8px;
color: var(--text-secondary);
}
.code-block {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 6px;
padding: 16px;
font-family: var(--mono);
font-size: 12px;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
margin-top: 12px;
color: var(--text-primary);
max-height: 400px;
overflow-y: auto;
}
/* Filter bar */
.filter-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.filter-bar input {
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
flex: 1;
min-width: 200px;
}
.filter-bar input:focus {
outline: none;
border-color: var(--accent);
}
.toast {
position: fixed;
bottom: 20px;
right: 20px;
background: var(--bg-card);
border: 1px solid var(--border);
padding: 12px 16px;
border-radius: 6px;
font-size: 13px;
display: none;
z-index: 1001;
}
.toast.show {
display: block;
animation: fadeIn 0.2s;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Responsive */
@media (max-width: 768px) {
.container {
padding: 16px;
}
.template-grid {
grid-template-columns: 1fr;
}
.header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.nav {
overflow-x: auto;
padding-bottom: 8px;
}
.modal {
margin: 10px;
}
}

22
web/css/variables.css Normal file
View file

@ -0,0 +1,22 @@
/* CSS-Variablen für das Prompt Templates Design-System */
:root {
--bg-primary: #0f0f0f;
--bg-secondary: #1a1a1a;
--bg-card: #1e1e1e;
--bg-input: #262626;
--bg-hover: #2a2a2a;
--border: #2e2e2e;
--border-light: #3d3d3d;
--text-primary: #dbdbdb;
--text-secondary: #8b8b8b;
--text-muted: #5e5e5e;
--accent: #e24329;
--accent-hover: #fc6d26;
--accent-light: rgba(226, 67, 41, 0.12);
--green: #2da160;
--red: #dd3e31;
--yellow: #e0a118;
--gray: #737373;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, sans-serif;
--mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}

81
web/file_ops.py Normal file
View file

@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""
Datei-Ein-/Ausgabe-Operationen für Template-Dateien.
Stellt sichere Lese- und Schreiboperationen mit Encoding-Handling bereit.
"""
import os
from pathlib import Path
from typing import Optional
def read_file(filepath: str) -> Optional[str]:
"""
Liest eine Datei und gibt den Inhalt als String zurück.
Args:
filepath: Absoluter Pfad zur Datei
Returns:
Dateiinhalt als UTF-8 String, oder None bei Fehler
"""
try:
with open(filepath, "r", encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
return None
except Exception:
return None
def read_file_binary(filepath: str) -> Optional[bytes]:
"""
Liest eine Datei im Binärmodus.
Args:
filepath: Absoluter Pfad zur Datei
Returns:
Dateiinhalt als Bytes, oder None bei Fehler
"""
try:
with open(filepath, "rb") as f:
return f.read()
except FileNotFoundError:
return None
except Exception:
return None
def write_file(filepath: str, content: bytes) -> bool:
"""
Schreibt Bytes in eine Datei. Erstellt parent-Verzeichnis falls nötig.
Args:
filepath: Absoluter Pfad zur Zieldatei
content: Zu schreibende Bytes
Returns:
True bei Erfolg, False bei Fehler
"""
try:
file_dir = os.path.dirname(filepath)
if not os.path.exists(file_dir) or not os.path.isdir(file_dir):
return False
with open(filepath, "wb") as f:
f.write(content)
return True
except Exception:
return False
def file_exists(filepath: str) -> bool:
"""Prüft, ob eine Datei existiert."""
return os.path.isfile(filepath)
def directory_exists(dirpath: str) -> bool:
"""Prüft, ob ein Verzeichnis existiert."""
return os.path.isdir(dirpath)

107
web/handler.py Normal file
View file

@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""
HTTP-Handler für den Template-Server.
Behandelt GET- und PUT-Anfragen für Templates und das Frontend.
"""
import http.server
import logging
from pathlib import Path
from urllib.parse import urlparse
# Support für direkte Ausführung und Package-Import
try:
from .path_validator import PathValidator
from .file_ops import read_file_binary, write_file, directory_exists
from .content_types import get_content_type
except ImportError:
from path_validator import PathValidator
from file_ops import read_file_binary, write_file, directory_exists
from content_types import get_content_type
logger = logging.getLogger(__name__)
MAX_BODY_SIZE = 10 * 1024 * 1024 # 10 MB
class Handler(http.server.SimpleHTTPRequestHandler):
"""HTTP-Handler für Template-Anfragen."""
validator = None # Wird von serve.py gesetzt
directory = None # Wird von serve.py gesetzt
def __init__(self, *args, directory=None, **kwargs):
super().__init__(*args, directory=directory or self.directory, **kwargs)
def do_PUT(self):
"""Speichert eine Template-Datei."""
file_path = self.validator.resolve_template_path(urlparse(self.path).path)
if file_path is None:
self.send_error(403, "Forbidden: Invalid path")
return
# Verzeichnis prüfen - muss existieren
file_dir = Path(file_path).parent
if not directory_exists(str(file_dir)):
self.send_error(404, "Directory not found")
return
# Content-Type prüfen
content_type = self.headers.get("Content-Type", "")
if "text/plain" not in content_type and not content_type.startswith("text/"):
self.send_error(400, "Unsupported content type")
return
# Inhalt lesen und speichern
content_length = int(self.headers.get("Content-Length", 0))
if content_length > MAX_BODY_SIZE:
self.send_error(413, "Request body too large")
return
if content_length <= 0:
self.send_error(400, "No content provided")
return
try:
file_content = self.rfile.read(content_length)
if not write_file(file_path, file_content):
raise Exception("write_file returned False")
response_content_type = get_content_type(file_path)
self.send_response(200)
self.send_header("Content-type", response_content_type)
self.end_headers()
self.wfile.write(b"File saved successfully")
except Exception as e:
logger.error("Failed to save file: %s", e)
self.send_error(500, f"Failed to save file: {e}")
def do_GET(self):
"""Liefert Dateien aus."""
parsed_path = urlparse(self.path).path
# Für Root-Pfad: index.html servieren
if parsed_path == "/" or parsed_path == "/index.html":
self.path = "/index.html"
return super().do_GET()
# Anfragen für /templates.json oder /templates/* umleiten
file_path = self.validator.resolve_template_path(urlparse(self.path).path)
if file_path is not None:
content_type = get_content_type(file_path)
file_content = read_file_binary(file_path)
if file_content is None:
self.send_error(404, "File not found")
return
self.send_response(200)
self.send_header("Content-type", content_type)
self.end_headers()
self.wfile.write(file_content)
return
return super().do_GET()

File diff suppressed because it is too large Load diff

186
web/js/api.js Normal file
View file

@ -0,0 +1,186 @@
/**
* API-Client für HTTP-Kommunikation mit dem Backend.
*/
/**
* Template-Inhalt laden und anzeigen
* @param {string} path - Pfad zur Template-Datei
*/
async function viewTemplate(path) {
try {
const response = await fetch(path);
if (!response.ok) throw new Error('Datei nicht gefunden');
let content = await response.text();
// Für JSON: formatiert anzeigen
if (path.endsWith('.json')) {
try {
const data = JSON.parse(content);
content = JSON.stringify(data, null, 2);
} catch (e) {
// Nicht valides JSON, als Raw Text anzeigen
}
}
showModal(path.split('/').pop(), content);
} catch (e) {
showModal(path, `Fehler beim Laden: ${e.message}`);
}
}
/**
* Template-Inhalt kopieren
* @param {string} path - Pfad zur Template-Datei
*/
async function copyContent(path) {
try {
const response = await fetch(path);
if (!response.ok) throw new Error('Datei nicht gefunden');
let content = await response.text();
// Für JSON: originalen Inhalt (nicht formatiert) kopieren
if (path.endsWith('.json')) {
try {
JSON.parse(content);
// Valides JSON - originalen Inhalt behalten
} catch (e) {
// Nicht valides JSON
}
}
await copyContentToClipboard(content);
} catch (e) {
showToast(`✗ Fehler: ${e.message}`);
}
}
/**
* Template-Daten vom Server laden
* @returns {Promise<Array>} Template-Liste
*/
async function loadTemplates() {
const response = await fetch('/templates.json');
if (!response.ok) {
return await scanTemplates();
}
return response.json();
}
/**
* Templates manuell scannen (Fallback wenn templates.json fehlt)
* @returns {Promise<Array>} Template-Liste
*/
async function scanTemplates() {
const templates = [];
const systemFiles = ['commit_analysis', 'code_reviewer', 'summarizer'];
for (const file of systemFiles) {
try {
const response = await fetch(`/templates/system/${file}.json`);
if (response.ok) {
const data = await response.json();
templates.push({
path: `/templates/system/${file}.json`,
type: 'system',
name: data.name || file,
description: data.description || '',
version: data.version || '1.0',
tags: data.tags || [],
format: 'json'
});
}
} catch (e) {}
}
const userFiles = ['email_draft', 'brainstorming'];
for (const file of userFiles) {
try {
const response = await fetch(`/templates/user/${file}.md`);
if (response.ok) {
const text = await response.text();
const name = text.split('\n')[0].replace('# ', '');
templates.push({
path: `/templates/user/${file}.md`,
type: 'user',
name: name,
description: '',
version: '1.0',
tags: [],
format: 'md'
});
}
} catch (e) {}
}
return templates;
}
/**
* Bearbeiteten Inhalt speichern
*/
async function saveEditedContent() {
if (!window.currentEditTemplate) return;
try {
if (window.currentEditTemplate.endsWith('.json')) {
const editContainer = document.getElementById('edit-content-content');
const firstChild = editContainer.children[0];
const formDiv = (firstChild && firstChild.nodeType === Node.ELEMENT_NODE && firstChild.tagName === 'DIV') ? firstChild : null;
if (!formDiv) {
showToast('✗ Keine Eingabefelder gefunden');
return;
}
const updatedData = extractJsonFromForm(formDiv);
const finalJsonString = JSON.stringify(updatedData, null, window.currentIndent);
// Valid JSON prüfen
try {
JSON.parse(finalJsonString);
} catch (e) {
showToast('✗ Ungültiges JSON. Bitte korrigiere den Inhalt.');
return;
}
const response = await fetch(window.currentEditTemplate, {
method: 'PUT',
headers: {'Content-Type': 'text/plain'},
body: finalJsonString
});
if (response.ok) {
showToast('✓ Änderungen gespeichert');
closeEditModal();
if (wasViewModalOpen()) {
closeModal();
}
await viewTemplate(window.currentEditTemplate);
} else {
throw new Error(`HTTP ${response.status}`);
}
} else {
const textarea = document.getElementById('edit-textarea');
const content = textarea.value;
const response = await fetch(window.currentEditTemplate, {
method: 'PUT',
headers: {'Content-Type': 'text/plain'},
body: content
});
if (response.ok) {
showToast('✓ Änderungen gespeichert');
closeEditModal();
if (wasViewModalOpen()) {
closeModal();
}
await viewTemplate(window.currentEditTemplate);
} else {
throw new Error(`HTTP ${response.status}`);
}
}
} catch (e) {
showToast(`✗ Fehler beim Speichern: ${e.message}`);
}
}

300
web/js/editor.js Normal file
View file

@ -0,0 +1,300 @@
/**
* JSON-Editor für die Template-Bearbeitung.
*
* Generiert ein Formular aus JSON-Daten und extrahiert
* die Werte zurück in ein JSON-Objekt.
*/
let editContainerRef = null;
let currentIndent = 2;
/**
* Öffne Edit-Modal für eine Template-Datei
* @param {string} path - Pfad zur Template-Datei
*/
function editModalContent(path) {
window.currentEditTemplate = path;
const title = path.split('/').pop();
document.getElementById('edit-title').textContent = `Template bearbeiten: ${title}`;
window._wasViewModalOpen = document.getElementById('modal').classList.contains('active');
// Inhalt laden und editierbare Formulare abhängig vom Dateityp erstellen
fetch(path)
.then(r => r.text())
.then(content => {
const editContainer = document.getElementById('edit-content-content');
editContainer.innerHTML = '';
if (path.endsWith('.json')) {
try {
const jsonData = JSON.parse(content);
// Original-Indent erkennen
const indentMatch = content.match(/^\s{2,8}/m);
currentIndent = indentMatch ? indentMatch[0].length : 2;
createJsonEditUI(editContainer, jsonData, path);
} catch (e) {
// Falls JSON ungültig, als Text bearbeiten
createTextEditUI(editContainer, content);
}
} else {
// Markdown als einfaches Textfeld
createTextEditUI(editContainer, content);
}
document.getElementById('edit-modal').classList.add('active');
})
.catch(e => showToast(`✗ Fehler beim Laden: ${e.message}`));
}
/**
* Erstelle ein Textarea für die Bearbeitung
* @param {HTMLElement} container - Ziel-Container
* @param {string} content - Dateiinhalt
*/
function createTextEditUI(container, content) {
const textarea = document.createElement('textarea');
textarea.id = 'edit-textarea';
textarea.value = content;
textarea.style.cssText = 'width: 100%; min-height: 300px; background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border); border-radius: 4px; padding: 10px; font-family: var(--mono); font-size: 13px;';
textarea.spellcheck = false;
container.appendChild(textarea);
}
/**
* Erstelle ein Formular aus JSON-Daten
* @param {HTMLElement} container - Ziel-Container
* @param {Object} jsonData - Zu bearbeitende JSON-Daten
* @param {string} editPathHint - Pfad-Hinweis
*/
function createJsonEditUI(container, jsonData, editPathHint = '') {
editContainerRef = container;
container.innerHTML = '';
const formDiv = document.createElement('div');
formDiv.style.cssText = 'display: flex; flex-direction: column; gap: 16px; padding: 8px; background: var(--bg-card); border-radius: 4px; margin: 0; min-height: 300px; overflow-y: auto;';
// Rekursive Funktion zum Erstellen von Eingabefeldern für alle Properties
function buildJsonForm(data, prefix = '', level = 0, targetElement = null) {
if (typeof data !== 'object' || data === null) return;
const container = targetElement || document.getElementById('edit-content-content');
if (!container) return;
Object.keys(data).forEach(key => {
const fullKey = prefix ? `${prefix}.${key}` : key;
const value = data[key];
const isObject = typeof value === 'object' && value !== null && !Array.isArray(value);
const isArray = Array.isArray(value);
// Für Objekte: Rekursiv alle inneren Properties anzeigen
if (isObject) {
const fieldContainer = document.createElement('div');
fieldContainer.style.cssText = 'background: #1a1a1a; padding: 12px; border-radius: 4px; border-left: 3px solid #4CAF50;';
const label = document.createElement('label');
label.htmlFor = `edit-${fullKey.replace(/[.\s]/g, '-')}`;
label.textContent = fullKey + ' (Objekt)';
label.style.cssText = 'font-weight: 600; color: #4CAF50; margin-bottom: 8px; display: block; font-size: 14px;';
fieldContainer.appendChild(label);
// Rekursiv innere Properties in den Container einfügen
const innerContainer = document.createElement('div');
innerContainer.style.cssText = 'padding-left: 12px; margin-top: 4px;';
buildJsonForm(value, fullKey, level + 1, innerContainer);
fieldContainer.appendChild(innerContainer);
container.appendChild(fieldContainer);
return;
}
// Für Arrays: Rekursiv jedes Element als eigenes Feld anzeigen
if (isArray) {
const arrayContainer = document.createElement('div');
arrayContainer.style.cssText = 'background: #1a1a1a; padding: 12px; border-radius: 4px; border-left: 3px solid #2196F3;';
const arrayLabel = document.createElement('label');
arrayLabel.htmlFor = `edit-${fullKey.replace(/[.\s]/g, '-')}`;
arrayLabel.textContent = fullKey + ' (Array) - ' + value.length + ' Elemente';
arrayLabel.style.cssText = 'font-weight: 600; color: #2196F3; margin-bottom: 8px; display: block; font-size: 14px;';
arrayContainer.appendChild(arrayLabel);
// Für jedes Array-Element ein separates Eingabefeld
const arrayItemsContainer = document.createElement('div');
arrayItemsContainer.style.cssText = 'margin-top: 4px; padding-left: 12px;';
value.forEach((item, index) => {
const itemKey = `${fullKey}[${index}]`;
const itemContainer = document.createElement('div');
itemContainer.style.cssText = 'background: #2a2a2a; padding: 8px; margin: 4px 0; border-radius: 3px; border-left: 2px solid #FF9800;';
const itemLabel = document.createElement('label');
itemLabel.htmlFor = `edit-${itemKey.replace(/[.\s]/g, '-')}`;
itemLabel.textContent = `Element [${index}]`;
itemLabel.style.cssText = 'font-weight: 500; color: #FF9800; margin-bottom: 4px; display: block; font-size: 13px;';
itemContainer.appendChild(itemLabel);
// Prüfen, ob das Array-Element selbst ein Objekt ist
if (typeof item === 'object' && item !== null) {
const innerObjContainer = document.createElement('div');
innerObjContainer.style.cssText = 'padding-left: 12px; margin-top: 4px;';
buildJsonForm(item, itemKey, level + 1, innerObjContainer);
itemContainer.appendChild(innerObjContainer);
} else {
const itemFieldContainer = document.createElement('div');
itemFieldContainer.style.cssText = 'background: var(--bg-input); padding: 8px; border-radius: 3px; margin-bottom: 4px;';
const input = document.createElement('input');
const type = typeof item === 'boolean' ? 'checkbox' : typeof item === 'number' ? 'number' : 'text';
input.type = type;
input.value = item !== null && item !== undefined ? item : '';
input.dataset.key = itemKey;
input.dataset.type = typeof item;
input.dataset.arrayIndex = index;
if (type === 'checkbox') input.checked = item;
else input.value = String(item !== null && item !== undefined ? item : '');
input.style.cssText = 'width: 100%; padding: 8px; background: #222222; color: #ffffff; border: 1px solid #555; border-radius: 3px; font-family: var(--mono); font-size: 13px;';
itemFieldContainer.appendChild(input);
itemContainer.appendChild(itemFieldContainer);
}
arrayItemsContainer.appendChild(itemContainer);
});
arrayContainer.appendChild(arrayItemsContainer);
container.appendChild(arrayContainer);
return;
}
// Für primitive Werte: Standard-Eingabefeld erstellen
const fieldContainer = document.createElement('div');
fieldContainer.style.cssText = 'background: var(--bg-input); padding: 8px; border-radius: 4px; border: 1px solid transparent;';
const label = document.createElement('label');
label.htmlFor = `edit-${fullKey.replace(/[.\s]/g, '-')}`;
label.textContent = fullKey;
label.style.cssText = 'font-weight: 600; color: var(--text-primary); display: block; margin-bottom: 4px; font-size: 14px; margin-top: 0;';
fieldContainer.appendChild(label);
// Das 'template'-Feld als Textarea rendern
const isTemplateField = (fullKey === 'template');
const type = typeof value === 'boolean' ? 'checkbox' : typeof value === 'number' ? 'number' : 'text';
if (isTemplateField) {
const textarea = document.createElement('textarea');
textarea.id = `edit-${fullKey.replace(/[.\s]/g, '-')}`;
textarea.value = value !== null && value !== undefined ? value : '';
textarea.dataset.key = fullKey;
textarea.dataset.type = typeof value;
textarea.style.cssText = 'width: 100%; min-height: 200px; padding: 8px; background: #222222; color: #ffffff; border: 1px solid #555; border-radius: 3px; font-family: var(--mono); font-size: 13px; resize: vertical;';
textarea.spellcheck = false;
fieldContainer.appendChild(textarea);
} else {
const input = document.createElement('input');
input.type = type;
input.value = value !== null && value !== undefined ? value : '';
input.dataset.key = fullKey;
input.dataset.type = typeof value;
if (type === 'checkbox') input.checked = value;
else input.value = String(value !== null && value !== undefined ? value : '');
input.style.cssText = 'width: 100%; padding: 8px; background: #222222; color: #ffffff; border: 1px solid #555; border-radius: 3px; font-family: var(--mono); font-size: 13px;';
fieldContainer.appendChild(input);
}
container.appendChild(fieldContainer);
});
}
buildJsonForm(jsonData, '', 0, formDiv);
container.appendChild(formDiv);
}
/**
* Extrahiere den Wert aus einem Input-Element
* @param {HTMLElement} input - Input-Element
* @returns {*} Extrahierter Wert
*/
function extractInputValue(input) {
if (input.type === 'checkbox') {
return input.checked;
}
const value = input.value;
try {
// Nur wenn der Input-Typ "text" ist und Wert JSON-ähnlich formatiert
if (input.type === 'text' && value.length > 0) {
if ((value.startsWith('{') && value.endsWith('}')) ||
(value.startsWith('[') && value.endsWith(']'))) {
try {
return JSON.parse(value);
} catch (parseErr) {
// Kein gültiges JSON, behalte String-Wert
}
}
}
if (input.dataset.type === 'number') {
return Number(value);
}
return value;
} catch (e) {
return value;
}
}
/**
* Extrahiere JSON aus dem Formular-Container
* @param {HTMLElement} formDiv - Formular-Container
* @returns {Object} Extrahiertes JSON-Objekt
*/
function extractJsonFromForm(formDiv) {
// Map: full key -> { value, type }
const inputEntries = [];
const inputs = formDiv.querySelectorAll('[data-key]');
inputs.forEach(input => {
const key = input.dataset.key || '';
inputEntries.push({ key, value: extractInputValue(input) });
});
// Sort by key to ensure stable parent-first construction
inputEntries.sort((a,b) => a.key.localeCompare(b.key));
const result = {};
inputEntries.forEach(({ key, value }) => {
const parts = key.split('.');
let cur = result;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const isLast = i === parts.length - 1;
const m = part.match(/^([^\[\]]+)\[(\d+)\]$/);
if (m && !isLast) {
const base = m[1];
const idx = Number(m[2]);
if (!cur[base]) cur[base] = [];
if (!cur[base][idx]) cur[base][idx] = {};
cur = cur[base][idx];
} else if (m && isLast) {
// Array leaf
const base = m[1];
const idx = Number(m[2]);
if (!cur[base]) cur[base] = [];
cur[base][idx] = value;
} else if (isLast) {
cur[part] = value;
} else {
if (!cur[part]) cur[part] = {};
cur = cur[part];
}
}
});
return result;
}
// Export for api.js (global scope, loaded before api.js)
// editModalContent, createTextEditUI, createJsonEditUI, extractInputValue, extractJsonFromForm
// sind als globale Funktionen verfügbar

52
web/js/main.js Normal file
View file

@ -0,0 +1,52 @@
/**
* Haupt-Initialisierung: Event-Listener, Hash-Filter, App-Start.
*/
let allTemplates = [];
// Search-Event
document.getElementById('search').addEventListener('input', (e) => {
currentQuery = e.target.value.toLowerCase();
applyFilters();
});
// Close modal on overlay click
document.getElementById('modal').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeModal();
});
// Escape key closes modal
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { closeEditModal(); closeModal(); }
});
// Hash -> type filter
window.addEventListener('hashchange', () => {
currentType = parseTypeFromHash();
applyFilters();
});
// Nav clicks set the hash; hashchange drives filtering
document.querySelectorAll('.nav a').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const target = link.getAttribute('href') || '#';
if (window.location.hash === target || (target === '#' && window.location.hash === '')) {
return; // same target, no hashchange would fire
}
window.location.hash = target;
});
});
// Initial load
loadTemplates().then(t => {
allTemplates = t;
window.allTemplates = t;
currentType = parseTypeFromHash();
applyFilters();
}).catch(e => {
console.error('Failed to load templates:', e);
allTemplates = [];
window.allTemplates = [];
applyFilters();
});

45
web/js/modal.js Normal file
View file

@ -0,0 +1,45 @@
/**
* Modal-Management für View und Edit Modals.
*/
/**
* Zeige View-Modal mit Titel und Inhalt
* @param {string} title - Titel des Modals
* @param {string} content - Inhalt als Text
*/
function showModal(title, content) {
document.getElementById('modal-title').textContent = title;
document.getElementById('modal-content').textContent = content;
document.getElementById('modal').classList.add('active');
}
/**
* Schließe View-Modal
*/
function closeModal() {
document.getElementById('modal').classList.remove('active');
}
/**
* Schließe Edit-Modal
*/
function closeEditModal() {
document.getElementById('edit-modal').classList.remove('active');
}
/**
* Prüfe ob View-Modal offen war (für Copy-After-View-Logic)
* @returns {boolean}
*/
function wasViewModalOpen() {
return window._wasViewModalOpen || false;
}
/**
* Merke dass View-Modal offen war
*/
function rememberViewModalOpen() {
window._wasViewModalOpen = true;
}
// exported functions are global (loaded as <script> tags)

94
web/js/templates.js Normal file
View file

@ -0,0 +1,94 @@
/**
* Template-Rendering und Filterung.
*
* Rendert die Template-Karte und wendet Filter (Typ, Suche) an.
*/
let currentEditTemplate = null;
/**
* Render Template-Karten in den Container
* @param {Array} templates - Zu rendernde Templates
*/
function renderTemplates(templates) {
const container = document.getElementById('templates');
const count = document.getElementById('count');
count.textContent = `${templates.length} Template(s)`;
if (templates.length === 0) {
container.innerHTML = '<div class="empty-state"><h3>Keine Templates gefunden</h3><p>Füge Templates in den templates/ Ordnern hinzu.</p></div>';
return;
}
container.innerHTML = templates.map(t => `
<div class="template-item">
<h3>${esc(t.name)}</h3>
<div class="meta">
<span>🏷 ${esc(t.type)}</span>
<span>📄 ${esc(t.format)}</span>
<span>📌 v${esc(t.version)}</span>
</div>
<p>${esc(t.description) || 'Keine Beschreibung'}</p>
<div class="tags">
${t.tags.map(tag => `<span class="tag">${esc(tag)}</span>`).join('')}
</div>
<div class="actions">
<button class="btn btn-icon" onclick="viewTemplate('${esc(t.path)}')">Anzeigen</button>
<button class="btn btn-icon" onclick="editModalContent('${esc(t.path)}')">📝 Bearbeiten</button>
<button class="btn btn-icon" onclick="copyContent('${esc(t.path)}')">Inhalt kopieren</button>
</div>
</div>
`).join('');
}
/**
* Filter-State: aktueller Typ-Filter
*/
let currentType = null;
let currentQuery = '';
/**
* Extrahiere Typ aus URL-Hash
* @returns {string|null} Typ-Filter oder null
*/
function parseTypeFromHash() {
const match = window.location.hash.match(/[?&]type=([^&]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
/**
* Setze Nav-Active-State basierend auf Typ
* @param {string|null} type - Typ-Filter
*/
function setNavActive(type) {
document.querySelectorAll('.nav a').forEach(a => {
const m = (a.getAttribute('href') || '').match(/type=([^&]+)/);
const aType = m ? m[1] : null;
a.classList.toggle('active', aType === type);
});
}
/**
* Wende Filter an und render Templates
*/
function applyFilters() {
let list = window.allTemplates || [];
if (currentType) {
list = list.filter(t => t.type === currentType);
}
if (currentQuery) {
const q = currentQuery;
list = list.filter(t =>
(t.name || '').toLowerCase().includes(q) ||
(t.description || '').toLowerCase().includes(q) ||
(t.tags || []).some(tag => tag.toLowerCase().includes(q))
);
}
setNavActive(currentType);
renderTemplates(list);
}
// Export for main.js (global scope, loaded before main.js)
// renderTemplates, applyFilters, parseTypeFromHash, setNavActive
// currentType, currentQuery sind als globale Variablen verfügbar

47
web/js/utils.js Normal file
View file

@ -0,0 +1,47 @@
/**
* Utility-Funktionen für das Prompt Templates Frontend.
*/
/**
* XSS-Schutz: Escaped HTML-Special-Chars
* @param {string} s - Zu escapender String
* @returns {string} Escaped String
*/
function esc(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
/**
* Zeige Toast-Nachricht für 3 Sekunden
* @param {string} message - Nachrichtentext
*/
function showToast(message) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 3000);
}
/**
* Inhalt in die Clipboard kopieren
* @param {string} content - Zu kopierender Inhalt
*/
async function copyContentToClipboard(content) {
try {
await navigator.clipboard.writeText(content);
showToast('✓ Inhalt kopiert');
} catch (e) {
// Fallback für ältere Browser
const textarea = document.createElement('textarea');
textarea.value = content;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showToast('✓ Inhalt kopiert');
}
}
// exported functions are global (loaded as <script> tags)

79
web/path_validator.py Normal file
View file

@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""
Pfad-Validierung für Template-Dateien.
Verhindert Path-Traversal-Angriffe und validiert, dass angeforderte
Pfade innerhalb des erlaubten Verzeichnisses liegen.
"""
import os
from typing import Optional
from urllib.parse import unquote, urlparse
class PathValidator:
"""Validiert und auflöst URL-Pfade zu Dateisystem-Pfaden."""
def __init__(self, root_dir: str, web_dir: str):
"""
Args:
root_dir: Projekt-Root-Verzeichnis (enthält templates/)
web_dir: web/-Verzeichnis (enthält templates.json)
"""
self.root_dir = root_dir
self.web_dir = web_dir
def resolve_template_path(self, path: str) -> Optional[str]:
"""
Resolve einen Request-Pfad zu einem Dateisystem-Pfad.
Gibt None zurück, falls der Pfad ungültig oder nicht autorisiert ist.
Beispiele:
/templates.json -> <web_dir>/templates.json
/templates/system/foo.json -> <root_dir>/templates/system/foo.json
"""
parsed = urlparse(path).path
path = parsed
# /templates.json ist ein Sonderfall - exakter Pfad im web/-Verzeichnis
if path == "/templates.json":
return os.path.join(self.web_dir, "templates.json")
# Nur /templates/* Pfade erlauben
if not path.startswith("/templates/"):
return None
# Extrahiere den relativen Pfad nach /templates/
rel_path = path[len("/templates/") :]
rel_path = unquote(rel_path)
# Ablehnen bei leeren Pfaden oder absoluten Pfaden
if not rel_path or os.path.isabs(rel_path):
return None
# Ablehnen bei '..' Sequenzen (vor der Normalisierung!)
if ".." in rel_path.split(os.sep):
return None
# Pfad normalisieren
normalized_rel = os.path.normpath(rel_path)
# Nach Normalisierung nochmal prüfen
if normalized_rel.startswith("..") or os.path.isabs(normalized_rel):
return None
# Vollständigen Pfad konstruieren
full_path = os.path.join(self.root_dir, "templates", normalized_rel)
# Explizite Prüfung, dass der Pfad innerhalb von ROOT_DIR/templates/ liegt
templates_base = os.path.abspath(os.path.join(self.root_dir, "templates"))
full_path_abs = os.path.abspath(full_path)
if (
not full_path_abs.startswith(templates_base + os.sep)
and full_path_abs != templates_base
):
return None
return full_path_abs

View file

@ -1,124 +1,65 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Minimaler Entwicklungs-Server für die Prompt Templates Webansicht. Minimaler Entwicklungs-Server für die Prompt Templates Webansicht.
Startet auf Port 8080 und dient die statischen Dateien aus.
Startet auf Port 8081 und dient die statischen Dateien aus.
Nutzt modulare Handler und Pfad-Validierung.
""" """
import http.server import logging
import socketserver
import os import os
import socket import socketserver
import json
# Support für direkte Ausführung (python3 web/serve.py) und Package-Import
try:
from .path_validator import PathValidator
from .handler import Handler
except ImportError:
from path_validator import PathValidator
from handler import Handler
# Verzeichnisse bestimmen
DIRECTORY = os.path.dirname(os.path.abspath(__file__)) DIRECTORY = os.path.dirname(os.path.abspath(__file__))
ROOT_DIR = os.path.abspath(os.path.join(DIRECTORY, '..')) ROOT_DIR = os.path.abspath(os.path.join(DIRECTORY, ".."))
# Validator initialisieren
validator = PathValidator(ROOT_DIR, DIRECTORY)
Handler.validator = validator
Handler.directory = DIRECTORY
def find_free_port(start_port=9000): def find_free_port(start_port=9000):
"""Finde einen freien Port ab start_port""" """Finde einen freien Port ab start_port.
Für zukünftige Smoke-Test-Integration.
"""
import socket
port = start_port port = start_port
while port < 10000: while port < 10000:
try: try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('', port)) s.bind(("", port))
return port return port
except OSError: except OSError:
port += 1 port += 1
return None return None
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=DIRECTORY, **kwargs)
def do_PUT(self):
# Nur PUT auf /templates/* Pfade erlauben
if not self.path.startswith('/templates'):
self.send_error(403, "Method not allowed for this path")
return
# /templates.json liegt im web/-Verzeichnis (Katalog), alles andere unter ROOT
if self.path == '/templates.json':
file_path = os.path.join(DIRECTORY, 'templates.json')
else:
rel_path = self.path[1:] # '/templates/system/test.json' → 'templates/system/test.json'
file_path = os.path.join(ROOT_DIR, rel_path)
# Verzeichnis prüfen - muss existieren
file_dir = os.path.dirname(file_path)
if not os.path.exists(file_dir) or not os.path.isdir(file_dir):
self.send_error(404, "Directory not found")
return
# Content-Type prüfen
content_type = self.headers.get('Content-Type', '')
if 'text/plain' not in content_type and not content_type.startswith('text/'):
self.send_error(400, "Unsupported content type")
return
# Inhalt lesen und speichern
content_length = int(self.headers.get('Content-Length', 0))
if content_length <= 0:
self.send_error(400, "No content provided")
return
try:
file_content = self.rfile.read(content_length)
# Datei schreiben
with open(file_path, 'wb') as f:
f.write(file_content)
self.send_response(200)
self.send_header('Content-type', 'text/plain')
self.end_headers()
self.wfile.write(b"File saved successfully")
except Exception as e:
self.send_error(500, f"Failed to save file: {e}")
def do_GET(self):
# Für Root-Pfad: index.html servieren
if self.path == '/' or self.path == '/index.html':
self.path = '/index.html'
return super().do_GET()
# Anfragen für /templates.json oder /templates/* umleiten
if self.path.startswith('/templates'):
# /templates.json ist der Katalog und liegt im web/-Verzeichnis,
# /templates/... verweist auf Template-Dateien im Projekt-Root
if self.path == '/templates.json':
file_path = os.path.join(DIRECTORY, 'templates.json')
else:
rel_path = self.path[1:] # '/templates/system/x.json' → 'templates/system/x.json'
file_path = os.path.join(ROOT_DIR, rel_path)
if os.path.exists(file_path) and not os.path.isdir(file_path):
try:
with open(file_path, 'rb') as f:
self.send_response(200)
self.send_header('Content-type', 'text/plain' if file_path.endswith('.md') else 'application/json')
self.end_headers()
self.wfile.write(f.read())
return
except Exception as e:
self.send_error(500, f"Error serving file: {e}")
return
else:
self.send_error(404, "File not found")
return
return super().do_GET()
def main(): def main():
PORT = 8081 PORT = 8081
print(f"Serving on http://localhost:{PORT}") logging.info("Serving on http://localhost:%s", PORT)
socketserver.TCPServer.allow_reuse_address = True
with socketserver.TCPServer(("", PORT), Handler) as httpd: with socketserver.TCPServer(("", PORT), Handler) as httpd:
print(f"Serving Prompt Templates on http://localhost:{PORT}") logging.info("Serving Prompt Templates on http://localhost:%s", PORT)
print(f"Press Ctrl+C to stop") logging.info("Press Ctrl+C to stop")
print(f"Directory: {DIRECTORY}") logging.info("Directory: %s", DIRECTORY)
try: try:
httpd.serve_forever() httpd.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nServer stopped") logging.info("\nServer stopped")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View file

@ -1,24 +1,24 @@
[ [
{ {
"path": "../templates/custom/brainstorming.md", "path": "templates/custom/brainstorming.md",
"type": "custom", "type": "custom",
"name": "Brainstorming Assistent", "name": "Brainstorming Assistent",
"description": "", "description": "Dient zur Ideengenerierung und Kreativitätsförderung",
"version": "1.0", "version": "1.0",
"tags": [], "tags": [],
"format": "md" "format": "md"
}, },
{ {
"path": "../templates/user/email_draft.md", "path": "templates/user/email_draft.md",
"type": "user", "type": "user",
"name": "Email Entwurf Assistent", "name": "Email Entwurf Assistent",
"description": "", "description": "Entwirft professionelle E-Mail-Entwürfe mit konfigurierbarem Tonfall",
"version": "1.0", "version": "1.0",
"tags": [], "tags": [],
"format": "md" "format": "md"
}, },
{ {
"path": "../templates/system/code_reviewer.json", "path": "templates/system/code_reviewer.json",
"type": "system", "type": "system",
"name": "Code Reviewer", "name": "Code Reviewer",
"description": "Analysiert Code auf Qualität, Best Practices und potenzielle Bugs", "description": "Analysiert Code auf Qualität, Best Practices und potenzielle Bugs",
@ -33,7 +33,7 @@
"format": "json" "format": "json"
}, },
{ {
"path": "../templates/system/commit_analysis.json", "path": "templates/system/commit_analysis.json",
"type": "system", "type": "system",
"name": "Git Commit Deep Analysis", "name": "Git Commit Deep Analysis",
"description": "Erstellt eine tiefe Analyse der letzten Git-Commits mit technischer und fachlicher Bewertung", "description": "Erstellt eine tiefe Analyse der letzten Git-Commits mit technischer und fachlicher Bewertung",
@ -49,7 +49,7 @@
"format": "json" "format": "json"
}, },
{ {
"path": "../templates/system/summarizer.json", "path": "templates/system/summarizer.json",
"type": "system", "type": "system",
"name": "Text Summarizer", "name": "Text Summarizer",
"description": "Erstellt präzise Zusammenfassungen von Texten mit konfigurierbarer Länge", "description": "Erstellt präzise Zusammenfassungen von Texten mit konfigurierbarer Länge",