"""Signed Premsir webhook -> a fixed local sender; Python 3.10+, no model calls.""" import argparse import hashlib import hmac import json import os from pathlib import Path import re import sqlite3 import subprocess import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer def create_server(secret, state, send, port=8787): """send(event) must raise on failure. Bind behind an existing HTTPS proxy.""" if len(secret) < 32: raise ValueError("Set a notification secret of at least 32 characters") state = Path(state).expanduser() state.parent.mkdir(parents=True, exist_ok=True, mode=0o700) with sqlite3.connect(state) as db: db.execute("CREATE TABLE IF NOT EXISTS delivered (id TEXT PRIMARY KEY, at INTEGER)") os.chmod(state, 0o600) lock = threading.Lock() class Handler(BaseHTTPRequestHandler): def log_message(self, *args): pass # Never log webhook bodies, paths, signatures or credentials. def reply(self, status, result): payload = json.dumps({"status": result}).encode() self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(payload))) self.end_headers() self.wfile.write(payload) def do_POST(self): if self.path != "/notifications": return self.reply(404, "not_found") self.connection.settimeout(5) try: size = int(self.headers.get("Content-Length", "0")) if not 0 < size <= 65536: return self.reply(413, "invalid_size") raw = self.rfile.read(size) timestamp = self.headers.get("X-Premsir-Timestamp", "") if abs(time.time() - int(timestamp)) > 300: return self.reply(401, "expired") expected = "sha256=" + hmac.new(secret.encode(), timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, self.headers.get("X-Premsir-Signature", "")): return self.reply(401, "invalid_signature") event = json.loads(raw) if not isinstance(event, dict) or not isinstance(event.get("id"), str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", event["id"]): return self.reply(400, "invalid_event") if event.get("type") not in ("order.payment_settled", "chat.message", "notification.test") or not isinstance(event.get("text"), str) or not event["text"]: return self.reply(400, "invalid_event") except (ValueError, TypeError, UnicodeError, TimeoutError, OSError): return self.reply(400, "invalid_request") if not lock.acquire(blocking=False): return self.reply(503, "busy") try: with sqlite3.connect(state) as db: if db.execute("SELECT 1 FROM delivered WHERE id=?", (event["id"],)).fetchone(): return self.reply(200, "duplicate") send(event) db.execute("INSERT INTO delivered VALUES (?, ?)", (event["id"], int(time.time()))) db.execute("DELETE FROM delivered WHERE at < ?", (int(time.time()) - 7 * 86400,)) return self.reply(200, "accepted") except Exception: return self.reply(503, "delivery_failed") finally: lock.release() return ThreadingHTTPServer(("127.0.0.1", port), Handler) def command_sender(command): if not command: raise ValueError("A fixed sender command is required") def send(event): text = event["text"] if isinstance(event.get("url"), str) and event["url"] not in text: text += "\n" + event["url"] # Notification text is stdin, never shell source or command-line options. subprocess.run(command, input=text, text=True, check=True, timeout=7, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return send if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--port", type=int, default=8787) parser.add_argument("--state", required=True) parser.add_argument("command", nargs=argparse.REMAINDER) args = parser.parse_args() command = args.command[1:] if args.command[:1] == ["--"] else args.command server = create_server(os.environ.get("PREMSIR_NOTIFICATION_SECRET", ""), args.state, command_sender(command), args.port) try: server.serve_forever() except KeyboardInterrupt: pass finally: server.server_close()