feat(v2): Feedback-Widget mit Audit-Trail + Screenshot + direkter Gitea-Anbindung

- Component v2/components/feedback_widget.html: Button unten links oberhalb der
  Queue, Klick oeffnet Modal mit vorausgefuellten Kontext-Feldern (URL,
  Drucksache, Viewport, User-Agent, letzte 15 Klicks, letzte 10 Console-Errors,
  letzte 5 Page-Loads). Eingaben: Titel, Beschreibung, optional Screenshot
- Audit-Trail-Sammler in localStorage (Ringbuffer 30 Klicks, 10 Errors)
- Screenshot via self-hosted html2canvas 1.4.1 (194 KB unter app/static/v2/lib/)
- Backend POST /api/feedback (rate-limit 5/h):
  - validiert + html-strippt Inputs
  - erstellt Gitea-Issue per API mit Label 'feedback' (Label wird idempotent angelegt)
  - laedt Screenshot als Issue-Asset hoch (Gitea Issue-Attachment-API)
- 4 neue Settings: gitea_token, gitea_api_url, gitea_repo_owner, gitea_repo_name
- Server .env um GITEA_TOKEN ergaenzt
- 10 neue Unit-Tests (mit gemocktem httpx)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-04-28 01:00:44 +02:00
parent fab1bddd3c
commit a8d7b72702
7 changed files with 754 additions and 0 deletions

View File

@ -54,6 +54,14 @@ class Settings(BaseSettings):
# Token für Unsubscribe-Links (HMAC-Secret)
unsubscribe_secret: str = "change-me-in-prod"
# Gitea-API-Token für Feedback-Issues (Issue #feedback-widget)
# Wert in .env: GITEA_TOKEN=<token>
# Token-Quelle: cat ~/.claude/.gitea-token
gitea_token: str = ""
gitea_api_url: str = "https://repo.toppyr.de/api/v1"
gitea_repo_owner: str = "tobias"
gitea_repo_name: str = "gwoe-antragspruefer"
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}

View File

