user-profiles: Cross-Channel-Identität mit Registrierung, Login, Profil + WhatsApp/Telegram-Flow
This commit is contained in:
@@ -1120,6 +1120,61 @@ def whatsapp_webhook():
|
||||
pass
|
||||
continue
|
||||
|
||||
# ── Nutzer-Registrierung per WhatsApp ────────────────
|
||||
pending_codes = getattr(app, '_pending_codes', {})
|
||||
if msg_type == "text":
|
||||
clean = content.strip().lower()
|
||||
# "registrieren" → Code senden
|
||||
if clean == "registrieren":
|
||||
import random
|
||||
code = str(random.randint(100000, 999999))
|
||||
pending_codes[sender_id] = code
|
||||
app._pending_codes = pending_codes
|
||||
if META_TOKEN:
|
||||
try:
|
||||
send_whatsapp_message(sender_id,
|
||||
f"🔐 Dein Verifikationscode: *{code}*\n\n"
|
||||
f"Antworte einfach mit: code {code}")
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
# "code 123456" → verifizieren + User anlegen
|
||||
if clean.startswith("code ") and len(clean) >= 10:
|
||||
entered = clean[5:].strip()
|
||||
if sender_id in pending_codes and pending_codes[sender_id] == entered:
|
||||
del pending_codes[sender_id]
|
||||
app._pending_codes = pending_codes
|
||||
# User anlegen
|
||||
existing = db.execute(
|
||||
"SELECT id FROM users WHERE phone=?", (sender_id,)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
db.execute(
|
||||
"INSERT INTO users (phone, name, verified) VALUES (?,?,1)",
|
||||
(sender_id, sender_name),
|
||||
)
|
||||
db.commit()
|
||||
if META_TOKEN:
|
||||
try:
|
||||
send_whatsapp_message(sender_id,
|
||||
"✅ Du bist jetzt registriert! Dein Profil: "
|
||||
"https://chat.datenhimmel.work/profile")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
if META_TOKEN:
|
||||
try:
|
||||
send_whatsapp_message(sender_id,
|
||||
"❌ Falscher Code. Schick \"registrieren\" für einen neuen.")
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
# ── Cross-Channel: User-Profil zuordnen ──────────────
|
||||
user_row = db.execute(
|
||||
"SELECT id, name FROM users WHERE phone=?", (sender_id,)
|
||||
).fetchone()
|
||||
|
||||
# Check for existing thread from this sender (5-min window)
|
||||
recent = db.execute(
|
||||
"""SELECT id, content, media_path, halle FROM submissions
|
||||
@@ -1186,9 +1241,10 @@ def whatsapp_webhook():
|
||||
cat = category_override or None
|
||||
db.execute(
|
||||
"""INSERT INTO submissions
|
||||
(channel, sender_id, sender_name, type, content, media_path, status, halle, category, spiel_titel)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'new', ?, ?, ?)""",
|
||||
("whatsapp", sender_id, sender_name, msg_type, content, media_path, halle, cat, spiel_titel),
|
||||
(channel, sender_id, sender_name, type, content, media_path, status, halle, category, spiel_titel, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'new', ?, ?, ?, ?)""",
|
||||
("whatsapp", sender_id, sender_name, msg_type, content, media_path, halle, cat, spiel_titel,
|
||||
user_row["id"] if user_row else None),
|
||||
)
|
||||
# Auto-mark matching neuheiten as sold out
|
||||
if cat == "ausverkauft" and spiel_titel:
|
||||
@@ -2296,6 +2352,194 @@ def submission_delete(sub_id: int):
|
||||
</html>"""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Nutzerprofil-System (Cross-Channel Identity)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
import hashlib, secrets
|
||||
|
||||
def _hash_password(password: str) -> str:
|
||||
"""SHA-256 mit Salt."""
|
||||
salt = secrets.token_hex(16)
|
||||
h = hashlib.sha256((salt + password).encode()).hexdigest()
|
||||
return f"{salt}${h}"
|
||||
|
||||
def _check_password(password: str, stored: str) -> bool:
|
||||
"""Vergleicht Passwort mit gespeichertem Hash."""
|
||||
try:
|
||||
salt, h = stored.split("$", 1)
|
||||
return hashlib.sha256((salt + password).encode()).hexdigest() == h
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _create_session(db, user_id: int, channel: str = "web") -> str:
|
||||
"""Erstellt Session-Token (gültig 30 Tage)."""
|
||||
token = secrets.token_urlsafe(32)
|
||||
db.execute(
|
||||
"INSERT INTO user_sessions (user_id, token, channel, expires_at) VALUES (?,?,?,datetime('now','+30 days'))",
|
||||
(user_id, token, channel),
|
||||
)
|
||||
db.commit()
|
||||
return token
|
||||
|
||||
def _get_user_from_session(db) -> dict | None:
|
||||
"""Liest User aus Session-Cookie."""
|
||||
token = request.cookies.get("bsn_session", "")
|
||||
if not token:
|
||||
return None
|
||||
row = db.execute(
|
||||
"SELECT u.* FROM users u JOIN user_sessions s ON u.id=s.user_id "
|
||||
"WHERE s.token=? AND s.expires_at > datetime('now')",
|
||||
(token,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
@app.route("/register", methods=["GET", "POST"])
|
||||
def user_register():
|
||||
db = get_db()
|
||||
error = ""
|
||||
if request.method == "POST":
|
||||
phone = request.form.get("phone", "").strip()
|
||||
name = request.form.get("name", "").strip()
|
||||
password = request.form.get("password", "").strip()
|
||||
if not phone or not name or len(password) < 4:
|
||||
error = "Bitte Telefon, Name und Passwort (min. 4 Zeichen) angeben."
|
||||
elif db.execute("SELECT id FROM users WHERE phone=?", (phone,)).fetchone():
|
||||
error = "Diese Telefonnummer ist bereits registriert."
|
||||
else:
|
||||
pw_hash = _hash_password(password)
|
||||
db.execute(
|
||||
"INSERT INTO users (phone, name, password_hash, verified) VALUES (?,?,?,1)",
|
||||
(phone, name, pw_hash),
|
||||
)
|
||||
db.commit()
|
||||
user_id = db.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
token = _create_session(db, user_id)
|
||||
resp = make_response(redirect("/profile"))
|
||||
resp.set_cookie("bsn_session", token, max_age=30*86400, httponly=True)
|
||||
return resp
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="de"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Registrieren — BSN Community</title>
|
||||
<style>
|
||||
body{{font-family:-apple-system,sans-serif;background:#1a1a2e;color:#eee;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0}}
|
||||
.card{{background:#16213e;border-radius:16px;padding:2rem;width:100%;max-width:400px;box-shadow:0 8px 32px #00000040}}
|
||||
h1{{color:#20228a;margin:0 0 1rem;font-size:1.4rem;text-align:center}}
|
||||
input{{width:100%;padding:0.8rem;margin:0.4rem 0;border-radius:8px;border:1px solid #333;background:#0f3460;color:#fff;box-sizing:border-box;font-size:1rem}}
|
||||
.btn{{width:100%;padding:0.8rem;background:#20228a;color:#fff;border:none;border-radius:8px;font-size:1rem;cursor:pointer;margin-top:0.5rem}}
|
||||
.btn:hover{{background:#2a2caa}}
|
||||
.error{{color:#ff453a;font-size:0.85rem;margin:0.5rem 0}}
|
||||
.link{{text-align:center;margin-top:1rem;font-size:0.85rem}}
|
||||
.link a{{color:#5e5ce6}}
|
||||
</style></head><body>
|
||||
<div class="card">
|
||||
<h1>📋 Registrieren</h1>
|
||||
{"<div class='error'>{}</div>".format(error) if error else ""}
|
||||
<form method="post">
|
||||
<input name="phone" placeholder="Telefon (z.B. +49152...)" value="{{}}" required>
|
||||
<input name="name" placeholder="Dein Name" required>
|
||||
<input name="password" type="password" placeholder="Passwort (min. 4 Zeichen)" required>
|
||||
<button class="btn" type="submit">Registrieren</button>
|
||||
</form>
|
||||
<div class="link">Bereits registriert? <a href="/login">Einloggen</a></div>
|
||||
</div></body></html>""".format(request.form.get("phone",""))
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def user_login():
|
||||
db = get_db()
|
||||
error = ""
|
||||
if request.method == "POST":
|
||||
phone = request.form.get("phone", "").strip()
|
||||
password = request.form.get("password", "").strip()
|
||||
user = db.execute("SELECT * FROM users WHERE phone=?", (phone,)).fetchone()
|
||||
if not user or not _check_password(password, user["password_hash"] or ""):
|
||||
error = "Telefon oder Passwort falsch."
|
||||
else:
|
||||
token = _create_session(db, user["id"])
|
||||
resp = make_response(redirect("/profile"))
|
||||
resp.set_cookie("bsn_session", token, max_age=30*86400, httponly=True)
|
||||
return resp
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="de"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login — BSN Community</title>
|
||||
<style>
|
||||
body{{font-family:-apple-system,sans-serif;background:#1a1a2e;color:#eee;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0}}
|
||||
.card{{background:#16213e;border-radius:16px;padding:2rem;width:100%;max-width:400px;box-shadow:0 8px 32px #00000040}}
|
||||
h1{{color:#20228a;margin:0 0 1rem;font-size:1.4rem;text-align:center}}
|
||||
input{{width:100%;padding:0.8rem;margin:0.4rem 0;border-radius:8px;border:1px solid #333;background:#0f3460;color:#fff;box-sizing:border-box;font-size:1rem}}
|
||||
.btn{{width:100%;padding:0.8rem;background:#20228a;color:#fff;border:none;border-radius:8px;font-size:1rem;cursor:pointer;margin-top:0.5rem}}
|
||||
.btn:hover{{background:#2a2caa}}
|
||||
.error{{color:#ff453a;font-size:0.85rem;margin:0.5rem 0}}
|
||||
.link{{text-align:center;margin-top:1rem;font-size:0.85rem}}
|
||||
.link a{{color:#5e5ce6}}
|
||||
</style></head><body>
|
||||
<div class="card">
|
||||
<h1>🔐 Login</h1>
|
||||
{"<div class='error'>{}</div>".format(error) if error else ""}
|
||||
<form method="post">
|
||||
<input name="phone" placeholder="Telefon" value="{{}}" required>
|
||||
<input name="password" type="password" placeholder="Passwort" required>
|
||||
<button class="btn" type="submit">Einloggen</button>
|
||||
</form>
|
||||
<div class="link">Neu hier? <a href="/register">Registrieren</a></div>
|
||||
</div></body></html>""".format(request.form.get("phone",""))
|
||||
|
||||
@app.route("/logout")
|
||||
def user_logout():
|
||||
token = request.cookies.get("bsn_session", "")
|
||||
if token:
|
||||
db = get_db()
|
||||
db.execute("DELETE FROM user_sessions WHERE token=?", (token,))
|
||||
db.commit()
|
||||
resp = make_response(redirect("/"))
|
||||
resp.set_cookie("bsn_session", "", max_age=0)
|
||||
return resp
|
||||
|
||||
@app.route("/profile", methods=["GET", "POST"])
|
||||
def user_profile():
|
||||
db = get_db()
|
||||
user = _get_user_from_session(db)
|
||||
if not user:
|
||||
return redirect("/login")
|
||||
msg = ""
|
||||
if request.method == "POST":
|
||||
name = request.form.get("name", "").strip()
|
||||
if name:
|
||||
db.execute("UPDATE users SET name=? WHERE id=?", (name, user["id"]))
|
||||
db.commit()
|
||||
msg = "✅ Profil aktualisiert."
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="de"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Profil — BSN Community</title>
|
||||
<style>
|
||||
body{{font-family:-apple-system,sans-serif;background:#1a1a2e;color:#eee;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0}}
|
||||
.card{{background:#16213e;border-radius:16px;padding:2rem;width:100%;max-width:400px;box-shadow:0 8px 32px #00000040}}
|
||||
h1{{color:#20228a;margin:0 0 1rem;font-size:1.4rem}}
|
||||
.field{{margin:0.8rem 0}}
|
||||
.label{{color:#888;font-size:0.8rem;text-transform:uppercase}}
|
||||
.value{{font-size:1.1rem}}
|
||||
input{{width:100%;padding:0.6rem;margin:0.3rem 0;border-radius:6px;border:1px solid #333;background:#0f3460;color:#fff;box-sizing:border-box}}
|
||||
.btn{{padding:0.6rem 1.2rem;background:#20228a;color:#fff;border:none;border-radius:6px;cursor:pointer}}
|
||||
.btn:hover{{background:#2a2caa}}
|
||||
.msg{{color:#30d158;margin:0.5rem 0}}
|
||||
.nav{{margin-top:1rem;display:flex;gap:0.5rem}}
|
||||
</style></head><body>
|
||||
<div class="card">
|
||||
<h1>👤 Mein Profil</h1>
|
||||
{"<div class='msg'>{}</div>".format(msg) if msg else ""}
|
||||
<div class="field"><div class="label">Telefon</div><div class="value">{user['phone']}</div></div>
|
||||
<form method="post">
|
||||
<div class="field"><div class="label">Name</div>
|
||||
<input name="name" value="{user['name'] or ''}" placeholder="Dein Name"></div>
|
||||
<button class="btn" type="submit">Speichern</button>
|
||||
</form>
|
||||
<div class="nav">
|
||||
<a href="/" style="color:#5e5ce6">← Startseite</a>
|
||||
<a href="/logout" style="color:#ff453a">Abmelden</a>
|
||||
</div>
|
||||
</div></body></html>"""
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def public_frontend():
|
||||
"""Public frontend showing published submissions — SPIEL Essen inspired."""
|
||||
@@ -3268,6 +3512,9 @@ def public_frontend():
|
||||
<ul class="offcanvas-nav-links">
|
||||
<li class="nav-section">Navigation</li>
|
||||
<li><a href="/">Startseite</a></li>
|
||||
<li class="nav-section">Community</li>
|
||||
<li><a href="/register">📋 Registrieren</a></li>
|
||||
<li><a href="/login">🔐 Login</a></li>
|
||||
<li class="nav-section">Rechtliches</li>
|
||||
<li><a href="/impressum">Impressum</a></li>
|
||||
<li><a href="/datenschutz">Datenschutzerklärung</a></li>
|
||||
@@ -3982,9 +4229,10 @@ def api_submit():
|
||||
|
||||
db.execute(
|
||||
"""INSERT INTO submissions
|
||||
(channel, sender_id, sender_name, type, content, media_path, status, halle, category, spiel_titel, email, phone)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'new', ?, ?, ?, ?, ?)""",
|
||||
('web', sender_id, sender_name, msg_type, text or None, media_path, halle_valid, category, spiel_titel or None, email or None, phone or None),
|
||||
(channel, sender_id, sender_name, type, content, media_path, status, halle, category, spiel_titel, email, phone, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'new', ?, ?, ?, ?, ?, ?)""",
|
||||
('web', sender_id, sender_name, msg_type, text or None, media_path, halle_valid, category, spiel_titel or None, email or None, phone or None,
|
||||
(_get_user_from_session(db) or {}).get('id')),
|
||||
)
|
||||
db.commit()
|
||||
sub_id = db.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
|
||||
+22
@@ -39,6 +39,28 @@ CREATE INDEX IF NOT EXISTS idx_submissions_channel ON submissions(channel);
|
||||
CREATE INDEX IF NOT EXISTS idx_submissions_category ON submissions(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_key ON knowledge(key);
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_category ON knowledge(category);
|
||||
|
||||
-- Cross-channel user profiles (phone = universal ID)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
phone TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
password_hash TEXT,
|
||||
profile_pic TEXT,
|
||||
verified INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
channel TEXT DEFAULT 'web',
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_phone ON users(phone);
|
||||
"""
|
||||
|
||||
SAMPLE_SUBMISSIONS = [
|
||||
|
||||
+89
-3
@@ -194,6 +194,91 @@ def telegram_webhook():
|
||||
spiel_titel = None
|
||||
category_override = None
|
||||
|
||||
# ── Nutzer-Registrierung per Telegram ──────────────────────────
|
||||
pending_codes = getattr(app, '_tg_pending_codes', {})
|
||||
if msg_type == "text":
|
||||
clean = content.strip().lower()
|
||||
if clean == "registrieren":
|
||||
import random
|
||||
code = str(random.randint(100000, 999999))
|
||||
pending_codes[sender_id] = code
|
||||
app._tg_pending_codes = pending_codes
|
||||
if TELEGRAM_TOKEN:
|
||||
import requests as _r
|
||||
try:
|
||||
_r.post(
|
||||
"{}/sendMessage".format(TELEGRAM_API),
|
||||
json={"chat_id": int(sender_id),
|
||||
"text": "🔐 Dein Verifikationscode: *{}*\n\n"
|
||||
"Antworte einfach mit: code {}".format(code, code)},
|
||||
timeout=10,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return "OK", 200
|
||||
if clean.startswith("code ") and len(clean) >= 10:
|
||||
entered = clean[5:].strip()
|
||||
if sender_id in pending_codes and pending_codes[sender_id] == entered:
|
||||
del pending_codes[sender_id]
|
||||
app._tg_pending_codes = pending_codes
|
||||
if TELEGRAM_TOKEN:
|
||||
import requests as _r
|
||||
try:
|
||||
_r.post(
|
||||
"{}/sendMessage".format(TELEGRAM_API),
|
||||
json={"chat_id": int(sender_id),
|
||||
"text": "✅ Du bist verifiziert! Welche Telefonnummer hast du? "
|
||||
"Antworte einfach mit: phone +49152..."},
|
||||
timeout=10,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
if TELEGRAM_TOKEN:
|
||||
import requests as _r
|
||||
try:
|
||||
_r.post(
|
||||
"{}/sendMessage".format(TELEGRAM_API),
|
||||
json={"chat_id": int(sender_id),
|
||||
"text": "❌ Falscher Code. Schick \"registrieren\" für einen neuen."},
|
||||
timeout=10,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return "OK", 200
|
||||
# "phone +49..." → Telefonnummer verknüpfen
|
||||
if clean.startswith("phone ") or clean.startswith("+49"):
|
||||
phone = clean[6:].strip() if clean.startswith("phone ") else clean.strip()
|
||||
# Normalize: remove spaces
|
||||
phone = phone.replace(" ", "").replace("-", "")
|
||||
existing = db.execute("SELECT id FROM users WHERE phone=?", (phone,)).fetchone()
|
||||
if existing:
|
||||
db.execute("UPDATE users SET name=? WHERE phone=?",
|
||||
(sender_name, phone))
|
||||
else:
|
||||
db.execute("INSERT INTO users (phone, name, verified) VALUES (?,?,1)",
|
||||
(phone, sender_name))
|
||||
db.commit()
|
||||
if TELEGRAM_TOKEN:
|
||||
import requests as _r
|
||||
try:
|
||||
_r.post(
|
||||
"{}/sendMessage".format(TELEGRAM_API),
|
||||
json={"chat_id": int(sender_id),
|
||||
"text": "✅ Deine Telefonnummer ist jetzt verknüpft! "
|
||||
"Dein Profil: https://chat.datenhimmel.work/profile"},
|
||||
timeout=10,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return "OK", 200
|
||||
|
||||
# ── Cross-Channel: User ueber Telegram-ID + phone verknuepfung ─
|
||||
user_row = db.execute(
|
||||
"SELECT id, name FROM users WHERE phone=?",
|
||||
(sender_id,), # Telegram-ID als Fallback
|
||||
).fetchone()
|
||||
|
||||
if "#out" in content.lower():
|
||||
category_override = "ausverkauft"
|
||||
idx = content.lower().find("#out")
|
||||
@@ -256,9 +341,10 @@ def telegram_webhook():
|
||||
cat = category_override or None
|
||||
db.execute(
|
||||
"""INSERT INTO submissions
|
||||
(channel, sender_id, sender_name, type, content, media_path, status, halle, category, spiel_titel)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'new', ?, ?, ?)""",
|
||||
("telegram", sender_id, sender_name, msg_type, content, media_path, halle, cat, spiel_titel),
|
||||
(channel, sender_id, sender_name, type, content, media_path, status, halle, category, spiel_titel, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'new', ?, ?, ?, ?)""",
|
||||
("telegram", sender_id, sender_name, msg_type, content, media_path, halle, cat, spiel_titel,
|
||||
user_row["id"] if user_row else None),
|
||||
)
|
||||
if cat == "ausverkauft" and spiel_titel:
|
||||
mark_ausverkauft_in_neuheiten(db, spiel_titel)
|
||||
|
||||
Reference in New Issue
Block a user