All checks were successful
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Successful in 3m22s
181 lines
7.8 KiB
Python
181 lines
7.8 KiB
Python
import asyncio
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
from aiohttp import web
|
|
|
|
from notifiers.base import ImageAttachment
|
|
from notifiers.factory import build_notifier
|
|
from .delivery_store import DeliveryStore
|
|
|
|
LOG = logging.getLogger("notify-gateway")
|
|
MAX_IMAGES = 4
|
|
MAX_IMAGE_BYTES = 50_000_000
|
|
MAX_TOTAL_IMAGE_BYTES = 50_000_000
|
|
|
|
|
|
def parse_images(raw: object) -> list[ImageAttachment]:
|
|
if raw in (None, []):
|
|
return []
|
|
if not isinstance(raw, list) or len(raw) > MAX_IMAGES:
|
|
raise web.HTTPBadRequest(reason="images must be a list with at most 4 items")
|
|
images: list[ImageAttachment] = []
|
|
total_bytes = 0
|
|
for item in raw:
|
|
if not isinstance(item, dict):
|
|
raise web.HTTPBadRequest(reason="invalid image item")
|
|
content_type = str(item.get("content_type") or "")
|
|
if content_type not in {"image/jpeg", "image/png"}:
|
|
raise web.HTTPBadRequest(reason="unsupported image content type")
|
|
try:
|
|
data = base64.b64decode(str(item.get("data_base64") or ""), validate=True)
|
|
except Exception:
|
|
raise web.HTTPBadRequest(reason="invalid image base64")
|
|
magic_ok = (
|
|
content_type == "image/jpeg" and data.startswith(bytes.fromhex("ffd8ff"))
|
|
) or (
|
|
content_type == "image/png"
|
|
and data.startswith(bytes.fromhex("89504e470d0a1a0a"))
|
|
)
|
|
if not magic_ok or not data or len(data) > MAX_IMAGE_BYTES:
|
|
raise web.HTTPBadRequest(reason="invalid image data or size")
|
|
total_bytes += len(data)
|
|
if total_bytes > MAX_TOTAL_IMAGE_BYTES:
|
|
raise web.HTTPBadRequest(reason="total image size exceeds 50 MB")
|
|
filename = Path(str(item.get("filename") or "image")).name
|
|
images.append(ImageAttachment(filename, content_type, data))
|
|
return images
|
|
|
|
|
|
class Gateway:
|
|
def __init__(self, cfg: dict):
|
|
self.cfg = cfg
|
|
self.expected = os.getenv("NOTIFY_GATEWAY_TOKEN", "") or str(cfg.get("api_token", ""))
|
|
self.channels_cfg = cfg.get("channels") or {}
|
|
self.notifiers = {}
|
|
self.telegram_bots = {}
|
|
for name, channel_cfg in self.channels_cfg.items():
|
|
if not channel_cfg.get("enabled"):
|
|
continue
|
|
if name == "telegram":
|
|
bots_cfg = channel_cfg.get("bots") or {}
|
|
if not isinstance(bots_cfg, dict) or not bots_cfg:
|
|
raise RuntimeError("Telegram bots are not configured")
|
|
self.telegram_bots = {
|
|
bot_name: build_notifier("telegram", bot_cfg)
|
|
for bot_name, bot_cfg in bots_cfg.items()
|
|
}
|
|
else:
|
|
self.notifiers[name] = build_notifier(name, channel_cfg)
|
|
self.store = DeliveryStore(str(cfg.get("state_db", "/app/data/deliveries.db")))
|
|
|
|
def _check(self, request: web.Request) -> None:
|
|
if not self.expected:
|
|
raise web.HTTPForbidden(reason="gateway token not configured")
|
|
auth = request.headers.get("Authorization", "")
|
|
if not hmac.compare_digest(auth, f"Bearer {self.expected}"):
|
|
raise web.HTTPUnauthorized()
|
|
|
|
async def health(self, request: web.Request):
|
|
channels = {
|
|
name: (bool(self.telegram_bots) if name == "telegram" else name in self.notifiers)
|
|
for name in self.channels_cfg
|
|
}
|
|
return web.json_response({
|
|
"ok": True,
|
|
"channels": channels,
|
|
"telegram_bots": sorted(self.telegram_bots),
|
|
})
|
|
|
|
async def send(self, request: web.Request):
|
|
self._check(request)
|
|
try:
|
|
data = await request.json()
|
|
except Exception:
|
|
raise web.HTTPBadRequest(reason="invalid json")
|
|
title = str(data.get("title", ""))
|
|
text = str(data.get("text", ""))
|
|
images = parse_images(data.get("images"))
|
|
wanted = data.get("channels")
|
|
if not text and not title and not images:
|
|
raise web.HTTPBadRequest(reason="empty message")
|
|
if not isinstance(wanted, list) or not wanted or not all(isinstance(x, str) and x for x in wanted):
|
|
raise web.HTTPBadRequest(reason="channels must explicitly select at least one channel")
|
|
if len(set(wanted)) != len(wanted):
|
|
raise web.HTTPBadRequest(reason="channels must not contain duplicates")
|
|
if len(title) > 500 or len(text) > 20000:
|
|
raise web.HTTPRequestEntityTooLarge(max_size=20500, actual_size=len(title) + len(text))
|
|
telegram_bot = data.get("telegram_bot")
|
|
if "telegram" in wanted:
|
|
if not isinstance(telegram_bot, str) or not telegram_bot.strip():
|
|
raise web.HTTPBadRequest(reason="telegram_bot is required for Telegram delivery")
|
|
telegram_bot = telegram_bot.strip()
|
|
if telegram_bot not in self.telegram_bots:
|
|
raise web.HTTPBadRequest(reason="unknown telegram_bot")
|
|
elif telegram_bot is not None:
|
|
raise web.HTTPBadRequest(reason="telegram_bot is only valid with the Telegram channel")
|
|
key = str(data.get("idempotency_key") or "").strip()
|
|
if not key:
|
|
digest_input = bytearray((title + "\0" + text).encode("utf-8"))
|
|
for image in images:
|
|
digest_input.extend(b"\0")
|
|
digest_input.extend(hashlib.sha256(image.data).digest())
|
|
digest = hashlib.sha256(digest_input).hexdigest()
|
|
key = f"content:{digest}"
|
|
if len(key) > 200:
|
|
raise web.HTTPBadRequest(reason="idempotency_key too long")
|
|
results: dict[str, str] = {}
|
|
for name in wanted:
|
|
notifier = self.telegram_bots.get(telegram_bot) if name == "telegram" else self.notifiers.get(name)
|
|
if not notifier:
|
|
results[name] = "disabled"
|
|
continue
|
|
delivery_target = f"telegram:{telegram_bot}" if name == "telegram" else name
|
|
if await self.store.delivered(key, delivery_target):
|
|
results[name] = "already_delivered"
|
|
continue
|
|
try:
|
|
await notifier.send(title, text, images)
|
|
await self.store.record(key, delivery_target, True)
|
|
results[name] = "ok"
|
|
except Exception as exc:
|
|
LOG.exception("gateway send failed channel=%s", name)
|
|
await self.store.record(key, delivery_target, False, repr(exc))
|
|
results[name] = "error"
|
|
ok = bool(results) and all(v in {"ok", "already_delivered"} for v in results.values())
|
|
return web.json_response({"ok": ok, "results": results})
|
|
|
|
async def run_app(self):
|
|
await self.store.init()
|
|
site = web.Application(client_max_size=70 * 1024 * 1024)
|
|
site.router.add_get("/health", self.health)
|
|
site.router.add_post("/send", self.send)
|
|
runner = web.AppRunner(site)
|
|
await runner.setup()
|
|
await web.TCPSite(runner, self.cfg.get("listen_host", "0.0.0.0"),
|
|
int(self.cfg.get("listen_port", 8770))).start()
|
|
LOG.info("notify-gateway ready channels=%s",
|
|
[k for k, v in (self.cfg.get("channels") or {}).items() if v.get("enabled")])
|
|
await asyncio.Event().wait()
|
|
|
|
|
|
def load_config():
|
|
with open(os.environ.get("NOTIFY_GATEWAY_CONFIG", "/app/config/config.yaml"),
|
|
encoding="utf-8") as f:
|
|
return yaml.safe_load(f)
|
|
|
|
|
|
def main():
|
|
Path("/app/logs").mkdir(parents=True, exist_ok=True)
|
|
logging.basicConfig(level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
|
# httpx logs full request URLs; Telegram and WeCom put credentials in URLs.
|
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
|
asyncio.run(Gateway(load_config()).run_app())
|