368 lines
14 KiB
Python
368 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Fehler-Melde-Server fuer brettspiel-news.de
|
|
Mini-HTTP-Server auf Port 8888. Nur das Formular + API.
|
|
Isoliert von allen anderen Diensten. Kein Flask, nur stdlib."""
|
|
|
|
import http.server
|
|
import json
|
|
import sqlite3
|
|
import os
|
|
import urllib.parse
|
|
import urllib.request
|
|
from datetime import datetime
|
|
|
|
PORT = 8888
|
|
HOST = "127.0.0.1"
|
|
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fehler.db")
|
|
|
|
TOKEN = os.environ.get("FEHLER_TOKEN", "br3ttsp1el-f3hl3r-2026")
|
|
|
|
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
|
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
|
|
|
|
# ═══════════════════════════════════════════════════
|
|
# SQLite
|
|
# ═══════════════════════════════════════════════════
|
|
|
|
def init_db():
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS fehler (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
article_id TEXT NOT NULL,
|
|
melder_name TEXT NOT NULL,
|
|
melder_email TEXT DEFAULT '',
|
|
fehler_text TEXT NOT NULL,
|
|
status TEXT DEFAULT 'offen',
|
|
loesung TEXT DEFAULT '',
|
|
created_at TEXT DEFAULT (datetime('now','localtime')),
|
|
updated_at TEXT DEFAULT (datetime('now','localtime'))
|
|
)
|
|
""")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
def save_report(article_id, name, email, text):
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.execute(
|
|
"INSERT INTO fehler (article_id, melder_name, melder_email, fehler_text) VALUES (?,?,?,?)",
|
|
(str(article_id), name, email, text)
|
|
)
|
|
rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
conn.commit()
|
|
conn.close()
|
|
return rid
|
|
|
|
def get_stats():
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
total = conn.execute("SELECT COUNT(*) as c FROM fehler").fetchone()["c"]
|
|
offen = conn.execute("SELECT COUNT(*) as c FROM fehler WHERE status='offen'").fetchone()["c"]
|
|
behoben = conn.execute("SELECT COUNT(*) as c FROM fehler WHERE status='behoben'").fetchone()["c"]
|
|
verworfen = conn.execute("SELECT COUNT(*) as c FROM fehler WHERE status='verworfen'").fetchone()["c"]
|
|
conn.close()
|
|
return {"total": total, "offen": offen, "behoben": behoben, "verworfen": verworfen}
|
|
|
|
def get_meldungen(status_filter=None, limit=100):
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
if status_filter:
|
|
rows = conn.execute(
|
|
"SELECT * FROM fehler WHERE status=? ORDER BY created_at DESC LIMIT ?",
|
|
(status_filter, limit)
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute(
|
|
"SELECT * FROM fehler ORDER BY created_at DESC LIMIT ?",
|
|
(limit,)
|
|
).fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
def update_status(rid, status, loesung):
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.execute(
|
|
"UPDATE fehler SET status=?, loesung=?, updated_at=datetime('now','localtime') WHERE id=?",
|
|
(status, loesung, rid)
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
# ═══════════════════════════════════════════════════
|
|
# Basti-Benachrichtigung (Datei-basiert)
|
|
# ═══════════════════════════════════════════════════
|
|
|
|
FEHLER_EINGANG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "eingang")
|
|
|
|
def notify_basti(report_id, article_id, name, text):
|
|
"""Schreibt Fehlermeldung als .md-Datei für Basti."""
|
|
os.makedirs(FEHLER_EINGANG, exist_ok=True)
|
|
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
fname = f"{ts}_{report_id}.md"
|
|
fpath = os.path.join(FEHLER_EINGANG, fname)
|
|
|
|
url = f"https://www.brettspiel-news.de/index.php/de/component/content/article/{article_id}"
|
|
|
|
content = f"""# 🐛 Fehlermeldung #{report_id}
|
|
|
|
**Artikel:** [#{article_id}]({url})
|
|
**Gemeldet von:** {name}
|
|
**Datum:** {datetime.now().strftime('%d.%m.%Y %H:%M')}
|
|
|
|
## Fehlerbeschreibung
|
|
|
|
{text}
|
|
|
|
---
|
|
*Status: offen | Bearbeiter: Basti*
|
|
"""
|
|
with open(fpath, "w") as f:
|
|
f.write(content)
|
|
|
|
print(f"[OK] Basti benachrichtigt → {fname}")
|
|
return fpath
|
|
|
|
# ═══════════════════════════════════════════════════
|
|
# HTML-Formular (inline, self-contained)
|
|
# ═══════════════════════════════════════════════════
|
|
|
|
FORM_HTML = r"""<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Fehler melden</title>
|
|
<style>
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{
|
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
background:transparent;
|
|
color:#1F2937;
|
|
font-size:14px;
|
|
padding:10px;
|
|
max-width:500px;
|
|
margin:0 auto;
|
|
}
|
|
.box{
|
|
background:#fff;
|
|
border:1px solid #e5e7eb;
|
|
border-radius:8px;
|
|
padding:16px;
|
|
}
|
|
h3{font-size:1rem;color:#20228a;margin-bottom:2px}
|
|
.sub{font-size:.8rem;color:#6b7280;margin-bottom:12px}
|
|
.fg{margin-bottom:10px}
|
|
.fg label{display:block;font-size:.8rem;font-weight:600;margin-bottom:3px;color:#4b5563}
|
|
.fg .req::after{content:" *";color:#e74c3c}
|
|
.fg input,.fg textarea{
|
|
width:100%;padding:8px 10px;border:1px solid #e5e7eb;
|
|
border-radius:6px;font-size:.9rem;font-family:inherit
|
|
}
|
|
.fg textarea{min-height:70px;resize:vertical}
|
|
button{
|
|
width:100%;padding:10px;background:#20228a;color:#fff;
|
|
border:none;border-radius:6px;font-size:.95rem;font-weight:600;
|
|
cursor:pointer;transition:background .2s
|
|
}
|
|
button:hover{background:#1a1c70}
|
|
button:disabled{background:#9ca3af;cursor:not-allowed}
|
|
.err{background:#fef2f2;border:1px solid #fca5a5;border-radius:6px;padding:8px;color:#991b1b;font-size:.85rem;margin-bottom:10px;display:none}
|
|
.ok{background:#ecfdf5;border:1px solid #6ee7b7;border-radius:6px;padding:12px;text-align:center;color:#065f46}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="app"></div>
|
|
<script>
|
|
var params=new URLSearchParams(window.location.search);
|
|
var articleId=params.get('article_id')||'';
|
|
var API=window.location.origin;
|
|
|
|
function render(){
|
|
document.getElementById('app').innerHTML=
|
|
'<div class=box>'+
|
|
'<h3>\u{1f41b} Fehler gefunden?</h3>'+
|
|
'<p class=sub>Wir prüfen jede Meldung. Danke!</p>'+
|
|
'<div id=form-container>'+
|
|
'<div class=err id=err-msg></div>'+
|
|
'<div class=fg><label class=req>Dein Name</label><input id=name placeholder="Max Mustermann" maxlength=100></div>'+
|
|
'<div class=fg><label>E-Mail (für Rückfragen)</label><input id=email type=email placeholder="max@example.com" maxlength=200></div>'+
|
|
'<div class=fg><label class=req>Was ist falsch?</label><textarea id=fehler placeholder="Beschreibe den Fehler möglichst genau…" maxlength=2000></textarea></div>'+
|
|
'<button id=btn onclick=submitReport()>Fehler melden</button>'+
|
|
'</div>'+
|
|
'<div id=ok-container style=display:none></div>'+
|
|
'</div>';
|
|
setTimeout(notifyHeight,100);
|
|
}
|
|
async function submitReport(){
|
|
var btn=document.getElementById('btn');
|
|
var err=document.getElementById('err-msg');
|
|
var name=document.getElementById('name').value.trim();
|
|
var email=document.getElementById('email').value.trim();
|
|
var fehler=document.getElementById('fehler').value.trim();
|
|
err.style.display='none';
|
|
if(!name){err.textContent='Bitte gib deinen Namen an.';err.style.display='block';return}
|
|
if(!fehler){err.textContent='Bitte beschreibe den Fehler.';err.style.display='block';return}
|
|
btn.disabled=true;btn.textContent='Wird gesendet\u2026';
|
|
try{
|
|
var res=await fetch(API+'/report',{
|
|
method:'POST',
|
|
headers:{'Content-Type':'application/json'},
|
|
body:JSON.stringify({article_id:articleId,melder_name:name,melder_email:email,fehler_text:fehler})
|
|
});
|
|
var data=await res.json();
|
|
if(res.ok){
|
|
document.getElementById('form-container').style.display='none';
|
|
var ok=document.getElementById('ok-container');
|
|
ok.style.display='block';
|
|
ok.innerHTML='<div class=ok><strong>\\u2705 Vielen Dank!</strong><br>Deine Meldung #'+data.id+' wurde gespeichert.<br>Wir kümmern uns darum.</div>';
|
|
}else{
|
|
err.textContent=data.error||'Fehler beim Senden.';
|
|
err.style.display='block';
|
|
}
|
|
}catch(e){
|
|
err.textContent='Netzwerkfehler. Bitte versuche es später erneut.';
|
|
err.style.display='block';
|
|
}finally{
|
|
btn.disabled=false;btn.textContent='Fehler melden';
|
|
}
|
|
setTimeout(notifyHeight,200);
|
|
}
|
|
function notifyHeight(){
|
|
var h=document.body.scrollHeight;
|
|
window.parent.postMessage({type:'fehler-form-height',height:h},'*');
|
|
}
|
|
window.addEventListener('load',render);
|
|
new ResizeObserver(notifyHeight).observe(document.body);
|
|
</script>
|
|
</body>
|
|
</html>"""
|
|
|
|
# ═══════════════════════════════════════════════════
|
|
# JSON-Helfer
|
|
# ═══════════════════════════════════════════════════
|
|
|
|
def json_response(handler, status, data):
|
|
body = json.dumps(data, ensure_ascii=False).encode()
|
|
handler.send_response(status)
|
|
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
|
handler.send_header("Access-Control-Allow-Origin", "*")
|
|
handler.send_header("Content-Length", str(len(body)))
|
|
handler.end_headers()
|
|
handler.wfile.write(body)
|
|
|
|
# ═══════════════════════════════════════════════════
|
|
# HTTP-Handler
|
|
# ═══════════════════════════════════════════════════
|
|
|
|
class FehlerHandler(http.server.BaseHTTPRequestHandler):
|
|
|
|
def do_GET(self):
|
|
p = urllib.parse.urlparse(self.path)
|
|
|
|
# Formular: / oder /form
|
|
if p.path in ("/", "/form"):
|
|
html = FORM_HTML.encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Content-Length", str(len(html)))
|
|
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
self.send_header("Pragma", "no-cache")
|
|
self.send_header("Expires", "0")
|
|
self.end_headers()
|
|
self.wfile.write(html)
|
|
return
|
|
|
|
# Stats (nur localhost)
|
|
if p.path == "/api/stats":
|
|
if self.client_address[0] not in ("127.0.0.1", "::1"):
|
|
json_response(self, 403, {"error": "forbidden"})
|
|
return
|
|
json_response(self, 200, get_stats())
|
|
return
|
|
|
|
# Meldungen (nur localhost)
|
|
if p.path == "/api/meldungen":
|
|
if self.client_address[0] not in ("127.0.0.1", "::1"):
|
|
json_response(self, 403, {"error": "forbidden"})
|
|
return
|
|
qs = urllib.parse.parse_qs(p.query)
|
|
status = qs.get("status", [None])[0]
|
|
json_response(self, 200, get_meldungen(status))
|
|
return
|
|
|
|
self.send_error(404)
|
|
|
|
def do_POST(self):
|
|
# CORS-Preflight
|
|
if self.path == "/report":
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
if length == 0:
|
|
json_response(self, 400, {"error": "Leerer Request"})
|
|
return
|
|
|
|
body = self.rfile.read(length)
|
|
try:
|
|
data = json.loads(body)
|
|
except json.JSONDecodeError:
|
|
json_response(self, 400, {"error": "Ungültiges JSON"})
|
|
return
|
|
|
|
article_id = data.get("article_id", "")
|
|
melder_name = data.get("melder_name", "").strip()
|
|
melder_email = data.get("melder_email", "").strip()
|
|
fehler_text = data.get("fehler_text", "").strip()
|
|
|
|
if not melder_name:
|
|
json_response(self, 400, {"error": "Name ist erforderlich"})
|
|
return
|
|
if not fehler_text:
|
|
json_response(self, 400, {"error": "Fehlerbeschreibung ist erforderlich"})
|
|
return
|
|
|
|
rid = save_report(article_id, melder_name, melder_email, fehler_text)
|
|
notify_basti(rid, article_id, melder_name, fehler_text)
|
|
|
|
json_response(self, 201, {"id": rid, "message": "Fehler gemeldet \u2014 danke!"})
|
|
return
|
|
|
|
# Status-Update (mit Token)
|
|
if self.path == "/api/update":
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
data = json.loads(self.rfile.read(length))
|
|
|
|
if data.get("token") != TOKEN:
|
|
json_response(self, 403, {"error": "forbidden"})
|
|
return
|
|
|
|
update_status(data["id"], data["status"], data.get("loesung", ""))
|
|
json_response(self, 200, {"ok": True})
|
|
return
|
|
|
|
self.send_error(404)
|
|
|
|
def do_OPTIONS(self):
|
|
self.send_response(204)
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|
self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
|
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
self.end_headers()
|
|
|
|
def log_message(self, format, *args):
|
|
print(f"[{datetime.now().strftime('%H:%M:%S')}] {args[0]}")
|
|
|
|
# ═══════════════════════════════════════════════════
|
|
# Main
|
|
# ═══════════════════════════════════════════════════
|
|
|
|
if __name__ == "__main__":
|
|
init_db()
|
|
print(f"🐛 Fehler-Server auf http://{HOST}:{PORT}")
|
|
print(f" Formular: http://{HOST}:{PORT}/?article_id=123")
|
|
print(f" Stats: http://{HOST}:{PORT}/api/stats")
|
|
httpd = http.server.HTTPServer((HOST, PORT), FehlerHandler)
|
|
try:
|
|
httpd.serve_forever()
|
|
except KeyboardInterrupt:
|
|
print("\n👋 Bye!")
|
|
httpd.server_close()
|