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 json
import os import os
import re import re
import secrets
import sqlite3 import sqlite3
import sys import sys
import uuid
from datetime import datetime from datetime import datetime
from pathlib import Path 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", "") META_TOKEN = os.environ.get("META_TOKEN", "")
PHONE_NUMBER_ID = os.environ.get("META_PHONE_NUMBER_ID", "1152708791258718") PHONE_NUMBER_ID = os.environ.get("META_PHONE_NUMBER_ID", "1152708791258718")
PORT = int(os.environ.get("PORT", "5002")) 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 = BASE_DIR / "media"
MEDIA_DIR.mkdir(exist_ok=True) MEDIA_DIR.mkdir(exist_ok=True)
@@ -3741,14 +3745,58 @@ def api_comment(sub_id: int):
if not email and not phone: 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 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( db.execute(
"INSERT INTO comments (submission_id, author_name, content, parent_id, email, phone) VALUES (?, ?, ?, ?, ?, ?)", "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), (sub_id, author or None, content, parent_id, email or None, phone or None, "pending" if VERIFICATION_REQUIRED else "published", verification_token),
) )
db.commit() db.commit()
comment_id = db.execute("SELECT last_insert_rowid()").fetchone()[0] 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) mod_status, mod_reason = moderate_comment(content)
db.execute( db.execute(
"UPDATE comments SET status=?, moderation_reason=? WHERE id=?", "UPDATE comments SET status=?, moderation_reason=? WHERE id=?",
@@ -3756,7 +3804,6 @@ def api_comment(sub_id: int):
) )
db.commit() db.commit()
# If blocked, skip notifications and return success silently
if mod_status == "blocked": if mod_status == "blocked":
print(f"[moderate] Comment #{comment_id} BLOCKED: {mod_reason}", file=sys.stderr) print(f"[moderate] Comment #{comment_id} BLOCKED: {mod_reason}", file=sys.stderr)
return jsonify({"ok": True, "comment_id": comment_id}), 201 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) # 1. Notify parent comment author (if replying to a comment)
if parent_id: if parent_id:
parent = db.execute( 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), (parent_id, sub_id),
).fetchone() ).fetchone()
if parent: if parent:
p_author = parent["author_name"] or "Jemand"
# WhatsApp to parent comment author # WhatsApp to parent comment author
if parent["phone"]: if parent["phone"]:
try: try:
@@ -3788,7 +3834,7 @@ def api_comment(sub_id: int):
# Email to parent comment author # Email to parent comment author
if parent["email"]: if parent["email"]:
try: try:
subject = f"💬 Antwort auf deinen BSN-Kommentar" subject = "💬 Antwort auf deinen BSN-Kommentar"
body = ( body = (
f"Hallo,\n\n" f"Hallo,\n\n"
f"{author or 'Jemand'} hat auf deinen Kommentar geantwortet:\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 notify_submission_author = True
if parent_id: if parent_id:
parent = db.execute( 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), (parent_id, sub_id),
).fetchone() ).fetchone()
if parent and (parent["email"] == row["email"] or parent["phone"] == row["phone"]): 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: if notify_submission_author:
# WhatsApp notification
if row["channel"] == "whatsapp" and row["sender_id"]: if row["channel"] == "whatsapp" and row["sender_id"]:
try: try:
msg = ( msg = (
@@ -3824,7 +3869,6 @@ def api_comment(sub_id: int):
send_whatsapp_message(row["sender_id"], msg) send_whatsapp_message(row["sender_id"], msg)
except Exception: except Exception:
pass pass
# Email to submission author
if row["email"]: if row["email"]:
try: try:
subject = f"💬 Neuer Kommentar zu deinem BSN-Beitrag #{sub_id}" 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) _send_email(row["email"], subject, body)
except Exception: except Exception:
pass pass
# WhatsApp to submission author via phone
if row["phone"]: if row["phone"]:
try: try:
msg = ( msg = (
@@ -3856,6 +3898,46 @@ def api_comment(sub_id: int):
return jsonify({"ok": True, "comment_id": comment_id}), 201 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__": if __name__ == "__main__":
import threading import threading
def keepalive(): def keepalive():