Add image attachments to feedback form (JPEG/PNG)
- File input in feedback modal (max 3 images, 5 MB each) - Frontend validation for file count and size - Backend: multipart/form-data with UploadFile, MIME attachments - Images attached to feedback email as base64-encoded attachments - Only JPEG and PNG allowed (validated server-side) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -5,12 +5,14 @@ import logging
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.base import MIMEBase
|
||||||
|
from email import encoders
|
||||||
|
from typing import List
|
||||||
|
|
||||||
import aiosmtplib
|
import aiosmtplib
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status, Form, UploadFile, File
|
||||||
|
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
from models import FeedbackRequest
|
|
||||||
from config import (
|
from config import (
|
||||||
SMTP_HOST,
|
SMTP_HOST,
|
||||||
SMTP_PORT,
|
SMTP_PORT,
|
||||||
@@ -38,12 +40,23 @@ _MAX_PER_HOUR = 3
|
|||||||
_WINDOW = 3600
|
_WINDOW = 3600
|
||||||
|
|
||||||
|
|
||||||
|
_ALLOWED_TYPES = {"image/jpeg", "image/png"}
|
||||||
|
_MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
|
||||||
|
_MAX_FILES = 3
|
||||||
|
|
||||||
|
|
||||||
@router.post("/feedback", status_code=204)
|
@router.post("/feedback", status_code=204)
|
||||||
async def send_feedback(
|
async def send_feedback(
|
||||||
data: FeedbackRequest,
|
category: str = Form(...),
|
||||||
|
message: str = Form(..., min_length=10, max_length=5000),
|
||||||
|
files: List[UploadFile] = File(default=[]),
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Feedback per E-Mail an das Team senden."""
|
"""Feedback per E-Mail an das Team senden (mit optionalen Bild-Anhaengen)."""
|
||||||
|
# Kategorie validieren
|
||||||
|
if category not in CATEGORY_LABELS:
|
||||||
|
raise HTTPException(status_code=422, detail="Ungueltige Kategorie.")
|
||||||
|
|
||||||
user_id = current_user["id"]
|
user_id = current_user["id"]
|
||||||
|
|
||||||
# Rate-Limiting
|
# Rate-Limiting
|
||||||
@@ -56,6 +69,13 @@ async def send_feedback(
|
|||||||
detail="Maximal 3 Feedback-Nachrichten pro Stunde. Bitte spaeter erneut versuchen.",
|
detail="Maximal 3 Feedback-Nachrichten pro Stunde. Bitte spaeter erneut versuchen.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Dateien validieren
|
||||||
|
if len(files) > _MAX_FILES:
|
||||||
|
raise HTTPException(status_code=422, detail=f"Maximal {_MAX_FILES} Dateien erlaubt.")
|
||||||
|
for f in files:
|
||||||
|
if f.content_type not in _ALLOWED_TYPES:
|
||||||
|
raise HTTPException(status_code=422, detail=f"Dateityp {f.content_type} nicht erlaubt (nur JPEG/PNG).")
|
||||||
|
|
||||||
if not SMTP_HOST:
|
if not SMTP_HOST:
|
||||||
logger.warning("SMTP nicht konfiguriert - Feedback nicht gesendet")
|
logger.warning("SMTP nicht konfiguriert - Feedback nicht gesendet")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -65,8 +85,8 @@ async def send_feedback(
|
|||||||
|
|
||||||
email = current_user.get("email", "")
|
email = current_user.get("email", "")
|
||||||
display_name = email.split("@")[0] if email else "Unbekannt"
|
display_name = email.split("@")[0] if email else "Unbekannt"
|
||||||
category_label = CATEGORY_LABELS.get(data.category, data.category)
|
category_label = CATEGORY_LABELS.get(category, category)
|
||||||
message_escaped = html.escape(data.message)
|
message_escaped = html.escape(message)
|
||||||
|
|
||||||
subject = f"[AegisSight Feedback] {category_label} von {display_name}"
|
subject = f"[AegisSight Feedback] {category_label} von {display_name}"
|
||||||
html_body = f"""\
|
html_body = f"""\
|
||||||
|
|||||||
@@ -551,6 +551,11 @@
|
|||||||
<textarea id="fb-message" required aria-required="true" minlength="10" maxlength="5000" rows="6" placeholder="Beschreibe dein Anliegen (mind. 10 Zeichen)..."></textarea>
|
<textarea id="fb-message" required aria-required="true" minlength="10" maxlength="5000" rows="6" placeholder="Beschreibe dein Anliegen (mind. 10 Zeichen)..."></textarea>
|
||||||
<div class="form-hint"><span id="fb-char-count">0</span> / 5.000 Zeichen</div>
|
<div class="form-hint"><span id="fb-char-count">0</span> / 5.000 Zeichen</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="fb-files">Bilder anhaengen (optional)</label>
|
||||||
|
<input type="file" id="fb-files" accept="image/jpeg,image/png" multiple style="font-size:13px;">
|
||||||
|
<div class="form-hint">Max. 3 Bilder (JPEG/PNG, je max. 5 MB)</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-feedback')">Abbrechen</button>
|
<button type="button" class="btn btn-secondary" onclick="closeModal('modal-feedback')">Abbrechen</button>
|
||||||
|
|||||||
@@ -197,6 +197,23 @@ const API = {
|
|||||||
return this._request('POST', '/feedback', data);
|
return this._request('POST', '/feedback', data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async sendFeedbackForm(formData) {
|
||||||
|
const token = localStorage.getItem('osint_token');
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 60000);
|
||||||
|
const resp = await fetch(this.baseUrl + '/feedback', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': token ? 'Bearer ' + token : '' },
|
||||||
|
body: formData,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || 'Fehler ' + resp.status);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Export
|
// Export
|
||||||
exportIncident(id, format, scope) {
|
exportIncident(id, format, scope) {
|
||||||
const token = localStorage.getItem('osint_token');
|
const token = localStorage.getItem('osint_token');
|
||||||
|
|||||||
@@ -2168,10 +2168,30 @@ const App = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dateien pruefen
|
||||||
|
const fileInput = document.getElementById('fb-files');
|
||||||
|
const files = fileInput ? Array.from(fileInput.files) : [];
|
||||||
|
if (files.length > 3) {
|
||||||
|
UI.showToast('Maximal 3 Bilder erlaubt.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const f of files) {
|
||||||
|
if (f.size > 5 * 1024 * 1024) {
|
||||||
|
UI.showToast('Datei "' + f.name + '" ist groesser als 5 MB.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Wird gesendet...';
|
btn.textContent = 'Wird gesendet...';
|
||||||
try {
|
try {
|
||||||
await API.sendFeedback({ category, message });
|
const formData = new FormData();
|
||||||
|
formData.append('category', category);
|
||||||
|
formData.append('message', message);
|
||||||
|
for (const f of files) {
|
||||||
|
formData.append('files', f);
|
||||||
|
}
|
||||||
|
await API.sendFeedbackForm(formData);
|
||||||
closeModal('modal-feedback');
|
closeModal('modal-feedback');
|
||||||
UI.showToast('Feedback gesendet. Vielen Dank!', 'success');
|
UI.showToast('Feedback gesendet. Vielen Dank!', 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren