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
This commit is contained in:
Michael 2026-05-03 14:40:44 +02:00
parent 070ebc3edd
commit 9890763f0f
15 changed files with 1490 additions and 1165 deletions

View file

@ -46,6 +46,7 @@ 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()

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}`);
}
}

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

@ -0,0 +1,286 @@
/**
* 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);
const type = typeof value === 'boolean' ? 'checkbox' : typeof value === 'number' ? 'number' : 'text';
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();
});

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

@ -0,0 +1,51 @@
/**
* 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;
}
export {
showModal,
closeModal,
closeEditModal,
wasViewModalOpen,
rememberViewModalOpen,
};

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

@ -0,0 +1,96 @@
/**
* Template-Rendering und Filterung.
*
* Rendert die Template-Karte und wendet Filter (Typ, Suche) an.
*/
let currentEditTemplate = null;
let editContainerRef = null;
let currentIndent = 2;
/**
* 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');
}
}
export { esc, showToast, copyContentToClipboard };

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,28 +1,40 @@
#!/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 8081 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 socketserver
import socket
import os
import json
import logging import logging
from urllib.parse import urlparse, unquote import os
import socketserver
logging.basicConfig(level=logging.INFO) # 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. Für zukünftige Smoke-Test-Integration.
""" """
import socket
port = start_port port = start_port
while port < 10000: while port < 10000:
try: try:
@ -34,141 +46,6 @@ def find_free_port(start_port=9000):
return None return None
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=DIRECTORY, **kwargs)
def _validate_template_path(self, path):
"""Validiert, dass der Pfad auf Schritte Beschränkt ist und innerhalb von ROOT_DIR/templates/ oder DIRECTORY liegt."""
parsed = urlparse(path).path
path = parsed
# /templates.json ist ein Sonderfall - exakter Pfad im web/-Verzeichnis
if path == "/templates.json":
return os.path.join(DIRECTORY, "templates.json")
# Nur /templates/* Pfade erlauben
if not path.startswith("/templates/"):
return None
# Extrahiere den relativen Pfad nach /templates/
rel_path = path[len("/templates/") :] # 'system/test.json' oder '../etc/passwd'
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(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(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
def do_PUT(self):
# Nur PUT auf /templates/* Pfade erlauben
file_path = self._validate_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 = 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
MAX_BODY = 10 * 1024 * 1024
content_length = int(self.headers.get("Content-Length", 0))
if content_length > MAX_BODY:
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)
# Datei schreiben
with open(file_path, "wb") as f:
f.write(file_content)
# Response Content-Type je nach Dateityp setzen
if file_path.endswith(".json"):
response_content_type = "application/json"
else:
response_content_type = "text/plain"
self.send_response(200)
self.send_header("Content-type", response_content_type)
self.end_headers()
self.wfile.write("File saved successfully".encode())
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
parsed_path = urlparse(self.path).path
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._validate_template_path(urlparse(self.path).path)
if file_path is not None:
# Content-Type je nach Dateiendung setzen
if file_path.endswith(".md"):
content_type = "text/plain"
elif file_path.endswith(".json"):
content_type = "application/json"
else:
content_type = "text/plain"
try:
with open(file_path, "rb") as f:
self.send_response(200)
self.send_header("Content-type", content_type)
self.end_headers()
self.wfile.write(f.read())
return
except FileNotFoundError:
self.send_error(404, "File not found")
return
except Exception as e:
self.send_error(500, f"Error serving file: {e}")
return
return super().do_GET()
def main(): def main():
PORT = 8081 PORT = 8081
logging.info("Serving on http://localhost:%s", PORT) logging.info("Serving on http://localhost:%s", PORT)