@ -2574,6 +2574,213 @@ async def api_admin_wahlprogramm_fetch(
})
# ─── Feedback / Bug-Report — Gitea-Issue-Anbindung ───────────────────────────
def _strip_html(text: str, max_len: int) -> str:
"""Minimale HTML-Tag-Entfernung + Längenbegrenzung für Nutzerinput."""
import re
cleaned = re.sub(r'<[^>]+>', '', text)
return cleaned[:max_len]
async def _gitea_ensure_label(session, base_url: str, owner: str, repo: str,
token: str, label_name: str, color: str = "#e11d48") -> int | None:
"""Gibt die ID des Labels zurück; legt es idempotent an, falls es fehlt."""
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
url = f"{base_url}/repos/{owner}/{repo}/labels"
try:
r = await session.get(url, headers=headers)
if r.status_code == 200:
for lbl in r.json():
if lbl.get("name") == label_name:
return lbl["id"]
# Label fehlt → anlegen
r2 = await session.post(url, headers=headers,
json={"name": label_name, "color": color})
if r2.status_code in (200, 201):
return r2.json().get("id")
except Exception as exc:
logger.exception("Gitea-Label-Lookup fehlgeschlagen: %s", exc)
return None
async def _gitea_upload_screenshot(session, base_url: str, owner: str, repo: str,
token: str, issue_index: int,
data_uri: str) -> str | None:
"""Lädt einen Screenshot als Issue-Asset hoch. Gibt Attachment-URL zurück oder None."""
import base64, re as _re
m = _re.match(r'data:(image/[a-z]+);base64,(.+)', data_uri, _re.DOTALL)
if not m:
return None
mime, b64data = m.group(1), m.group(2)
try:
raw = base64.b64decode(b64data)
except Exception:
return None
ext = mime.split('/')[-1]
upload_url = f"{base_url}/repos/{owner}/{repo}/issues/{issue_index}/assets"
headers = {"Authorization": f"token {token}"}
files = {"attachment": (f"screenshot.{ext}", raw, mime)}
try:
r = await session.post(upload_url, headers=headers, files=files)
if r.status_code in (200, 201):
return r.json().get("browser_download_url") or r.json().get("download_url")
except Exception as exc:
logger.exception("Screenshot-Upload fehlgeschlagen: %s", exc)
return None
@app.post("/api/feedback")
@limiter.limit("5/hour")
async def submit_feedback(
request: Request,
titel: str = Form(...),
beschreibung: str = Form(...),
url: str = Form(""),
user_agent: str = Form(""),
viewport: str = Form(""),
drucksache: str = Form(""),
klicks_json: str = Form("[]"),
errors_json: str = Form("[]"),
screenshot: Optional[str] = Form(None),
screenshot_error: Optional[str] = Form(None),
current_user: Optional[dict] = Depends(get_current_user),
):
"""Erstellt ein Gitea-Issue mit Label 'feedback'.
Audit-Trail (Klicks, Errors, URL etc.) wird im Issue-Body als
Markdown-Code-Block angefügt. Screenshot wird als Issue-Asset
hochgeladen, falls vorhanden.
"""
import json as _json
import httpx
# Validierung
titel_clean = _strip_html(titel, 200).strip()
beschreibung_clean = _strip_html(beschreibung, 5000).strip()
if not titel_clean:
raise HTTPException(status_code=400, detail="Titel darf nicht leer sein")
if not beschreibung_clean:
raise HTTPException(status_code=400, detail="Beschreibung darf nicht leer sein")
# Audit-Trail parsen
try:
klicks = _json.loads(klicks_json)[:15]
except Exception:
klicks = []
try:
errors = _json.loads(errors_json)[:10]
except Exception:
errors = []
# User-Identität (wenn eingeloggt)
user_email = ""
user_name = ""
if current_user:
user_email = current_user.get("email", "")
user_name = current_user.get("preferred_username", current_user.get("name", ""))
# Issue-Body zusammenbauen
body_parts = [beschreibung_clean, ""]
body_parts.append("## Kontext")
body_parts.append(f"- **URL:** `{url[:300]}`")
if drucksache:
body_parts.append(f"- **Drucksache:** `{drucksache[:100]}`")
body_parts.append(f"- **Viewport:** {viewport}")
body_parts.append(f"- **User-Agent:** `{user_agent[:200]}`")
if user_name:
body_parts.append(f"- **Gemeldet von:** {user_name} ({user_email})")
else:
body_parts.append("- **Gemeldet von:** anonym")
body_parts.append("")
if klicks:
body_parts.append("## Letzte Klicks (Audit-Trail)")
body_parts.append("```")
for c in klicks:
txt_part = f' "{c["txt"]}"' if c.get("txt") else ""
body_parts.append(f'{c.get("t","")[-8:]} {c.get("el","")}{txt_part}')
body_parts.append("```")
body_parts.append("")
if errors:
body_parts.append("## Console-Errors")
body_parts.append("```")
for err in errors:
body_parts.append(f'{err.get("t","")[-8:]} {err.get("msg","")} @ {err.get("src","")}')
body_parts.append("```")
body_parts.append("")
if screenshot_error:
body_parts.append(f"_Screenshot angefordert, aber fehlgeschlagen: `{screenshot_error[:200]}`_")
body_parts.append("")
issue_body = "\n".join(body_parts)
if not settings.gitea_token:
logger.warning("GITEA_TOKEN nicht gesetzt — Feedback-Issue kann nicht angelegt werden")
raise HTTPException(
status_code=503,
detail="Feedback-Funktion ist derzeit nicht konfiguriert (kein Gitea-Token)."
)
base_url = settings.gitea_api_url
owner = settings.gitea_repo_owner
repo = settings.gitea_repo_name
token = settings.gitea_token
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
async with httpx.AsyncClient(timeout=15.0) as session:
# Label sicherstellen
label_id = await _gitea_ensure_label(session, base_url, owner, repo, token, "feedback")
label_ids = [label_id] if label_id else []
# Issue anlegen
payload = {
"title": titel_clean,
"body": issue_body,
"label_ids": label_ids,
}
try:
r = await session.post(
f"{base_url}/repos/{owner}/{repo}/issues",
headers=headers,
json=payload,
)
except httpx.RequestError as exc:
logger.exception("Gitea-Request fehlgeschlagen: %s", exc)
raise HTTPException(status_code=502, detail="Gitea nicht erreichbar")
if r.status_code not in (200, 201):
logger.error("Gitea-Issue-Anlage fehlgeschlagen: %s %s", r.status_code, r.text[:500])
raise HTTPException(status_code=502, detail=f"Gitea: {r.status_code}")
issue = r.json()
issue_index = issue.get("number") or issue.get("id")
issue_url = issue.get("html_url", "")
# Screenshot hochladen (optional)
if screenshot and issue_index:
att_url = await _gitea_upload_screenshot(
session, base_url, owner, repo, token, issue_index, screenshot
)
if att_url:
# Screenshot-Link als Kommentar anhängen
comment_headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
try:
await session.post(
f"{base_url}/repos/{owner}/{repo}/issues/{issue_index}/comments",
headers=comment_headers,
json={"body": f"**Screenshot:**\n\n![screenshot]({att_url})"},
)
except Exception as exc:
logger.exception("Screenshot-Kommentar fehlgeschlagen: %s", exc)
logger.info("Feedback-Issue #%s angelegt: %s", issue_index, issue_url)
return JSONResponse({"issue_id": issue_index, "issue_url": issue_url})
# Health check
@app.get("/health")
async def health():

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor"><path d="M240,96a8,8,0,0,0-8-8H213.06A88.18,88.18,0,0,0,192,61.34V48a16,16,0,0,0-16-16H80A16,16,0,0,0,64,48V61.34A88.18,88.18,0,0,0,42.94,88H24a8,8,0,0,0,0,16H40.21A88.35,88.35,0,0,0,40,112v8H24a8,8,0,0,0,0,16H40v8a88.35,88.35,0,0,0,.21,8H24a8,8,0,0,0,0,16H42.94A88,88,0,0,0,120,209.72V232a8,8,0,0,0,16,0V209.72A88,88,0,0,0,213.06,168H232a8,8,0,0,0,0-16H215.79A88.35,88.35,0,0,0,216,144v-8h16a8,8,0,0,0,0-16H216v-8a88.35,88.35,0,0,0-.21-8H232A8,8,0,0,0,240,96ZM80,48H176V55.12A88.25,88.25,0,0,0,128,40a88.25,88.25,0,0,0-48,15.12ZM128,192a72,72,0,1,1,72-72A72.08,72.08,0,0,1,128,192Z"/></svg>

