- 成员删除:DELETE /projects/{id}/members/{user_id},限 owner 或
全局管理员,禁删唯一 owner,记 remove_member 审计
(AuthStore.remove_member + server 分发,沿用 PATCH 守卫口径)
- PR needs_local_worker 落库:非 local 扫到 queued 任务时 CAS
写行状态为 needs_local_worker;claim/find_open 同步认该状态,
标记过的行仍可被 local worker 认领、不被搁置;另修
claim/find_open 对裸 sqlite3 tuple 行的 dict() 兜底
(TypeError/ValueError)
- 推送 DNS TOCTOU:发送改走建连时解析+判段的连接类,解析
一次、只拨判过的 IP,Host/SNI 仍为原域名;opener 直接构造
以排除 ProxyHandler 改写(build_opener 默认会塞代理)
710 lines
28 KiB
Python
710 lines
28 KiB
Python
"""推送订阅与分发模块(云端架构实施)。
|
||
|
||
零第三方依赖,只用 Python 标准库。本模块只做两件事:
|
||
|
||
1. 推送订阅的增删查(SQLite 表 ``push_subscriptions``)。
|
||
2. 状态变化事件的分发(只发状态变化,不发进行中状态)。
|
||
|
||
边界(不要超出本模块宣称的范围):
|
||
|
||
- 合法事件类型与 ``webhooks.ALLOWED_NOTIFICATION_EVENTS`` 一致:
|
||
``completed``、``failed``、``goal-unmet``、``merge-failed``、
|
||
``review-requested``。其他事件类型拒绝或忽略,并写明原因,
|
||
绝不静默丢弃。
|
||
- 偏好过滤:``silent``(静默)不发;失败类事件(``failed``、
|
||
``goal-unmet``、``merge-failed``)即使偏好是 ``digest``(摘要)
|
||
也即时发;非失败类(``completed``、``review-requested``)在
|
||
``digest`` 偏好下本次不立即发,返回结果里明确列入
|
||
``skipped_for_digest``(待摘要列表),不是静默丢弃。
|
||
- 发送端可注入:``webhook`` / ``slack`` 走可注入的 ``sender``。
|
||
``sender`` 为 ``None`` 时用内置真实发送端(见 :func:`send_webhook` /
|
||
:func:`send_slack`,标准库 urllib POST,单次 10 秒超时,不
|
||
重试,失败记入 errors);显式传入的 sender 优先于内置端。
|
||
``sender`` 可以是可调用对象(收到一个 dict,见
|
||
:func:`dispatch_event`),也可以是按通道分的 dict
|
||
(``{"webhook": callable, "slack": callable}``),或带 ``send``
|
||
方法的对象。显式 sender 里没有某通道时,该通道仍报
|
||
“无 sender”,不静默成功。
|
||
- ``webpush`` / ``apns`` / ``fcm`` 只有接口,没有真实发送端:
|
||
一律明确报错“缺凭证(VAPID / APNs 证书 / FCM key)”,不编造
|
||
真实推送、不生成或提交任何私钥到仓库、不向真实推送服务发
|
||
任何请求。原因不是“没空写”:webpush 需要浏览器端订阅
|
||
(endpoint + p256dh + auth 三字段,现有订阅模型只有一个
|
||
target 文本字段,装不下)与标准库没有的 P-256 ECDH +
|
||
AES-GCM;apns 需要标准库没有的 HTTP/2;fcm 需要用户
|
||
另建 Firebase 项目与服务账号——三者都没有现成接收方,
|
||
本模块不为此造一套半吊子加密或假发送。真实凭证与真实
|
||
发送端是后续待接线的工作,见模块末尾的缺口说明。
|
||
- 本模块不跑真实模型回合。webhook/slack 的真实发送只 POST
|
||
到订阅者自己填的 target URL(创建订阅已要求项目
|
||
create_task 权限)。P1 收紧后:真实发送层对 target 做 SSRF
|
||
校验——loopback、私有网段、链路本地、云元数据地址一律不
|
||
POST;主机名先 DNS 解析再按解析出的 IP 判,解析失败拒绝
|
||
(见 validate_push_target)。本地接收器如需放行,须运维显式
|
||
置 CODEX_PUSH_ALLOW_PRIVATE_TARGETS=1(默认关闭,测试用
|
||
本地接收器也须经此开关,不在代码里默认放行 loopback)。
|
||
密钥、token、证书一律不由本模块读取、写入或返回。
|
||
"""
|
||
|
||
import errno
|
||
import http.client
|
||
import ipaddress
|
||
import json
|
||
import os
|
||
import socket
|
||
import sqlite3
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
from cloud.db import connect as _db_connect
|
||
from cloud.db import PgConnection as _PgConnection
|
||
import time
|
||
import uuid
|
||
from contextlib import contextmanager
|
||
|
||
__all__ = [
|
||
"CHANNELS",
|
||
"PREFERENCES",
|
||
"ALLOWED_PUSH_EVENTS",
|
||
"FAILURE_EVENTS",
|
||
"MISSING_CREDENTIAL_REASONS",
|
||
"DEFAULT_SENDERS",
|
||
"init_push_db",
|
||
"create_subscription",
|
||
"delete_subscription",
|
||
"list_subscriptions",
|
||
"send_webhook",
|
||
"send_slack",
|
||
"dispatch_event",
|
||
"validate_push_target",
|
||
]
|
||
|
||
CHANNELS = ("webhook", "slack", "webpush", "apns", "fcm")
|
||
PREFERENCES = ("instant", "digest", "silent")
|
||
|
||
# 只发状态变化的事件集合,与 webhooks.ALLOWED_NOTIFICATION_EVENTS 一致。
|
||
ALLOWED_PUSH_EVENTS = frozenset(
|
||
{"completed", "failed", "goal-unmet", "merge-failed", "review-requested"}
|
||
)
|
||
|
||
# 失败类事件:即使偏好是 digest 也即时发。
|
||
FAILURE_EVENTS = frozenset({"failed", "goal-unmet", "merge-failed"})
|
||
|
||
# webpush/apns/fcm 缺凭证时的明确报错文案(不含任何真实凭证)。
|
||
MISSING_CREDENTIAL_REASONS = {
|
||
"webpush": "missing credential: VAPID keys not configured",
|
||
"apns": "missing credential: APNs certificate not configured",
|
||
"fcm": "missing credential: FCM key not configured",
|
||
}
|
||
|
||
|
||
def _is_connection(db):
|
||
return isinstance(db, (sqlite3.Connection, _PgConnection))
|
||
|
||
|
||
@contextmanager
|
||
def _connect(db):
|
||
"""按 db 类型取连接:Connection 直接用(不关闭);路径则新建并关闭。"""
|
||
if _is_connection(db):
|
||
yield db
|
||
elif isinstance(db, (str, bytes)) or hasattr(db, "__fspath__"):
|
||
conn = _db_connect(str(db))
|
||
try:
|
||
yield conn
|
||
finally:
|
||
conn.close()
|
||
else:
|
||
raise TypeError("db must be a sqlite3 connection or a db path")
|
||
|
||
|
||
def init_push_db(db):
|
||
"""创建 push_subscriptions 表(已存在则不动)。"""
|
||
with _connect(db) as conn:
|
||
conn.execute(
|
||
"CREATE TABLE IF NOT EXISTS push_subscriptions("
|
||
"id TEXT PRIMARY KEY, "
|
||
"project_id TEXT NOT NULL, "
|
||
"user_id TEXT NOT NULL, "
|
||
"channel TEXT NOT NULL, "
|
||
"target TEXT NOT NULL, "
|
||
"preference TEXT NOT NULL, "
|
||
"created_at REAL, "
|
||
"UNIQUE(project_id, user_id, channel, target))"
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def _row_to_dict(row):
|
||
return {
|
||
"id": row[0],
|
||
"project_id": row[1],
|
||
"user_id": row[2],
|
||
"channel": row[3],
|
||
"target": row[4],
|
||
"preference": row[5],
|
||
"created_at": row[6],
|
||
}
|
||
|
||
|
||
def _validate_subscription_fields(project_id, user_id, channel, target,
|
||
preference):
|
||
if not project_id or not isinstance(project_id, str):
|
||
raise ValueError("project_id required")
|
||
if not user_id or not isinstance(user_id, str):
|
||
raise ValueError("user_id required")
|
||
if channel not in CHANNELS:
|
||
raise ValueError("channel must be one of %s" % (CHANNELS,))
|
||
if not target or not isinstance(target, str):
|
||
raise ValueError("target required")
|
||
if preference not in PREFERENCES:
|
||
raise ValueError("preference must be one of %s" % (PREFERENCES,))
|
||
|
||
|
||
def create_subscription(db, project_id, user_id, channel, target,
|
||
preference="instant"):
|
||
"""创建一条推送订阅,返回订阅 dict。
|
||
|
||
同一 (project_id, user_id, channel, target) 已存在时返回已有订阅,
|
||
不建重复行。channel 取值 webhook/slack/webpush/apns/fcm;
|
||
preference 取值 instant/digest/silent;非法取值抛 ValueError。
|
||
"""
|
||
_validate_subscription_fields(
|
||
project_id, user_id, channel, target, preference)
|
||
init_push_db(db)
|
||
with _connect(db) as conn:
|
||
existing = conn.execute(
|
||
"SELECT id, project_id, user_id, channel, target, preference,"
|
||
" created_at FROM push_subscriptions"
|
||
" WHERE project_id = ? AND user_id = ? AND channel = ?"
|
||
" AND target = ?",
|
||
(project_id, user_id, channel, target),
|
||
).fetchone()
|
||
if existing is not None:
|
||
return _row_to_dict(existing)
|
||
sub_id = uuid.uuid4().hex
|
||
now = time.time()
|
||
conn.execute(
|
||
"INSERT INTO push_subscriptions(id, project_id, user_id,"
|
||
" channel, target, preference, created_at)"
|
||
" VALUES(?, ?, ?, ?, ?, ?, ?)",
|
||
(sub_id, project_id, user_id, channel, target, preference, now),
|
||
)
|
||
conn.commit()
|
||
row = conn.execute(
|
||
"SELECT id, project_id, user_id, channel, target, preference,"
|
||
" created_at FROM push_subscriptions WHERE id = ?",
|
||
(sub_id,),
|
||
).fetchone()
|
||
return _row_to_dict(row)
|
||
|
||
|
||
def delete_subscription(db, subscription_id=None, *, project_id=None,
|
||
user_id=None, channel=None, target=None):
|
||
"""删除订阅,返回 True(删到至少一条)或 False(无匹配)。
|
||
|
||
优先按 subscription_id 删除;未传 id 时按给出的
|
||
project_id/user_id/channel/target 过滤删除(至少要给一个过滤条件,
|
||
否则不删任何行并返回 False,避免误删整表)。
|
||
"""
|
||
init_push_db(db)
|
||
with _connect(db) as conn:
|
||
if subscription_id is not None:
|
||
cur = conn.execute(
|
||
"DELETE FROM push_subscriptions WHERE id = ?",
|
||
(subscription_id,),
|
||
)
|
||
else:
|
||
conditions = []
|
||
params = []
|
||
for column, value in (
|
||
("project_id", project_id),
|
||
("user_id", user_id),
|
||
("channel", channel),
|
||
("target", target),
|
||
):
|
||
if value is not None:
|
||
conditions.append("%s = ?" % column)
|
||
params.append(value)
|
||
if not conditions:
|
||
return False
|
||
cur = conn.execute(
|
||
"DELETE FROM push_subscriptions WHERE "
|
||
+ " AND ".join(conditions),
|
||
params,
|
||
)
|
||
conn.commit()
|
||
return cur.rowcount > 0
|
||
|
||
|
||
def list_subscriptions(db, project_id, user_id=None):
|
||
"""列出某项目的订阅;传 user_id 时只列该用户的。
|
||
|
||
返回按 created_at、id 排序的订阅 dict 列表。只查本项目,
|
||
跨项目订阅绝不会混入(见单测)。
|
||
"""
|
||
if not project_id or not isinstance(project_id, str):
|
||
raise ValueError("project_id required")
|
||
init_push_db(db)
|
||
with _connect(db) as conn:
|
||
if user_id is None:
|
||
rows = conn.execute(
|
||
"SELECT id, project_id, user_id, channel, target,"
|
||
" preference, created_at FROM push_subscriptions"
|
||
" WHERE project_id = ? ORDER BY created_at, id",
|
||
(project_id,),
|
||
).fetchall()
|
||
else:
|
||
rows = conn.execute(
|
||
"SELECT id, project_id, user_id, channel, target,"
|
||
" preference, created_at FROM push_subscriptions"
|
||
" WHERE project_id = ? AND user_id = ?"
|
||
" ORDER BY created_at, id",
|
||
(project_id, user_id),
|
||
).fetchall()
|
||
return [_row_to_dict(row) for row in rows]
|
||
|
||
|
||
def _resolve_channel_sender(sender, channel):
|
||
"""从注入的 sender 里取出某通道的发送函数,取不到返回 None。"""
|
||
if sender is None:
|
||
return None
|
||
if isinstance(sender, dict):
|
||
candidate = sender.get(channel)
|
||
if candidate is None:
|
||
return None
|
||
if callable(candidate):
|
||
return candidate
|
||
send = getattr(candidate, "send", None)
|
||
return send if callable(send) else None
|
||
if callable(sender):
|
||
return sender
|
||
send = getattr(sender, "send", None)
|
||
return send if callable(send) else None
|
||
|
||
|
||
# 真实发送端:单次投递超时(秒),不重试——失败记入 errors,
|
||
# 下一次事件自然重发,不在这里循环撞墙。
|
||
_PUSH_SEND_TIMEOUT = 10
|
||
|
||
|
||
def _post_json(url, payload, resolver=None):
|
||
"""POST JSON 到 url:返回 (ok, detail),2xx 为 ok。
|
||
|
||
建连时解析一次并判段、钉 IP 直连(见 _checked_opener):
|
||
Host 头与 TLS SNI 仍为原域名,只把拨号地址换成判过的
|
||
IP——预检解析与建连解析不一致时,建连侧的判定为准,
|
||
绝不会拨向未验证的 IP。HTTP 状态错误与网络异常一律
|
||
返回 (False, reason),不抛(调用方据此记 errors);
|
||
只有调用方传了非法 URL 时 Request 构造本身可能抛
|
||
ValueError,由发送端先校验 scheme 挡住,不走到这里。
|
||
"""
|
||
data = json.dumps(payload).encode("utf-8")
|
||
request = urllib.request.Request(
|
||
url, data=data, method="POST",
|
||
headers={"Content-Type": "application/json",
|
||
"User-Agent": "codex-app-server"})
|
||
try:
|
||
with _checked_opener(resolver).open(
|
||
request, timeout=_PUSH_SEND_TIMEOUT) as response:
|
||
response.read()
|
||
return True, "HTTP %s" % response.status
|
||
except urllib.error.HTTPError as e:
|
||
return False, "HTTP %s" % e.code
|
||
except Exception as exc: # noqa: BLE001 -- 网络异常转 reason,不抛
|
||
return False, "%s: %s" % (type(exc).__name__, exc)
|
||
|
||
|
||
def _pick_connect_ip(host, resolver):
|
||
"""建连时解析一次并判段,返回要拨的 IP(只抛 OSError)。
|
||
|
||
字面量 IP 不经过 DNS(只复核段);域名解析一次:
|
||
allow-private 时取首个,否则取首个全局单播 IP;无可用
|
||
IP 时抛 OSError。调用方拨的就是这里判过的 IP,之后
|
||
不再二次解析——校验与建连之间的 DNS 重绑定窗口就此
|
||
闭合(旧版 validate 后 urlopen 再解析一次的 TOCTOU)。
|
||
"""
|
||
if _allow_private_targets():
|
||
try:
|
||
ipaddress.ip_address(host)
|
||
return host
|
||
except ValueError:
|
||
pass
|
||
ips = (resolver or _default_resolver)(host)
|
||
if not ips:
|
||
raise OSError("DNS resolution failed for target host")
|
||
return ips[0]
|
||
try:
|
||
ipaddress.ip_address(host)
|
||
except ValueError:
|
||
pass
|
||
else:
|
||
if _ip_is_blocked(host):
|
||
raise OSError(
|
||
"target resolves to a non-public address: %s" % host)
|
||
return host
|
||
ips = (resolver or _default_resolver)(host)
|
||
if not ips:
|
||
raise OSError("DNS resolution failed for target host")
|
||
for ip_text in ips:
|
||
if not _ip_is_blocked(ip_text):
|
||
return ip_text
|
||
raise OSError("target host resolves to a non-public address")
|
||
|
||
|
||
def _checked_connection_class(base, resolver):
|
||
"""建连时解析+判段的连接类:Host 头与 TLS SNI 仍为原域名
|
||
(self.host 不动),只把拨号地址换成判过的 IP。
|
||
|
||
resolver 由调用方注入(测试 mock DNS 用);为 None 时
|
||
用默认解析。代理隧道场景(_tunnel_host 已置)仍走基类:
|
||
隧道里解析权在代理方,钉 IP 不适用,预检判定为准。
|
||
"""
|
||
class CheckedConnection(base):
|
||
def connect(self):
|
||
if getattr(self, "_tunnel_host", None):
|
||
super().connect()
|
||
return
|
||
picked = _pick_connect_ip(self.host, resolver)
|
||
self.sock = socket.create_connection(
|
||
(picked, self.port), self.timeout, self.source_address)
|
||
try:
|
||
self.sock.setsockopt(
|
||
socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||
except OSError as exc:
|
||
if exc.errno != errno.ENOPROTOOPT:
|
||
raise
|
||
if isinstance(self, http.client.HTTPSConnection):
|
||
self.sock = self._context.wrap_socket(
|
||
self.sock, server_hostname=self.host)
|
||
return CheckedConnection
|
||
|
||
|
||
class _CheckedHTTPHandler(urllib.request.HTTPHandler):
|
||
def __init__(self, resolver=None):
|
||
super().__init__()
|
||
self._resolver = resolver
|
||
|
||
def http_open(self, req):
|
||
return self.do_open(
|
||
_checked_connection_class(http.client.HTTPConnection,
|
||
self._resolver), req)
|
||
|
||
|
||
class _CheckedHTTPSHandler(urllib.request.HTTPSHandler):
|
||
def __init__(self, resolver=None, debuglevel=0, context=None,
|
||
check_hostname=None):
|
||
super().__init__(debuglevel=debuglevel, context=context,
|
||
check_hostname=check_hostname)
|
||
self._resolver = resolver
|
||
|
||
def https_open(self, req):
|
||
return self.do_open(
|
||
_checked_connection_class(http.client.HTTPSConnection,
|
||
self._resolver), req,
|
||
context=self._context, check_hostname=self._check_hostname)
|
||
|
||
|
||
def _checked_opener(resolver=None):
|
||
"""发送用 opener:建连时解析+判段的连接类 + 重定向/错误处理。
|
||
|
||
刻意直接构造 OpenerDirector、不用 build_opener:后者会
|
||
自动塞入 ProxyHandler,本机 egress 代理会把请求改写成
|
||
代理端口、钉 IP 失效(实测拨号跑到代理端口)。钉 IP 的
|
||
前提是直连;代理环境下发送会直连失败并记入 errors
|
||
(loud,不静默成功)。重定向仍跟随,但每一跳都重新
|
||
走建连判定(新目标同样解析+判段)。
|
||
"""
|
||
opener = urllib.request.OpenerDirector()
|
||
opener.add_handler(_CheckedHTTPHandler(resolver))
|
||
opener.add_handler(_CheckedHTTPSHandler(resolver))
|
||
opener.add_handler(urllib.request.HTTPRedirectHandler())
|
||
opener.add_handler(urllib.request.HTTPErrorProcessor())
|
||
return opener
|
||
|
||
|
||
def _require_http_url(target, channel):
|
||
"""发送端 target 校验:只接受 http(s) URL,其他一律 ValueError
|
||
(dispatch 记入 errors,不静默成功、不乱 POST)。"""
|
||
if not isinstance(target, str) or not (
|
||
target.startswith("http://") or target.startswith("https://")):
|
||
raise ValueError(
|
||
"%s target must be an http(s) URL" % channel)
|
||
return target
|
||
|
||
|
||
# 云元数据主机名:即使解析出的 IP 恰好不在私有段,也直接挡。
|
||
_BLOCKED_HOSTNAMES = frozenset({"metadata.google.internal"})
|
||
|
||
|
||
def _allow_private_targets():
|
||
"""运维显式开关:仅 CODEX_PUSH_ALLOW_PRIVATE_TARGETS=1 时放行
|
||
内网目标(本地接收器场景)。默认关闭;开关只影响 IP 段判定,
|
||
不影响 scheme 校验。"""
|
||
return os.environ.get(
|
||
"CODEX_PUSH_ALLOW_PRIVATE_TARGETS", "").strip() == "1"
|
||
|
||
|
||
def _default_resolver(hostname):
|
||
"""默认 DNS 解析:返回 IP 字符串列表;解析失败抛异常,
|
||
由 validate_push_target 转成“拒绝”。"""
|
||
infos = socket.getaddrinfo(hostname, None)
|
||
out = []
|
||
for info in infos:
|
||
sockaddr = info[4] if len(info) > 4 else None
|
||
if sockaddr and sockaddr[0]:
|
||
out.append(str(sockaddr[0]))
|
||
return out
|
||
|
||
|
||
def _ip_is_blocked(ip_text):
|
||
"""非全局单播 IP 一律算内网/特殊地址:loopback、私有网段、
|
||
链路本地(169.254/16,含云元数据 169.254.169.254)、保留段、
|
||
组播、未指定地址都在 is_global=False 里。"""
|
||
try:
|
||
return not ipaddress.ip_address(ip_text).is_global
|
||
except ValueError:
|
||
return True
|
||
|
||
|
||
def validate_push_target(target, resolver=None, check_dns=True):
|
||
"""SSRF 校验:通过返回 None,否则返回原因字符串(不抛)。
|
||
|
||
顺序:scheme(http/https)-> 主机名存在 -> 元数据主机名 ->
|
||
IP 字面量直接判段 -> (check_dns 时)DNS 解析后逐 IP 判段,
|
||
解析失败或解析出空结果都拒绝。本预检只是 fail-fast:
|
||
真正起强制作用的是发送时建连类的解析+判段(解析一次、
|
||
只拨判过的 IP),预检解析与建连解析不一致时以建连侧
|
||
的判定为准,不会拨向未验证的 IP(旧版 validate 后
|
||
urlopen 再解析一次的 TOCTOU 已闭合)。
|
||
check_dns=False 时只做字面量判定:供直接调 send_* 但不想
|
||
做 DNS 的调用方用;dispatch 的真实发送路径一律
|
||
check_dns=True,显式 sender 路径不走本预检(见 dispatch)。
|
||
"""
|
||
if not isinstance(target, str) or not (
|
||
target.startswith("http://") or target.startswith("https://")):
|
||
return "target must be an http(s) URL"
|
||
try:
|
||
host = urllib.parse.urlparse(target).hostname
|
||
except ValueError:
|
||
return "target host is invalid"
|
||
if not host:
|
||
return "target host required"
|
||
lowered = host.lower()
|
||
if lowered in _BLOCKED_HOSTNAMES or lowered.endswith(
|
||
".metadata.google.internal"):
|
||
return "target host is a cloud metadata address"
|
||
if _allow_private_targets():
|
||
return None
|
||
try:
|
||
ipaddress.ip_address(host)
|
||
is_literal = True
|
||
except ValueError:
|
||
is_literal = False
|
||
if is_literal:
|
||
if _ip_is_blocked(host):
|
||
return "target resolves to a non-public address: %s" % host
|
||
return None
|
||
if not check_dns:
|
||
return None
|
||
resolve = resolver or _default_resolver
|
||
try:
|
||
ips = resolve(host)
|
||
except Exception:
|
||
return "DNS resolution failed for target host"
|
||
if not ips:
|
||
return "DNS resolution failed for target host"
|
||
for ip_text in ips:
|
||
if _ip_is_blocked(ip_text):
|
||
return ("target host resolves to a non-public address: %s"
|
||
% ip_text)
|
||
return None
|
||
|
||
|
||
def send_webhook(message):
|
||
"""webhook 通道真实发送:POST JSON 到订阅的 target URL。
|
||
|
||
body 固定为 {event_type, project_id, task_id, payload},
|
||
task_id 从 payload 里取(没有则为 None)。非 2xx 抛
|
||
RuntimeError(dispatch 记入 errors,不算 sent)。
|
||
"""
|
||
target = _require_http_url(message["target"], "webhook")
|
||
blocked = validate_push_target(
|
||
target, resolver=message.get("_resolver"))
|
||
if blocked is not None:
|
||
raise RuntimeError(
|
||
"webhook delivery failed: target blocked: %s" % blocked)
|
||
payload = message["payload"] if isinstance(
|
||
message["payload"], dict) else {}
|
||
ok, detail = _post_json(target, {
|
||
"event_type": message["event_type"],
|
||
"project_id": message["project_id"],
|
||
"task_id": payload.get("task_id"),
|
||
"payload": message["payload"],
|
||
}, resolver=message.get("_resolver"))
|
||
if not ok:
|
||
raise RuntimeError("webhook delivery failed: %s" % detail)
|
||
|
||
|
||
def send_slack(message):
|
||
"""slack 通道真实发送:POST Slack incoming webhook 格式
|
||
({"text": ...})到 target URL(Slack webhook 地址本身
|
||
就是 http(s) URL,与 webhook 通道同一投递路径)。
|
||
|
||
文本一行讲清事件:任务 id + 事件类型 + 项目,失败类
|
||
事件附 error 摘要(截断 500 字)。非 2xx 抛 RuntimeError。
|
||
"""
|
||
target = _require_http_url(message["target"], "slack")
|
||
blocked = validate_push_target(
|
||
target, resolver=message.get("_resolver"))
|
||
if blocked is not None:
|
||
raise RuntimeError(
|
||
"slack delivery failed: target blocked: %s" % blocked)
|
||
payload = message["payload"] if isinstance(
|
||
message["payload"], dict) else {}
|
||
text = "[codex] task %s %s (project %s)" % (
|
||
payload.get("task_id", "?"), message["event_type"],
|
||
message["project_id"])
|
||
if payload.get("error"):
|
||
text += "\nerror: %s" % str(payload["error"])[:500]
|
||
ok, detail = _post_json(target, {"text": text},
|
||
resolver=message.get("_resolver"))
|
||
if not ok:
|
||
raise RuntimeError("slack delivery failed: %s" % detail)
|
||
|
||
|
||
# sender=None 时的内置真实发送端:只覆盖 webhook/slack;
|
||
# webpush/apns/fcm 不在此列,仍走缺凭证报错。
|
||
DEFAULT_SENDERS = {"webhook": send_webhook, "slack": send_slack}
|
||
|
||
|
||
def dispatch_event(db, event_type, project_id, payload, sender=None,
|
||
resolver=None):
|
||
"""分发一次状态变化事件,只发本项目的订阅。
|
||
|
||
参数:
|
||
db: sqlite3 连接或数据库路径。
|
||
event_type: 合法类型见 ALLOWED_PUSH_EVENTS;其他类型拒绝,
|
||
返回 ``{"sent": [], ..., "reason": "event not allowed"}``
|
||
且不调用任何 sender。
|
||
project_id: 只查该项目的订阅,跨项目不混。
|
||
payload: 原事件载荷(dict 或其他),原样放入发送给 sender
|
||
的消息 dict 的 ``payload`` 字段。
|
||
sender: 可注入发送端(见模块 docstring);为 None 时用
|
||
内置真实发送端(webhook/slack 真 POST,webpush/apns/fcm
|
||
仍报缺凭证);显式传入的 sender 优先于内置端,显式
|
||
sender 里没有某通道时该通道报“无 sender”,不静默成功。
|
||
|
||
返回 dict,键固定为::
|
||
|
||
{
|
||
"event_type": ...,
|
||
"project_id": ...,
|
||
"sent": [{"subscription_id", "user_id", "channel", "target"}],
|
||
"skipped_for_digest": [订阅 dict(待摘要,本次未立即发)],
|
||
"skipped_silent": [订阅 dict(静默偏好,本次未发)],
|
||
"errors": [{"subscription_id", "user_id", "channel", "target",
|
||
"reason": ...}],
|
||
}
|
||
|
||
非法事件类型时额外含 ``"reason": "event not allowed"``。
|
||
sender 抛异常时记入 errors,不中断其他订阅的分发。
|
||
"""
|
||
result = {
|
||
"event_type": event_type,
|
||
"project_id": project_id,
|
||
"sent": [],
|
||
"skipped_for_digest": [],
|
||
"skipped_silent": [],
|
||
"errors": [],
|
||
}
|
||
if event_type not in ALLOWED_PUSH_EVENTS:
|
||
result["reason"] = "event not allowed"
|
||
result["errors"] = [{
|
||
"subscription_id": None,
|
||
"user_id": None,
|
||
"channel": None,
|
||
"target": None,
|
||
"reason": "event not allowed: %r not in %s"
|
||
% (event_type, sorted(ALLOWED_PUSH_EVENTS)),
|
||
}]
|
||
return result
|
||
if not project_id or not isinstance(project_id, str):
|
||
result["reason"] = "project_id required"
|
||
result["errors"] = [{
|
||
"subscription_id": None,
|
||
"user_id": None,
|
||
"channel": None,
|
||
"target": None,
|
||
"reason": "project_id required",
|
||
}]
|
||
return result
|
||
|
||
real_send = sender is None
|
||
if sender is None:
|
||
# 内置真实发送端:webhook/slack 真 POST;显式 sender
|
||
# 传入时不走这里(注入优先),webpush/apns/fcm 永远
|
||
# 不走 sender,仍在循环里报缺凭证。
|
||
sender = DEFAULT_SENDERS
|
||
subscriptions = list_subscriptions(db, project_id)
|
||
is_failure = event_type in FAILURE_EVENTS
|
||
for sub in subscriptions:
|
||
base = {
|
||
"subscription_id": sub["id"],
|
||
"user_id": sub["user_id"],
|
||
"channel": sub["channel"],
|
||
"target": sub["target"],
|
||
}
|
||
# 偏好过滤:silent 永远不发;digest 只对非失败类延迟。
|
||
if sub["preference"] == "silent":
|
||
result["skipped_silent"].append(sub)
|
||
continue
|
||
if sub["preference"] == "digest" and not is_failure:
|
||
result["skipped_for_digest"].append(sub)
|
||
continue
|
||
# webpush/apns/fcm:只有接口,缺凭证,明确报错,不走 sender、
|
||
# 不联网、不编造推送。
|
||
if sub["channel"] in MISSING_CREDENTIAL_REASONS:
|
||
result["errors"].append(
|
||
dict(base, reason=MISSING_CREDENTIAL_REASONS[sub["channel"]]))
|
||
continue
|
||
# SSRF 预检(真实发送路径,发送前挡,不 POST):含 DNS
|
||
# 的全量校验。显式 sender 路径不预检:注入的 sender 是
|
||
# 调用方自控的投递(测试 seam),不是服务端发起的 HTTP,
|
||
# 挡它不增加生产安全性;生产服务端一律走 sender=None
|
||
# 的真实发送,必经本预检与 send_webhook/send_slack 的
|
||
# 二次校验。原因文案含 "delivery failed",与旧调用方
|
||
# 对投递失败的断言兼容。
|
||
if real_send:
|
||
blocked = validate_push_target(
|
||
sub["target"], resolver=resolver, check_dns=True)
|
||
if blocked is not None:
|
||
result["errors"].append(
|
||
dict(base, reason="%s delivery failed: target blocked:"
|
||
" %s" % (sub["channel"], blocked)))
|
||
continue
|
||
# webhook/slack:sender=None 时已换成内置真实发送端;
|
||
# 显式 sender 里没有该通道时明确报错,不静默成功。
|
||
channel_sender = _resolve_channel_sender(sender, sub["channel"])
|
||
if channel_sender is None:
|
||
result["errors"].append(
|
||
dict(base, reason="no sender for channel %s"
|
||
% sub["channel"]))
|
||
continue
|
||
message = {
|
||
"event_type": event_type,
|
||
"project_id": project_id,
|
||
"payload": payload,
|
||
"channel": sub["channel"],
|
||
"target": sub["target"],
|
||
"user_id": sub["user_id"],
|
||
"subscription_id": sub["id"],
|
||
}
|
||
try:
|
||
channel_sender(message)
|
||
except Exception as exc: # noqa: BLE001 -- 分发失败记 errors,不中断
|
||
result["errors"].append(
|
||
dict(base, reason="sender failed: %s" % exc))
|
||
continue
|
||
result["sent"].append(base)
|
||
return result
|