Double-Opt-In Verification für Kommentare (E-Mail/WhatsApp)

- Neues comments.verification_token Feld (UUID)
- /api/comment: Bei VERIFICATION_REQUIRED=1 → pending + Token
- Verification-Link per E-Mail und WhatsApp versendet
- GET /verify-comment/<token>: Prüfung → LLM-Moderation → published/blocked
- Testsystem-Bypass: VERIFICATION_REQUIRED=0 → direkt published (default)
- Bestehende 40 Kommentare bleiben unangetastet (status='published')
- Token-Einmalgebrauch: 404 bei Wiederverwendung
This commit is contained in:
Hermes Agent
2026-06-18 01:28:34 +02:00
parent 77d4ec426c
commit 7c96743237
+95 -13
View File
@@ -16,8 +16,10 @@ Usage:
import json
import os
import re
import secrets
import sqlite3
import sys
import uuid
from datetime import datetime
from pathlib import Path
@@ -45,6 +47,8 @@ VERIFY_TOKEN = os.environ.get("VERIFY_TOKEN", "bsn_verify_2026")
META_TOKEN = os.environ.get("META_TOKEN", "")
PHONE_NUMBER_ID = os.environ.get("META_PHONE_NUMBER_ID", "1152708791258718")
PORT = int(os.environ.get("PORT", "5002"))
VERIFICATION_REQUIRED = os.environ.get("VERIFICATION_REQUIRED", "").lower() in ("1", "true", "yes")
# Test system: verification is OFF by default (set VERIFICATION_REQUIRED=1 in .env to enable)
MEDIA_DIR = BASE_DIR / "media"
MEDIA_DIR.mkdir(exist_ok=True)
@@ -3741,14 +3745,58 @@ def api_comment(sub_id: int):
if not email and not phone:
return jsonify({"error": "Bitte E-Mail-Adresse oder Handynummer angeben, damit wir dich bei Antworten benachrichtigen können."}), 400
verification_token = str(uuid.uuid4()) if VERIFICATION_REQUIRED else None
db.execute(
"INSERT INTO comments (submission_id, author_name, content, parent_id, email, phone) VALUES (?, ?, ?, ?, ?, ?)",
(sub_id, author or None, content, parent_id, email or None, phone or None),
"INSERT INTO comments (submission_id, author_name, content, parent_id, email, phone, status, verification_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(sub_id, author or None, content, parent_id, email or None, phone or None, "pending" if VERIFICATION_REQUIRED else "published", verification_token),
)
db.commit()
comment_id = db.execute("SELECT last_insert_rowid()").fetchone()[0]
# LLM Moderation — run after insert, update status
# === VERIFICATION REQUIRED (production mode) ===
if VERIFICATION_REQUIRED and verification_token:
base = request.host_url.rstrip("/")
verify_url = f"{base}/verify-comment/{verification_token}"
# Send verification via WhatsApp
if phone:
try:
msg = (
f"📨 *Bitte bestätige deinen Kommentar!*\n\n"
f"Jemand (du?) hat auf boardgame.network kommentiert:\n"
f"_{content[:150]}{'...' if len(content) > 150 else ''}_\n\n"
f"Klicke hier zum Bestätigen:\n"
f"👉 {verify_url}\n\n"
f"Falls du das nicht warst, ignoriere diese Nachricht einfach."
)
send_whatsapp_message(phone, msg)
except Exception:
pass
# Send verification via email
if email:
try:
subject = "📨 Bitte bestätige deinen Kommentar auf boardgame.network"
body = (
f"Hallo,\n\n"
f"jemand (du?) hat auf boardgame.network kommentiert:\n\n"
f"\"{content[:200]}{'...' if len(content) > 200 else ''}\"\n\n"
f"Klicke zum Bestätigen:\n"
f"👉 {verify_url}\n\n"
f"Falls du das nicht warst, ignoriere diese E-Mail einfach.\n\n"
f"Viele Grüße,\n"
f"boardgame.network\n"
)
_send_email(email, subject, body)
except Exception:
pass
return jsonify({"ok": True, "comment_id": comment_id, "pending": True,
"message": "Bitte bestätige deinen Kommentar über den Link in deiner E-Mail oder WhatsApp-Nachricht."}), 201
# === NO VERIFICATION (test system) — immediate moderation + notification ===
# LLM Moderation
mod_status, mod_reason = moderate_comment(content)
db.execute(
"UPDATE comments SET status=?, moderation_reason=? WHERE id=?",
@@ -3756,7 +3804,6 @@ def api_comment(sub_id: int):
)
db.commit()
# If blocked, skip notifications and return success silently
if mod_status == "blocked":
print(f"[moderate] Comment #{comment_id} BLOCKED: {mod_reason}", file=sys.stderr)
return jsonify({"ok": True, "comment_id": comment_id}), 201
@@ -3768,11 +3815,10 @@ def api_comment(sub_id: int):
# 1. Notify parent comment author (if replying to a comment)
if parent_id:
parent = db.execute(
"SELECT author_name, email, phone FROM comments WHERE id = ? AND submission_id = ?",
"SELECT author_name, email, phone FROM comments WHERE id = ? AND submission_id = ? AND status='published'",
(parent_id, sub_id),
).fetchone()
if parent:
p_author = parent["author_name"] or "Jemand"
# WhatsApp to parent comment author
if parent["phone"]:
try:
@@ -3788,7 +3834,7 @@ def api_comment(sub_id: int):
# Email to parent comment author
if parent["email"]:
try:
subject = f"💬 Antwort auf deinen BSN-Kommentar"
subject = "💬 Antwort auf deinen BSN-Kommentar"
body = (
f"Hallo,\n\n"
f"{author or 'Jemand'} hat auf deinen Kommentar geantwortet:\n\n"
@@ -3805,14 +3851,13 @@ def api_comment(sub_id: int):
notify_submission_author = True
if parent_id:
parent = db.execute(
"SELECT author_name, email, phone FROM comments WHERE id = ? AND submission_id = ?",
"SELECT author_name, email, phone FROM comments WHERE id = ? AND submission_id = ? AND status='published'",
(parent_id, sub_id),
).fetchone()
if parent and (parent["email"] == row["email"] or parent["phone"] == row["phone"]):
notify_submission_author = False # Conversation with themselves
notify_submission_author = False
if notify_submission_author:
# WhatsApp notification
if row["channel"] == "whatsapp" and row["sender_id"]:
try:
msg = (
@@ -3824,7 +3869,6 @@ def api_comment(sub_id: int):
send_whatsapp_message(row["sender_id"], msg)
except Exception:
pass
# Email to submission author
if row["email"]:
try:
subject = f"💬 Neuer Kommentar zu deinem BSN-Beitrag #{sub_id}"
@@ -3839,8 +3883,6 @@ def api_comment(sub_id: int):
_send_email(row["email"], subject, body)
except Exception:
pass
# WhatsApp to submission author via phone
if row["phone"]:
try:
msg = (
@@ -3856,6 +3898,46 @@ def api_comment(sub_id: int):
return jsonify({"ok": True, "comment_id": comment_id}), 201
@app.route("/verify-comment/<token>")
def verify_comment(token: str):
"""Verify a pending comment via token link."""
db = get_db()
row = db.execute(
"SELECT c.*, s.spiel_titel FROM comments c LEFT JOIN submissions s ON c.submission_id = s.id WHERE c.verification_token = ? AND c.status = 'pending'",
(token,),
).fetchone()
if not row:
return "<h1>Ungültiger Link</h1><p>Dieser Bestätigungslink ist nicht gültig oder wurde bereits verwendet.</p><p><a href='/'>← Zur Startseite</a></p>", 404
# Run LLM moderation
mod_status, mod_reason = moderate_comment(row["content"])
db.execute(
"UPDATE comments SET status=?, moderation_reason=?, verification_token=NULL WHERE id=?",
(mod_status if mod_status == "blocked" else "published", mod_reason, row["id"]),
)
db.commit()
if mod_status == "blocked":
return f"<html lang=de><body style='font-family:sans-serif;text-align:center;padding:3rem;'><h1 style='color:#c62828;'>🚫 Kommentar abgelehnt</h1><p>{mod_reason or 'Verstoß gegen Community-Richtlinien'}</p><p><a href='/'>← Zur Startseite</a></p></body></html>", 403
# Redirect to the submission detail page
return f"""<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"><meta http-equiv="refresh" content="3;url=/beitrag/{row['submission_id']}">
<title>Kommentar bestätigt</title>
<style>
body {{font-family:'Inter',sans-serif;text-align:center;padding:3rem;background:#f5f3ee;color:#1a1a2e;}}
h1 {{color:#20228a;}}
a {{color:#20228a;}}
</style></head>
<body>
<h1> Kommentar bestätigt!</h1>
<p>Dein Kommentar ist jetzt auf boardgame.network sichtbar.</p>
<p>Du wirst in 3 Sekunden weitergeleitet<br><a href="/beitrag/{row['submission_id']}"> Direkt zum Beitrag</a></p>
</body>
</html>"""
if __name__ == "__main__":
import threading
def keepalive():