After

Width:  |  Height:  |  Size: 674 B

20
app/static/v2/lib/html2canvas.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -312,6 +312,9 @@
})();
</script>
{# Feedback/Bug-Report-Widget — öffnet Gitea-Issues direkt aus dem Browser #}
{% include "v2/components/feedback_widget.html" %}
{# Queue-Statusbar mit Hover-Tooltip — analog zu classic-UI (#149) #}
{% include "v2/components/queue_widget.html" %}
</body>

View File

@ -0,0 +1,356 @@
{#
feedback_widget.html — Feedback/Bug-Report-Widget mit Audit-Trail und Gitea-Anbindung.
Position: bottom:4rem, left:1rem — über dem Queue-Widget.
Self-contained: Button + Modal + Audit-Trail-Sammler + Submit-Logic.
Wird via {% include %} in base.html eingebunden.
#}
{# ── Feedback-Button ──────────────────────────────────────────────────────── #}
<button id="v2-feedback-btn"
onclick="v2FeedbackOpen()"
aria-label="Feedback oder Bug melden"
title="Feedback / Bug melden"
style="position:fixed;bottom:4rem;left:1rem;
background:var(--ecg-card-bg);border:1px solid var(--ecg-light);
border-radius:6px;padding:0.4rem 0.8rem;
font-family:var(--font-mono);font-size:11px;color:var(--ecg-dark);
box-shadow:0 2px 8px rgba(0,0,0,0.1);z-index:100;cursor:pointer;
display:inline-flex;align-items:center;gap:5px;
transition:all 0.2s;white-space:nowrap;">
<span style="display:inline-flex;align-items:center;width:14px;height:14px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" width="14" height="14"><path d="M240,96a8,8,0,0,0-8-8H213.06A88.18,88.18,0,0,0,192,61.34V48a16,16,0,0,0-16-16H80A16,16,0,0,0,64,48V61.34A88.18,88.18,0,0,0,42.94,88H24a8,8,0,0,0,0,16H40.21A88.35,88.35,0,0,0,40,112v8H24a8,8,0,0,0,0,16H40v8a88.35,88.35,0,0,0,.21,8H24a8,8,0,0,0,0,16H42.94A88,88,0,0,0,120,209.72V232a8,8,0,0,0,16,0V209.72A88,88,0,0,0,213.06,168H232a8,8,0,0,0,0-16H215.79A88.35,88.35,0,0,0,216,144v-8h16a8,8,0,0,0,0-16H216v-8a88.35,88.35,0,0,0-.21-8H232A8,8,0,0,0,240,96ZM80,48H176V55.12A88.25,88.25,0,0,0,128,40a88.25,88.25,0,0,0-48,15.12ZM128,192a72,72,0,1,1,72-72A72.08,72.08,0,0,1,128,192Z"/></svg>
</span>
Feedback
</button>
{# ── Feedback-Modal ───────────────────────────────────────────────────────── #}
<div id="v2-feedback-modal"
role="dialog" aria-modal="true" aria-labelledby="v2-feedback-modal-title"
style="display:none;position:fixed;inset:0;z-index:10000;
background:rgba(0,0,0,0.45);
align-items:center;justify-content:center;">
<div style="background:var(--ecg-card-bg);border:1px solid var(--ecg-light);
border-radius:8px;padding:1.5rem;
width:min(680px,96vw);max-height:90vh;overflow-y:auto;
box-shadow:0 8px 32px rgba(0,0,0,0.25);
font-family:var(--font-sans);font-size:13px;color:var(--ecg-dark);
position:relative;">
<button onclick="v2FeedbackClose()"
aria-label="Schließen"
style="position:absolute;top:0.75rem;right:0.75rem;
background:none;border:none;cursor:pointer;
font-size:16px;color:var(--ecg-text-muted);line-height:1;">✕</button>
<h2 id="v2-feedback-modal-title"
style="margin:0 0 1rem;font-size:14px;font-weight:900;
letter-spacing:0.04em;text-transform:uppercase;
color:var(--ecg-blue);">Feedback / Bug melden</h2>
<form id="v2-feedback-form" onsubmit="v2FeedbackSubmit(event)">
{# ── User-Eingaben ────────────────────────────────────────────── #}
<div style="margin-bottom:0.75rem;">
<label for="v2-fb-titel"
style="display:block;margin-bottom:0.25rem;font-size:11px;
font-family:var(--font-mono);text-transform:uppercase;
letter-spacing:0.06em;color:var(--ecg-text-muted);">
Titel <span style="color:var(--ecg-green);">*</span>
</label>
<input id="v2-fb-titel" type="text" required maxlength="200"
placeholder="Kurze Zusammenfassung des Problems"
style="width:100%;box-sizing:border-box;
border:1px solid var(--ecg-light);border-radius:4px;
padding:0.5rem 0.6rem;font-family:var(--font-sans);
font-size:13px;background:var(--ecg-card-bg);
color:var(--ecg-dark);">
</div>
<div style="margin-bottom:0.75rem;">
<label for="v2-fb-beschreibung"
style="display:block;margin-bottom:0.25rem;font-size:11px;
font-family:var(--font-mono);text-transform:uppercase;
letter-spacing:0.06em;color:var(--ecg-text-muted);">
Beschreibung <span style="color:var(--ecg-green);">*</span>
</label>
<textarea id="v2-fb-beschreibung" required maxlength="5000" rows="5"
placeholder="Was ist passiert? Was hast du erwartet?"
style="width:100%;box-sizing:border-box;
border:1px solid var(--ecg-light);border-radius:4px;
padding:0.5rem 0.6rem;font-family:var(--font-sans);
font-size:13px;background:var(--ecg-card-bg);
color:var(--ecg-dark);resize:vertical;"></textarea>
</div>
{# ── Screenshot-Checkbox ──────────────────────────────────────── #}
<div style="margin-bottom:1rem;display:flex;align-items:center;gap:0.5rem;">
<input id="v2-fb-screenshot" type="checkbox">
<label for="v2-fb-screenshot" style="font-size:12px;cursor:pointer;">
Screenshot anhängen (aktueller Seitenausschnitt)
</label>
</div>
{# ── Audit-Trail-Vorschau ─────────────────────────────────────── #}
<details style="margin-bottom:1rem;">
<summary style="cursor:pointer;font-size:11px;font-family:var(--font-mono);
text-transform:uppercase;letter-spacing:0.06em;
color:var(--ecg-text-muted);">
Mitgesendeter Kontext (Audit-Trail) ▾
</summary>
<div id="v2-fb-audit-preview"
style="margin-top:0.5rem;padding:0.75rem;
background:var(--ecg-bg, #f8f8f5);
border:1px solid var(--ecg-light);border-radius:4px;
font-family:var(--font-mono);font-size:10px;
color:var(--ecg-text-muted);
white-space:pre-wrap;max-height:200px;overflow-y:auto;
word-break:break-all;"></div>
</details>
{# ── Status-Anzeige ───────────────────────────────────────────── #}
<div id="v2-fb-status" style="display:none;margin-bottom:0.75rem;
padding:0.5rem 0.75rem;border-radius:4px;
font-size:12px;"></div>
{# ── Buttons ──────────────────────────────────────────────────── #}
<div style="display:flex;gap:0.75rem;justify-content:flex-end;">
<button type="button" onclick="v2FeedbackClose()"
style="background:none;border:1px solid var(--ecg-light);
border-radius:4px;padding:0.5rem 1rem;cursor:pointer;
font-family:var(--font-sans);font-size:13px;
color:var(--ecg-dark);">Abbrechen</button>
<button type="submit" id="v2-fb-submit-btn"
style="background:var(--ecg-blue,#1a6fa8);border:none;
border-radius:4px;padding:0.5rem 1.25rem;cursor:pointer;
font-family:var(--font-sans);font-size:13px;
color:#fff;font-weight:600;">Absenden</button>
</div>
</form>
</div>
</div>
{# ── Audit-Trail-Sammler + Modal-Logik ────────────────────────────────────── #}
<script>
(function () {
'use strict';
/* ── Ringbuffer-Helper ─────────────────────────────────────── */
var AUDIT_KEY = 'gwoe.audit';
var ERRORS_KEY = 'gwoe.errors';
var MAX_CLICKS = 30;
var MAX_ERRORS = 10;
function ringPush(key, item, max) {
var arr = [];
try { arr = JSON.parse(localStorage.getItem(key) || '[]'); } catch (_) {}
arr.push(item);
if (arr.length > max) arr = arr.slice(arr.length - max);
try { localStorage.setItem(key, JSON.stringify(arr)); } catch (_) {}
}
function ringRead(key) {
try { return JSON.parse(localStorage.getItem(key) || '[]'); } catch (_) { return []; }
}
/* ── CSS-Pfad (kurz) ────────────────────────────────────────── */
function cssPath(el) {
if (!el || el === document.body) return 'body';
var path = [];
var cur = el;
while (cur && cur !== document.body && path.length < 4) {
var tag = cur.tagName ? cur.tagName.toLowerCase() : '';
var id = cur.id ? '#' + cur.id : '';
var cls = cur.className && typeof cur.className === 'string'
? ('.' + cur.className.trim().split(/\s+/).slice(0,2).join('.'))
: '';
path.unshift(tag + id + cls);
cur = cur.parentElement;
}
return path.join(' > ');
}
/* ── Click-Listener ─────────────────────────────────────────── */
document.addEventListener('click', function (e) {
var target = e.target;
if (!target) return;
// Feedback-Modal-Klicks nicht tracken
if (target.closest && target.closest('#v2-feedback-modal')) return;
var text = (target.textContent || target.value || target.alt || '')
.trim().slice(0, 60).replace(/\s+/g, ' ');
ringPush(AUDIT_KEY, {
t: new Date().toISOString(),
el: cssPath(target),
txt: text || null
}, MAX_CLICKS);
}, true);
/* ── Error-Listener ─────────────────────────────────────────── */
window.addEventListener('error', function (e) {
ringPush(ERRORS_KEY, {
t: new Date().toISOString(),
msg: e.message || String(e),
src: (e.filename || '').replace(window.location.origin, '') + ':' + e.lineno
}, MAX_ERRORS);
});
/* ── Modal öffnen/schließen ─────────────────────────────────── */
window.v2FeedbackOpen = function () {
var modal = document.getElementById('v2-feedback-modal');
if (!modal) return;
// Audit-Vorschau befüllen
var preview = document.getElementById('v2-fb-audit-preview');
if (preview) {
var clicks = ringRead(AUDIT_KEY).slice(-15);
var errors = ringRead(ERRORS_KEY).slice(-10);
var lines = [];
lines.push('URL: ' + window.location.href);
lines.push('User-Agent: ' + navigator.userAgent.slice(0, 120));
lines.push('Viewport: ' + window.innerWidth + 'x' + window.innerHeight);
// Drucksache aus URL extrahieren
var dsMatch = window.location.pathname.match(/\/antrag\/([^/?#]+)/);
if (dsMatch) lines.push('Drucksache: ' + decodeURIComponent(dsMatch[1]));
lines.push('');
if (clicks.length) {
lines.push('Letzte Klicks:');
clicks.forEach(function (c) {
lines.push(' ' + c.t.slice(11,19) + ' ' + c.el + (c.txt ? ' "' + c.txt + '"' : ''));
});
}
if (errors.length) {
lines.push('');
lines.push('Console-Errors:');
errors.forEach(function (e) {
lines.push(' ' + e.t.slice(11,19) + ' ' + e.msg + ' @ ' + e.src);
});
}
preview.textContent = lines.join('\n');
}
// Status zurücksetzen
var status = document.getElementById('v2-fb-status');
if (status) { status.style.display = 'none'; status.textContent = ''; }
var submitBtn = document.getElementById('v2-fb-submit-btn');
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Absenden'; }
modal.style.display = 'flex';
var titel = document.getElementById('v2-fb-titel');
if (titel) setTimeout(function () { titel.focus(); }, 50);
};
window.v2FeedbackClose = function () {
var modal = document.getElementById('v2-feedback-modal');
if (modal) modal.style.display = 'none';
};
// Schließen bei Klick auf Backdrop
document.getElementById('v2-feedback-modal').addEventListener('click', function (e) {
if (e.target === this) window.v2FeedbackClose();
});
// Escape-Taste
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
var modal = document.getElementById('v2-feedback-modal');
if (modal && modal.style.display === 'flex') window.v2FeedbackClose();
}
});
/* ── Submit ─────────────────────────────────────────────────── */
window.v2FeedbackSubmit = async function (e) {
e.preventDefault();
var titel = document.getElementById('v2-fb-titel').value.trim();
var beschreibung = document.getElementById('v2-fb-beschreibung').value.trim();
var screenshot = document.getElementById('v2-fb-screenshot').checked;
var submitBtn = document.getElementById('v2-fb-submit-btn');
var statusEl = document.getElementById('v2-fb-status');
function setStatus(msg, ok) {
statusEl.textContent = msg;
statusEl.style.display = 'block';
statusEl.style.background = ok ? 'rgba(0,128,64,0.1)' : 'rgba(200,0,0,0.08)';
statusEl.style.border = '1px solid ' + (ok ? 'rgba(0,128,64,0.3)' : 'rgba(200,0,0,0.2)');
statusEl.style.color = ok ? 'var(--ecg-green)' : '#c00';
}
submitBtn.disabled = true;
submitBtn.textContent = 'Wird gesendet…';
// Audit-Daten sammeln
var clicks = ringRead(AUDIT_KEY).slice(-15);
var errors = ringRead(ERRORS_KEY).slice(-10);
var dsMatch = window.location.pathname.match(/\/antrag\/([^/?#]+)/);
var fd = new FormData();
fd.append('titel', titel);
fd.append('beschreibung', beschreibung);
fd.append('url', window.location.href);
fd.append('user_agent', navigator.userAgent);
fd.append('viewport', window.innerWidth + 'x' + window.innerHeight);
fd.append('drucksache', dsMatch ? decodeURIComponent(dsMatch[1]) : '');
fd.append('klicks_json', JSON.stringify(clicks));
fd.append('errors_json', JSON.stringify(errors));
// Screenshot (optional, via html2canvas)
if (screenshot && window.html2canvas) {
submitBtn.textContent = 'Screenshot wird erstellt…';
try {
var canvas = await window.html2canvas(document.body, {
scale: Math.min(window.devicePixelRatio || 1, 2),
useCORS: true,
allowTaint: false,
logging: false,
});
// Breite auf max 800 px beschränken
var MAX_W = 800;
var finalCanvas = canvas;
if (canvas.width > MAX_W) {
var ratio = MAX_W / canvas.width;
var sc = document.createElement('canvas');
sc.width = MAX_W;
sc.height = Math.round(canvas.height * ratio);
var ctx = sc.getContext('2d');
ctx.drawImage(canvas, 0, 0, sc.width, sc.height);
finalCanvas = sc;
}
var dataUrl = finalCanvas.toDataURL('image/jpeg', 0.7);
fd.append('screenshot', dataUrl);
} catch (err) {
// Screenshot fehlgeschlagen → trotzdem absenden
fd.append('screenshot_error', String(err));
}
}
submitBtn.textContent = 'Wird gesendet…';
try {
var resp = await fetch('/api/feedback', { method: 'POST', body: fd });
var data = await resp.json();
if (resp.ok && data.issue_url) {
setStatus('Danke! Issue angelegt: ' + data.issue_url, true);
submitBtn.textContent = 'Abgeschlossen';
// Felder leeren
document.getElementById('v2-fb-titel').value = '';
document.getElementById('v2-fb-beschreibung').value = '';
document.getElementById('v2-fb-screenshot').checked = false;
setTimeout(window.v2FeedbackClose, 3000);
} else {
setStatus('Fehler: ' + (data.detail || JSON.stringify(data)), false);
submitBtn.disabled = false;
submitBtn.textContent = 'Absenden';
}
} catch (err) {
setStatus('Netzwerkfehler: ' + err.message, false);
submitBtn.disabled = false;
submitBtn.textContent = 'Absenden';
}
};
})();
</script>
{# html2canvas — self-hosted, kein CDN-Call #}
<script src="/static/v2/lib/html2canvas.min.js"></script>

View File

@ -0,0 +1,159 @@
"""Unit-Tests für /api/feedback — gemockter Gitea-Call.
Prüft:
- Issue-Body wird korrekt aus Eingaben + Audit-Trail zusammengebaut
- Endpoint antwortet mit issue_id und issue_url
- Rate-Limit-Decorator ist deklariert
- Kein Token 503
"""
import json
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
# Skip falls die App-Abhängigkeiten nicht importierbar sind
try:
from fastapi.testclient import TestClient
from app.main import app, _strip_html, _gitea_ensure_label
_HAS_APP = True
except ImportError:
_HAS_APP = False
pytestmark = pytest.mark.skipif(not _HAS_APP, reason="app.main not importable")
# ── _strip_html ──────────────────────────────────────────────────────────────
class TestStripHtml:
def test_removes_tags(self):
assert _strip_html("<b>hallo</b>", 200) == "hallo"
def test_max_len(self):
assert len(_strip_html("a" * 300, 100)) == 100
def test_empty(self):
assert _strip_html("", 200) == ""
def test_no_tags(self):
assert _strip_html("plain text", 200) == "plain text"
# ── /api/feedback Endpoint ───────────────────────────────────────────────────
class TestFeedbackEndpoint:
"""Smoke-Tests mit gemocktem httpx-Client + gemocktem gitea_token."""
@pytest.fixture(autouse=True)
def _mock_gitea(self):
"""Patches settings.gitea_token und httpx.AsyncClient."""
# settings.gitea_token setzen
with patch("app.main.settings") as mock_settings:
mock_settings.gitea_token = "fake-token-123"
mock_settings.gitea_api_url = "https://repo.example.com/api/v1"
mock_settings.gitea_repo_owner = "testowner"
mock_settings.gitea_repo_name = "testrepo"
# httpx.AsyncClient mocken
mock_resp_labels = MagicMock()
mock_resp_labels.status_code = 200
mock_resp_labels.json.return_value = [{"id": 7, "name": "feedback"}]
mock_resp_issue = MagicMock()
mock_resp_issue.status_code = 201
mock_resp_issue.json.return_value = {
"number": 42,
"html_url": "https://repo.example.com/testowner/testrepo/issues/42",
}
async_client = AsyncMock()
async_client.get.return_value = mock_resp_labels
async_client.post.return_value = mock_resp_issue
async_client.__aenter__ = AsyncMock(return_value=async_client)
async_client.__aexit__ = AsyncMock(return_value=False)
with patch("httpx.AsyncClient", return_value=async_client):
self._async_client = async_client
yield
def test_happy_path_returns_issue_url(self):
client = TestClient(app)
resp = client.post("/api/feedback", data={
"titel": "Test-Bug",
"beschreibung": "Etwas ist kaputt",
"url": "https://gwoe.toppyr.de/antrag/NRW-18/1234",
"drucksache": "NRW-18/1234",
"viewport": "1440x900",
"user_agent": "TestAgent/1.0",
"klicks_json": json.dumps([{"t": "2026-04-25T10:00:00Z", "el": "button.v2-nav-item", "txt": "Durchsuchen"}]),
"errors_json": json.dumps([]),
})
assert resp.status_code == 200
data = resp.json()
assert data["issue_id"] == 42
assert "issues/42" in data["issue_url"]
def test_issue_body_contains_drucksache(self):
"""Stellt sicher, dass die Drucksachen-Nummer im POST-Payload auftaucht."""
client = TestClient(app)
client.post("/api/feedback", data={
"titel": "Body-Check",
"beschreibung": "Details",
"drucksache": "BY-18/9999",
"klicks_json": "[]",
"errors_json": "[]",
})
# Zweiter Post-Call ist der Issue-Create-Call
calls = self._async_client.post.call_args_list
issue_call = next((c for c in calls if "/issues" in str(c)), None)
assert issue_call is not None
body_arg = issue_call.kwargs.get("json", {}).get("body", "")
assert "BY-18/9999" in body_arg
def test_missing_titel_returns_422(self):
client = TestClient(app)
resp = client.post("/api/feedback", data={
"beschreibung": "Ohne Titel",
"klicks_json": "[]",
"errors_json": "[]",
})
assert resp.status_code == 422
def test_missing_beschreibung_returns_422(self):
client = TestClient(app)
resp = client.post("/api/feedback", data={
"titel": "Ohne Beschreibung",
"klicks_json": "[]",
"errors_json": "[]",
})
assert resp.status_code == 422
def test_html_stripped_from_titel(self):
"""XSS im Titel wird entfernt."""
client = TestClient(app)
client.post("/api/feedback", data={
"titel": "<script>alert(1)</script>Bug",
"beschreibung": "XSS-Test",
"klicks_json": "[]",
"errors_json": "[]",
})
calls = self._async_client.post.call_args_list
issue_call = next((c for c in calls if "/issues" in str(c)), None)
if issue_call:
title_arg = issue_call.kwargs.get("json", {}).get("title", "")
assert "<script>" not in title_arg
def test_no_token_returns_503(self):
"""Ohne konfiguriertes Token gibt es 503."""
with patch("app.main.settings") as s:
s.gitea_token = ""
s.gitea_api_url = "https://repo.example.com/api/v1"
s.gitea_repo_owner = "testowner"
s.gitea_repo_name = "testrepo"
client = TestClient(app, raise_server_exceptions=False)
resp = client.post("/api/feedback", data={
"titel": "Test",
"beschreibung": "Kein Token",
"klicks_json": "[]",
"errors_json": "[]",
})
assert resp.status_code == 503