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:
@@ -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():
|
||||||
|
|||||||
Reference in New Issue
Block a user