Spaces:
Running
Running
Upload 2 files
Browse files- Dockerfile +8 -0
- receiver.py +90 -0
Dockerfile
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
RUN useradd --create-home --uid 1000 appuser
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
COPY --chown=appuser:appuser receiver.py /app/receiver.py
|
| 5 |
+
USER appuser
|
| 6 |
+
ENV PYTHONUNBUFFERED=1
|
| 7 |
+
EXPOSE 7860
|
| 8 |
+
CMD ["python", "receiver.py"]
|
receiver.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Temporary webhook payload inspector. No Telegram/LLM calls are made."""
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import secrets
|
| 5 |
+
import sqlite3
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from urllib.parse import urlsplit
|
| 10 |
+
|
| 11 |
+
MAX_BYTES = 1024 * 1024
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def make_server(host="0.0.0.0", port=7860, *, secret, db_path):
|
| 15 |
+
if len(secret) < 24 or not secret.isascii() or not all(c.isalnum() or c in "-_" for c in secret):
|
| 16 |
+
raise ValueError("WEBHOOK_SECRET must contain at least 24 ASCII letters, digits, '-' or '_'")
|
| 17 |
+
path = Path(db_path)
|
| 18 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 19 |
+
with sqlite3.connect(path) as db:
|
| 20 |
+
db.execute("CREATE TABLE IF NOT EXISTS samples (id INTEGER PRIMARY KEY, received_at TEXT, content_type TEXT, body TEXT)")
|
| 21 |
+
|
| 22 |
+
class Handler(BaseHTTPRequestHandler):
|
| 23 |
+
def log_message(self, *_args):
|
| 24 |
+
pass # Request paths contain the secret. Never log them.
|
| 25 |
+
|
| 26 |
+
def reply(self, code, payload):
|
| 27 |
+
raw = json.dumps(payload, ensure_ascii=False, indent=2).encode()
|
| 28 |
+
self.send_response(code)
|
| 29 |
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
| 30 |
+
self.send_header("Content-Length", str(len(raw)))
|
| 31 |
+
self.send_header("Cache-Control", "no-store")
|
| 32 |
+
self.send_header("X-Content-Type-Options", "nosniff")
|
| 33 |
+
self.end_headers()
|
| 34 |
+
self.wfile.write(raw)
|
| 35 |
+
|
| 36 |
+
def authorized(self, prefix):
|
| 37 |
+
return secrets.compare_digest(urlsplit(self.path).path.encode("utf-8"), (prefix + secret).encode("utf-8"))
|
| 38 |
+
|
| 39 |
+
def do_GET(self):
|
| 40 |
+
if urlsplit(self.path).path in ("/", "/health"):
|
| 41 |
+
return self.reply(200, {"status": "receiver_ready", "mode": "payload_inspection_only"})
|
| 42 |
+
if not self.authorized("/samples/"):
|
| 43 |
+
return self.reply(404, {"error": "not_found"})
|
| 44 |
+
with sqlite3.connect(path, timeout=10) as db:
|
| 45 |
+
rows = db.execute("SELECT id, received_at, content_type, body FROM samples ORDER BY id DESC LIMIT 3").fetchall()
|
| 46 |
+
result = []
|
| 47 |
+
for sample_id, received_at, content_type, body in rows:
|
| 48 |
+
try:
|
| 49 |
+
payload = json.loads(body)
|
| 50 |
+
except ValueError:
|
| 51 |
+
payload = body
|
| 52 |
+
result.append({"id": sample_id, "received_at": received_at, "content_type": content_type, "payload": payload})
|
| 53 |
+
return self.reply(200, {"count": len(result), "samples": result})
|
| 54 |
+
|
| 55 |
+
def do_POST(self):
|
| 56 |
+
if not self.authorized("/medialogia/"):
|
| 57 |
+
return self.reply(404, {"error": "not_found"})
|
| 58 |
+
try:
|
| 59 |
+
size = int(self.headers["Content-Length"])
|
| 60 |
+
except (ValueError, TypeError):
|
| 61 |
+
return self.reply(411, {"error": "content_length_required"})
|
| 62 |
+
if size < 0 or size > MAX_BYTES:
|
| 63 |
+
return self.reply(413, {"error": "payload_too_large"})
|
| 64 |
+
self.connection.settimeout(20)
|
| 65 |
+
try:
|
| 66 |
+
raw = self.rfile.read(size)
|
| 67 |
+
except (OSError, TimeoutError):
|
| 68 |
+
return self.reply(408, {"error": "request_timeout"})
|
| 69 |
+
if len(raw) != size:
|
| 70 |
+
return self.reply(400, {"error": "incomplete_body"})
|
| 71 |
+
try:
|
| 72 |
+
body = raw.decode("utf-8")
|
| 73 |
+
except UnicodeDecodeError:
|
| 74 |
+
return self.reply(400, {"error": "expected_utf8"})
|
| 75 |
+
try:
|
| 76 |
+
with sqlite3.connect(path, timeout=10) as db:
|
| 77 |
+
db.execute("INSERT INTO samples(received_at,content_type,body) VALUES(?,?,?)", (datetime.now(timezone.utc).isoformat(), self.headers.get("Content-Type", ""), body))
|
| 78 |
+
db.execute("DELETE FROM samples WHERE id NOT IN (SELECT id FROM samples ORDER BY id DESC LIMIT 20)")
|
| 79 |
+
except sqlite3.Error:
|
| 80 |
+
return self.reply(503, {"error": "storage_unavailable"})
|
| 81 |
+
print("Webhook sample saved", flush=True)
|
| 82 |
+
return self.reply(200, {"ok": True, "mode": "payload_inspection_only"})
|
| 83 |
+
|
| 84 |
+
return ThreadingHTTPServer((host, port), Handler)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
if __name__ == "__main__":
|
| 88 |
+
server = make_server(port=int(os.getenv("PORT", "7860")), secret=os.getenv("WEBHOOK_SECRET", ""), db_path=os.getenv("PROBE_DB_PATH", "/tmp/webhook-probe/samples.sqlite3"))
|
| 89 |
+
print("Receiver ready on port 7860; inspection only; local samples are temporary", flush=True)
|
| 90 |
+
server.serve_forever()
|