from __future__ import annotations import asyncio import base64 import hashlib import hmac import json import time from collections import OrderedDict from dataclasses import dataclass, field LINE_SIGNATURE_HEADER = "x-line-signature" LINE_WEBHOOK_MAX_BODY_BYTES = 64 * 1024 _REPLAY_WINDOW_SECONDS = 600 _REPLAY_CACHE_MAX_ENTRIES = 4096 class RetryableError(Exception): pass class ReplayDetectedError(RetryableError): pass class WebhookBodyTooLargeError(Exception): pass class WebhookParseError(Exception): pass class ConcurrentRequestRejectedError(Exception): pass def validate_line_signature(raw_body: bytes, signature: str, channel_secret: str) -> bool: if not signature or not channel_secret: return False if len(raw_body) > LINE_WEBHOOK_MAX_BODY_BYTES: return False try: computed = hmac.new( key=channel_secret.encode("utf-8"), msg=raw_body if isinstance(raw_body, bytes) else raw_body.encode("utf-8"), digestmod=hashlib.sha256, ).digest() computed_b64 = base64.b64encode(computed).decode("utf-8") return hmac.compare_digest(computed_b64, signature) except Exception: return False def parse_webhook_body(raw_body: bytes) -> list[dict] | None: try: body_str = raw_body.decode("utf-8") if isinstance(raw_body, bytes) else raw_body data = json.loads(body_str) return data.get("events", []) except (json.JSONDecodeError, UnicodeDecodeError): return None @dataclass class WebhookReplayGuard: _window_seconds: float = _REPLAY_WINDOW_SECONDS _max_entries: int = _REPLAY_CACHE_MAX_ENTRIES _seen_hashes: OrderedDict = field(default_factory=OrderedDict) def check_and_claim(self, signature_hash: str) -> None: now = time.time() cutoff = now - self._window_seconds expired = [k for k, ts in self._seen_hashes.items() if ts < cutoff] for k in expired: self._seen_hashes.pop(k, None) if signature_hash in self._seen_hashes: raise ReplayDetectedError(f"Replay detected for signature {signature_hash[:16]}...") self._seen_hashes[signature_hash] = now while len(self._seen_hashes) > self._max_entries: self._seen_hashes.popitem(last=False) def clear(self) -> None: self._seen_hashes.clear() @dataclass class MultiAccountSignatureRouter: _secrets: OrderedDict = field(default_factory=OrderedDict) def register_account(self, account_id: str, channel_secret: str) -> None: self._secrets[account_id] = channel_secret def unregister_account(self, account_id: str) -> None: self._secrets.pop(account_id, None) def list_accounts(self) -> list[str]: return list(self._secrets.keys()) def match_signature(self, raw_body: bytes, signature: str) -> str | None: for account_id, secret in self._secrets.items(): if validate_line_signature(raw_body, signature, secret): return account_id return None def clear(self) -> None: self._secrets.clear() @dataclass class WebhookConcurrencyGuard: _max_concurrent: int = 1 _locks: dict[str, asyncio.Lock] = field(default_factory=dict) _in_flight: dict[str, int] = field(default_factory=dict) async def acquire(self, path: str) -> bool: if path not in self._locks: self._locks[path] = asyncio.Lock() self._in_flight[path] = 0 if self._in_flight[path] >= self._max_concurrent: return False self._in_flight[path] += 1 await self._locks[path].acquire() return True def release(self, path: str) -> None: if path in self._locks: lock = self._locks[path] if lock.locked(): lock.release() self._in_flight[path] = max(0, self._in_flight.get(path, 1) - 1) def in_flight_count(self, path: str | None = None) -> int: if path: return self._in_flight.get(path, 0) return sum(self._in_flight.values()) def clear(self) -> None: self._locks.clear() self._in_flight.clear()