From 9890763f0f764e2786e82b47c08f04b18cbb8743 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 3 May 2026 14:40:44 +0200 Subject: [PATCH] refactor: split serve.py and index.html into single-responsibility modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, / + + + + + + + diff --git a/web/js/api.js b/web/js/api.js new file mode 100644 index 0000000..0f407e4 --- /dev/null +++ b/web/js/api.js @@ -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} 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} 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}`); + } +} diff --git a/web/js/editor.js b/web/js/editor.js new file mode 100644 index 0000000..afb1d6b --- /dev/null +++ b/web/js/editor.js @@ -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 diff --git a/web/js/main.js b/web/js/main.js new file mode 100644 index 0000000..d757ac0 --- /dev/null +++ b/web/js/main.js @@ -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(); +}); diff --git a/web/js/modal.js b/web/js/modal.js new file mode 100644 index 0000000..b82d5bf --- /dev/null +++ b/web/js/modal.js @@ -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, +}; diff --git a/web/js/templates.js b/web/js/templates.js new file mode 100644 index 0000000..4ccc275 --- /dev/null +++ b/web/js/templates.js @@ -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 = '

Keine Templates gefunden

Füge Templates in den templates/ Ordnern hinzu.

'; + return; + } + + container.innerHTML = templates.map(t => ` +
+

${esc(t.name)}

+
+ 🏷️ ${esc(t.type)} + 📄 ${esc(t.format)} + 📌 v${esc(t.version)} +
+

${esc(t.description) || 'Keine Beschreibung'}

+
+ ${t.tags.map(tag => `${esc(tag)}`).join('')} +
+
+ + + +
+
+ `).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 diff --git a/web/js/utils.js b/web/js/utils.js new file mode 100644 index 0000000..c442513 --- /dev/null +++ b/web/js/utils.js @@ -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 }; diff --git a/web/path_validator.py b/web/path_validator.py new file mode 100644 index 0000000..63f5ca6 --- /dev/null +++ b/web/path_validator.py @@ -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 -> /templates.json + /templates/system/foo.json -> /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 diff --git a/web/serve.py b/web/serve.py index 852d183..a86e720 100755 --- a/web/serve.py +++ b/web/serve.py @@ -1,28 +1,40 @@ #!/usr/bin/env python3 """ Minimaler Entwicklungs-Server für die Prompt Templates Webansicht. + 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 -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__)) 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): - """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 while port < 10000: try: @@ -34,141 +46,6 @@ def find_free_port(start_port=9000): 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(): PORT = 8081 logging.info("Serving on http://localhost:%s", PORT)