From 86d2840e417d9c642509ca4594b223cb57e9a62d Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 16:21:41 +0800 Subject: [PATCH 1/7] =?UTF-8?q?W3-d=20=E9=82=AE=E4=BB=B6=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E3=80=81=E5=BE=85=E5=8A=9E=E6=B1=87=E6=80=BB=E3=80=81=E8=AE=BF?= =?UTF-8?q?=E9=97=AE=E5=AF=86=E9=92=A5=E5=88=B0=E6=9C=9F=E6=8F=90=E9=86=92?= =?UTF-8?q?=EF=BC=88=E5=90=8E=E7=AB=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 邮件(04 细案 B2,集成-I4 / I5,改写应用平台 Q6): - notify/mail.py:标准库 smtplib 放进 asyncio.to_thread,单次超时 10 秒;4xx 和连不上按 1/2/4 秒重试,5xx 不重试; 报错只写主机名和服务器回话,口令一律抹掉;纯文本 + 转义过的 HTML,带 List-Unsubscribe 和 Auto-Submitted;测试可换假的 SMTP 工厂。模块说明写清改写 Q6 的理由:SMTP 是可选配置、退信不追踪照实说「已交给邮件服务器」 - 通知渠道多一种格式「邮件」(core.CHANNEL_FORMATS;定时任务的 Webhook 只会发 HTTP,FORMATS 不加,免得定时任务能选 邮件):个人邮件渠道不收地址,只发到名录里的账号邮箱,没有邮箱记一条失败投递并写明去「设置 → 身份」填;全局、应用的填 固定地址,最多 20 个;邮件服务没启用时不收新的邮件渠道;渠道列表另给 mail(启没启用、本人的账号邮箱),投递记录带格式 - 设置的 mail 一组(只有管理员能改):口令用 core/crypto 加密,读的时候只说配没配;启用时要配全;发件人名称、主机不许 换行;「发测试邮件」只发到管理员自己的账号邮箱,关登录、没有邮箱时 422 说清怎么办 - mail.default_for_assignees(缺省关):开了之后第一次派到待办的人自动有一个个人邮件渠道(订阅待办打开、动作结果未知、 审核有结果),本人删了不再建回来;关登录时不建 待办汇总与密钥到期(04 细案 B3): - notify/digest.py 接进调度循环的步骤列表:每天到 notify.digest_at(缺省 09:00,服务端时区)给订阅了 task.digest 的 个人渠道发汇总(条数、最早时限、最多 5 条节点名、去待办箱的链接,没有待办不发);按日期在 settings 表里插一行认领, 重启不重发;认领行和自动建邮件渠道的名单是通知自己记的账,设置接口不列不收 - key.expiring:到期前 7 天、1 天各推一次,个人密钥推本人的个人渠道、服务账号的推全局渠道,推之前记审计、按审计去重; 轮换下来的旧密钥、撤销了的、建的时候就在这一档里的不推 - 两个事件推送实现了,才在 notify/service.EVENTS 打开它们的订阅范围 设置的 notify 一组加 digest_at;只给一部分时补上存着的另几项;环境变量锁住对外访问地址时只核对那一项,汇总时间照常能改。 apps 一组加 share_links(分享链接的开关,缺省关,下一个提交用)。测试:假 SMTP 工厂下的收件人、失败投递、重试、口令、 设置与权限、自动建渠道;汇总按时、只发一次、重启不重发;密钥两档、谁收、不该推的几种。 --- backend/app/api/notify.py | 155 ++++++--- backend/app/api/settings.py | 108 +++++- backend/app/notify/__init__.py | 7 +- backend/app/notify/core.py | 8 +- backend/app/notify/digest.py | 185 ++++++++++ backend/app/notify/mail.py | 424 +++++++++++++++++++++++ backend/app/notify/service.py | 191 +++++++++- backend/app/scheduler/loop.py | 7 +- backend/tests/test_notify_digest.py | 251 ++++++++++++++ backend/tests/test_notify_events.py | 11 +- backend/tests/test_notify_mail.py | 421 ++++++++++++++++++++++ backend/tests/test_public_url.py | 5 +- backend/tests/test_settings_apps_auth.py | 15 +- backend/tests/test_settings_notify.py | 39 ++- 14 files changed, 1741 insertions(+), 86 deletions(-) create mode 100644 backend/app/notify/digest.py create mode 100644 backend/app/notify/mail.py create mode 100644 backend/tests/test_notify_digest.py create mode 100644 backend/tests/test_notify_mail.py diff --git a/backend/app/api/notify.py b/backend/app/api/notify.py index 8702528a..95e73ea4 100644 --- a/backend/app/api/notify.py +++ b/backend/app/api/notify.py @@ -7,6 +7,11 @@ 个人渠道谁都能建(权限最低的使用者也能),地址另过一遍 SSRF 检查(tools/net.py,不开内网),保存和发送时各一次: 不然服务端替它往内网地址发 POST,测试接口交回的状态码和报错就成了探测内网的信号(A6.2:只有登记的端点能访问内网)。 全局、应用的渠道只有搭建者以上能建,和定时任务的 Webhook 同一个信任级别,地址照旧只查格式(内网的接收端很常见)。 + +邮件(第五种格式,notify/mail.py,04 细案 B2):个人邮件渠道不收地址,只发到本人在名录里的账号邮箱(能填任意地址的话,个人 +渠道就成了谁都能用的邮件中继);全局、应用的邮件渠道填固定的收件地址,最多 20 个。邮件服务(设置的 mail 一组)没启用时 +不收新的邮件渠道:建了也发不出去,就是「看着生效、其实没生效」。渠道列表另给 mail:邮件服务启没启用、本人的账号邮箱, +设置页据此决定列不列「邮件」这一格式、个人邮件渠道写「发到账号邮箱 …」。 """ from __future__ import annotations @@ -20,7 +25,7 @@ from app.api.deps import current_principal from app.core.crypto import encrypt from app.db.base import SessionLocal from app.db.models import NotifyChannel, NotifyDelivery -from app.notify import core +from app.notify import core, mail from app.notify.service import EVENTS, channel_out, subscribe_problem router = APIRouter(prefix="/api/notify", tags=["notify"]) @@ -64,25 +69,72 @@ async def _safe_personal(scope: str, url: str) -> None: def _validated(payload: ChannelIn, *, url: str | None, saved: list[str] | None = None) -> None: """saved 是渠道已经订阅着的事件:只核对这次新勾的(notify/service.subscribe_problem)。以前存下的照旧——当时界面对 哪种渠道都列全部事件,现在才按事件推给哪几条路核对,不能让这些渠道连开关、改名都 422。""" - if payload.format not in core.FORMATS: - raise HTTPException(422, f"消息格式只能是{'、'.join(core.FORMAT_LABEL.values())}之一") + if payload.format not in core.CHANNEL_FORMATS: + raise HTTPException(422, f"消息格式只能是{'、'.join(core.FORMAT_LABEL[f] for f in core.CHANNEL_FORMATS)}之一") for event in dict.fromkeys(payload.events): if event not in (saved or ()) and (problem := subscribe_problem(event, payload.scope)): raise HTTPException(422, problem) - if url is not None and (problem := core.format_problem(payload.format, url)): + if url is not None and payload.format != "email" and (problem := core.format_problem(payload.format, url)): raise HTTPException(422, problem) +#: 个人邮件渠道带了地址:不收(见模块说明) +PERSONAL_MAIL_NO_URL = "个人邮件渠道只发到你的账号邮箱,不用填地址" + + +async def _mail_target(scope: str, url: str | None, *, saved: str | None = None) -> str: + """邮件渠道 url 列存什么(明文,调用方加密):个人渠道存占位,全局、应用的是校验过的收件地址(逗号分隔)。 + url 没给、原来就是邮件渠道的(saved)沿用原来的地址。邮件服务没启用时不收(见模块说明)。""" + from app.db.base import SessionLocal + + async with SessionLocal() as session: + cfg = await mail.load_config(session) + if not cfg.enabled: + raise HTTPException(422, mail.NOT_ENABLED) + if scope == "user": + if url and url.strip(): + raise HTTPException(422, PERSONAL_MAIL_NO_URL) + return mail.PERSONAL_PLACEHOLDER + if not (url and url.strip()): + if saved: + return saved + raise HTTPException(422, "请填写收件地址,多个地址用逗号或换行分隔") + try: + return ", ".join(mail.parse_recipients(url)) + except ValueError as e: + raise HTTPException(422, str(e)) from e + + +async def _owner_emails(rows: list[NotifyChannel]) -> dict[str, str | None]: + """个人邮件渠道的拥有者 → 账号邮箱(channel_out 写「发到账号邮箱 …」用)。""" + from app.db.models import User + + owners = {r.owner_id for r in rows if r.scope == "user" and r.format == "email" and r.owner_id} + if not owners: + return {} + async with SessionLocal() as session: + found = (await session.execute(select(User.id, User.email).where(User.id.in_(owners)))).all() + return {uid: email for uid, email in found} + + @router.get("/channels") async def list_channels() -> dict[str, Any]: - """{items}:形状同前端的接口约定(frontend/src/api/capabilities.ts)。""" + """{items, mail}:形状同前端的接口约定(frontend/src/api/capabilities.ts)。mail 是邮件服务启没启用、本人的账号邮箱 + (关登录时没有账号,为 null):设置页据此列不列「邮件」格式、个人邮件渠道发到哪。""" + from app.db.models import User + principal = current_principal() stmt = select(NotifyChannel).order_by(NotifyChannel.created_at) if principal.mode != "off": # 个人渠道只给本人看 stmt = stmt.where(or_(NotifyChannel.scope != "user", NotifyChannel.owner_id == principal.user_id)) async with SessionLocal() as session: - return {"items": [channel_out(r) for r in (await session.execute(stmt)).scalars()]} + rows = list((await session.execute(stmt)).scalars()) + cfg = await mail.load_config(session) + mine = await session.get(User, principal.user_id) if principal.mode != "off" and principal.user_id else None + emails = await _owner_emails(rows) + return {"items": [channel_out(r, emails.get(r.owner_id or "")) for r in rows], + "mail": {"enabled": cfg.enabled, "account_email": (mine.email or None) if mine is not None else None}} @router.get("/events") @@ -96,19 +148,25 @@ async def list_events() -> list[dict[str, Any]]: async def create_channel(payload: ChannelIn) -> dict[str, Any]: owner = current_principal().user_id if payload.scope == "user" else payload.owner_id _can_edit(payload.scope, owner) - try: - url = core.check_url(payload.url) - except ValueError as e: - raise HTTPException(422, str(e)) from e - _validated(payload, url=url) - await _safe_personal(payload.scope, url) + if payload.format == "email": + _validated(payload, url=None) + url = await _mail_target(payload.scope, payload.url) + secret = None # 邮件没有签名密钥 + else: + try: + url = core.check_url(payload.url) + except ValueError as e: + raise HTTPException(422, str(e)) from e + _validated(payload, url=url) + await _safe_personal(payload.scope, url) + secret = encrypt(payload.secret) if payload.secret else None row = NotifyChannel(name=payload.name.strip(), scope=payload.scope, owner_id=owner, format=payload.format, - url=encrypt(url), secret=encrypt(payload.secret) if payload.secret else None, - events=list(dict.fromkeys(payload.events)), enabled=payload.enabled) + url=encrypt(url), secret=secret, events=list(dict.fromkeys(payload.events)), + enabled=payload.enabled) async with SessionLocal() as session: session.add(row) await session.commit() - return channel_out(row) + return channel_out(row, (await _owner_emails([row])).get(row.owner_id or "")) class ChannelPatch(BaseModel): @@ -144,29 +202,40 @@ async def update_channel(channel_id: str, payload: ChannelIn) -> dict[str, Any]: _can_edit(row.scope, row.owner_id) if payload.scope != row.scope: raise HTTPException(422, "通知渠道的范围建好之后不能修改,请新建一个") - url = None - if payload.url: - try: - url = core.check_url(payload.url) - except ValueError as e: - raise HTTPException(422, str(e)) from e - if url is None: - from app.core.crypto import decrypt + from app.core.crypto import decrypt - current = decrypt(row.url) or "" - _validated(payload, url=current or None, saved=list(row.events or [])) + url = None + if payload.format == "email": + _validated(payload, url=None, saved=list(row.events or [])) + # 只开关、改名、改订阅(地址不传)的不重新核对邮件服务:停用了邮件服务,已有的渠道照样能关掉、改名 + if payload.url or row.format != "email": + saved = decrypt(row.url) if row.format == "email" and row.scope != "user" else None + row.url = encrypt(await _mail_target(row.scope, payload.url, saved=saved)) + row.secret = None else: - _validated(payload, url=url, saved=list(row.events or [])) - await _safe_personal(row.scope, url) - row.url = encrypt(url) + if payload.url: + try: + url = core.check_url(payload.url) + except ValueError as e: + raise HTTPException(422, str(e)) from e + elif row.format == "email": + # 邮件渠道换成 Webhook:原来存的是收件地址,不是能发的 Webhook 地址 + raise HTTPException(422, "换成 Webhook 格式要填写 Webhook 地址") + if url is None: + current = decrypt(row.url) or "" + _validated(payload, url=current or None, saved=list(row.events or [])) + else: + _validated(payload, url=url, saved=list(row.events or [])) + await _safe_personal(row.scope, url) + row.url = encrypt(url) + if payload.secret is not None: + row.secret = encrypt(payload.secret) if payload.secret else None row.name, row.format = payload.name.strip(), payload.format row.events, row.enabled = list(dict.fromkeys(payload.events)), payload.enabled - if payload.secret is not None: - row.secret = encrypt(payload.secret) if payload.secret else None if row.scope == "app": row.owner_id = payload.owner_id await session.commit() - return channel_out(row) + return channel_out(row, (await _owner_emails([row])).get(row.owner_id or "")) @router.delete("/channels/{channel_id}", status_code=204) @@ -191,7 +260,8 @@ async def test_channel(channel_id: str) -> dict[str, Any]: raise HTTPException(404, "通知渠道不存在,可能已被删除") _can_edit(row.scope, row.owner_id) message = {"event": "test", "title": f"通知渠道「{row.name}」测试", "ok": True, "retry": False, - "lines": [("说明", "这是一条测试消息。收到即表示渠道地址配置正确" + "lines": [("说明", ("这是一封测试邮件。收到即表示邮件服务和收件地址配置正确" if row.format == "email" else + "这是一条测试消息。收到即表示渠道地址配置正确") + (";请求头带有签名,接收端可据此核对签名密钥" if row.secret and row.format == "generic" else ""))]} outcome = await send(row, message) @@ -201,6 +271,10 @@ async def test_channel(channel_id: str) -> dict[str, Any]: attempts=outcome.attempts, error=outcome.error)) await session.commit() if outcome.ok: + if row.format == "email": + # 交给了邮件服务器不等于进了收件箱:退信不追踪,照实说 + return {"ok": True, "http_status": None, + "message": f"{outcome.error or mail.HANDED_OFF},是否送达以邮件服务器为准,请到收件箱确认"} return {"ok": True, "http_status": outcome.http_status, "message": "请到接收端确认已收到"} return {"ok": False, "http_status": outcome.http_status, "message": outcome.error or "发送失败"} @@ -212,12 +286,15 @@ async def list_deliveries( task_id: str | None = None, limit: int = Query(default=100, ge=1, le=500), ) -> dict[str, Any]: - """投递记录。开登录后 user 角色只看发往自己的个人渠道的:别的渠道的投递记录里有所有应用、所有运行的待办和动作。""" - stmt = select(NotifyDelivery).order_by(NotifyDelivery.created_at.desc()).limit(limit) + """投递记录。开登录后 user 角色只看发往自己的个人渠道的:别的渠道的投递记录里有所有应用、所有运行的待办和动作。 + + format 是渠道的格式:邮件渠道成功的那条界面上写「已交给邮件服务器」,不写「已送达」(退信不追踪)。""" + stmt = (select(NotifyDelivery, NotifyChannel.format) + .join(NotifyChannel, NotifyChannel.id == NotifyDelivery.channel_id) + .order_by(NotifyDelivery.created_at.desc()).limit(limit)) principal = current_principal() if principal.mode != "off" and not principal.at_least("builder"): - stmt = stmt.join(NotifyChannel, NotifyChannel.id == NotifyDelivery.channel_id).where( - NotifyChannel.scope == "user", NotifyChannel.owner_id == principal.user_id) + stmt = stmt.where(NotifyChannel.scope == "user", NotifyChannel.owner_id == principal.user_id) if channel_id: stmt = stmt.where(NotifyDelivery.channel_id == channel_id) if run_id: @@ -225,9 +302,9 @@ async def list_deliveries( if task_id: stmt = stmt.where(NotifyDelivery.task_id == task_id) async with SessionLocal() as session: - rows = (await session.execute(stmt)).scalars() + rows = (await session.execute(stmt)).all() return {"items": [{ "id": r.id, "channel_id": r.channel_id, "subject_kind": r.subject_kind, "subject_id": r.subject_id, "event": r.event, "run_id": r.run_id, "task_id": r.task_id, "status": r.status, - "http_status": r.http_status, "attempts": r.attempts, "error": r.error, - "created_at": r.created_at.isoformat() if r.created_at else None} for r in rows]} + "http_status": r.http_status, "attempts": r.attempts, "error": r.error, "format": fmt, + "created_at": r.created_at.isoformat() if r.created_at else None} for r, fmt in rows]} diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index c45a685a..c1f0fdaa 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -613,8 +613,9 @@ def catalog_draft_values(stored: Any) -> dict[str, Any]: #: 全站只认一个对外地址(core/public_url.py):环境变量 AGENTLAB_PUBLIC_URL 设了的话这一项不生效、不能改(PUT 回 409)。 #: 键名沿用 notify 一组,界面上在「设置 → 通用」 NOTIFY_GROUP = public_url.SETTING_GROUP -NOTIFY_DEFAULTS: dict[str, Any] = {public_url.SETTING_KEY: None} -NOTIFY_SETTING_LABEL = {public_url.SETTING_KEY: "对外访问地址"} +#: digest_at:每天几点给订阅了「每天的待办汇总」的个人渠道发汇总(HH:MM,按服务端所在时区;notify/digest.py,04 细案 B3) +NOTIFY_DEFAULTS: dict[str, Any] = {public_url.SETTING_KEY: None, "digest_at": "09:00"} +NOTIFY_SETTING_LABEL = {public_url.SETTING_KEY: "对外访问地址", "digest_at": "待办汇总的发送时间"} #: GET /api/settings 另给的只读一组:{value: 生效的对外访问地址, env_locked: 是不是环境变量指定的}。设置页据此显示 #: 「由环境变量指定」;PUT 带上它也不落库(同 limits) PUBLIC_URL_GROUP = "public_url" @@ -641,15 +642,25 @@ def notify_problem(value: Any) -> str | None: if key not in NOTIFY_DEFAULTS: return (f"「通知」设置中不支持「{key}」,可设置的项:" + "、".join(f"「{NOTIFY_SETTING_LABEL[k]}」" for k in NOTIFY_DEFAULTS)) - if problem := _base_url_problem(item): + if key == "digest_at": + from app.notify.digest import time_ok + + if not time_ok(item): + return f"「{NOTIFY_SETTING_LABEL[key]}」需要写成 时:分(00:00 到 23:59,例如 09:00),当前为「{item}」" + elif problem := _base_url_problem(item): return problem return None def notify_values(stored: Any) -> dict[str, Any]: - """库里存的(或刚写进来的)notify 一组 → 补齐默认、规范化:去掉首尾空白和末尾的 /,空串当没填;存坏了的按没填。""" + """库里存的(或刚写进来的)notify 一组 → 补齐默认、规范化:去掉首尾空白和末尾的 /,空串当没填;存坏了的按没填。 + 汇总时间存坏了按缺省。""" + from app.notify.digest import time_ok + stored = stored if isinstance(stored, dict) else {} - return {public_url.SETTING_KEY: public_url.normalize(stored.get(public_url.SETTING_KEY))} + at = stored.get("digest_at") + return {public_url.SETTING_KEY: public_url.normalize(stored.get(public_url.SETTING_KEY)), + "digest_at": at.strip() if time_ok(at) else NOTIFY_DEFAULTS["digest_at"]} async def notify_settings(session: AsyncSession) -> dict[str, Any]: @@ -676,8 +687,11 @@ def public_url_values(stored_notify: Any) -> dict[str, Any]: #: user_personal_apps:开了登录时使用者能不能建个人应用、刷新、提交审核(默认关,同问数据的开关;搭建者及以上不受它管)。 #: 关登录时它照样生效:关着就没有个人应用的入口。只有管理员能改(整个设置接口的改动都要管理员) APPS_GROUP = "apps" -APPS_DEFAULTS: dict[str, Any] = {"on_demand_daily_limit": 200, "user_personal_apps": False} -APPS_SETTING_LABEL = {"on_demand_daily_limit": "按需视图每日上限", "user_personal_apps": "使用者可以建个人应用"} +#: share_links:能不能建分享链接(只读、不用登录就能看的视图页,api/shares.py,04 细案 C2、集成-I6)。缺省关;关掉时已有的 +#: 分享链接一并打不开(管理员随时收得回来),撤销照常能做 +APPS_DEFAULTS: dict[str, Any] = {"on_demand_daily_limit": 200, "user_personal_apps": False, "share_links": False} +APPS_SETTING_LABEL = {"on_demand_daily_limit": "按需视图每日上限", "user_personal_apps": "使用者可以建个人应用", + "share_links": "允许建分享链接"} #: 上限的上限:再大就等于不限,要不限的不该靠这一项 APPS_DAILY_LIMIT_MAX = 100_000 @@ -694,7 +708,7 @@ def apps_problem(value: Any) -> str | None: if key not in APPS_DEFAULTS: return (f"「应用」设置中不支持「{key}」,可设置的项:" + "、".join(f"「{APPS_SETTING_LABEL[k]}」" for k in APPS_DEFAULTS)) - if key == "user_personal_apps": + if key in ("user_personal_apps", "share_links"): if not isinstance(item, bool): return f"「{APPS_SETTING_LABEL[key]}」只能是开或关,当前为「{item}」" elif not _daily_limit_ok(item): @@ -708,8 +722,10 @@ def apps_values(stored: Any) -> dict[str, Any]: stored = stored if isinstance(stored, dict) else {} limit = stored.get("on_demand_daily_limit") personal = stored.get("user_personal_apps") + shares = stored.get("share_links") return {"on_demand_daily_limit": limit if _daily_limit_ok(limit) else APPS_DEFAULTS["on_demand_daily_limit"], - "user_personal_apps": personal if isinstance(personal, bool) else APPS_DEFAULTS["user_personal_apps"]} + "user_personal_apps": personal if isinstance(personal, bool) else APPS_DEFAULTS["user_personal_apps"], + "share_links": shares if isinstance(shares, bool) else APPS_DEFAULTS["share_links"]} async def apps_settings(session: AsyncSession) -> dict[str, Any]: @@ -718,6 +734,15 @@ async def apps_settings(session: AsyncSession) -> dict[str, Any]: return apps_values(row.value if row else None) +# -------------------------------------------------------------------------- +# 邮件服务(notify/mail.py,04 细案 B2):只有管理员能改(整个设置接口的改动都要管理员),口令加密存、读的时候只说配没配 +# -------------------------------------------------------------------------- + +MAIL_GROUP = "mail" +#: 发测试邮件没有可发的账号(关登录)、账号没有邮箱时 POST /api/settings/mail/test 回 422 的机读码 +MAIL_TEST_NO_ADDRESS = "mail_test_no_address" + + # -------------------------------------------------------------------------- # 用户设置 # -------------------------------------------------------------------------- @@ -731,6 +756,19 @@ def _hidden_groups() -> tuple[str, ...]: return (AUTH_KEY,) +def _mail_public(stored: Any) -> dict[str, Any]: + from app.notify import mail + + return mail.public_value(stored) + + +def _internal(key: str) -> bool: + """通知自己记的账(每天汇总的认领行、自动建过邮件渠道的人,notify/digest.STATE_PREFIX):不是用户设置,不列、不收。""" + from app.notify.digest import STATE_PREFIX + + return key.startswith(STATE_PREFIX) + + DEFAULT_SETTINGS: dict[str, Any] = { "ui": {"theme": "system", "language": "zh", "canvas_snap": True, "autosave": True}, "run": { @@ -761,8 +799,10 @@ DEFAULT_SETTINGS: dict[str, Any] = { NOTIFY_GROUP: notify_values(None), # 起草模型:数据目录起草、按配方导入的 AI 起草用的模型,留空沿用助手的模型 CATALOG_DRAFT_GROUP: catalog_draft_values(None), - # 应用:按需视图的每日上限、使用者能不能建个人应用(管理员在设置页改) + # 应用:按需视图的每日上限、使用者能不能建个人应用、能不能建分享链接(管理员在设置页改) APPS_GROUP: apps_values(None), + # 邮件服务(SMTP):通知渠道的邮件格式用它发信。缺省关 + MAIL_GROUP: _mail_public(None), } @@ -817,6 +857,7 @@ async def get_settings(session: AsyncSession = Depends(get_session)) -> dict[str stored.pop(VERIFY_SPEND, None) for key in _hidden_groups(): stored.pop(key, None) + stored = {k: v for k, v in stored.items() if not _internal(k)} merged = {k: {**v, **(stored.get(k) or {})} for k, v in DEFAULT_SETTINGS.items()} # 裁判、核查设置按引擎实际生效的样子给:存坏了的项显示默认,存了 null 的上限显示不限 merged[JUDGE_GROUP] = judge_values(stored.get(JUDGE_GROUP)) @@ -825,6 +866,8 @@ async def get_settings(session: AsyncSession = Depends(get_session)) -> dict[str merged[PUBLIC_URL_GROUP] = public_url_values(stored.get(NOTIFY_GROUP)) merged[CATALOG_DRAFT_GROUP] = catalog_draft_values(stored.get(CATALOG_DRAFT_GROUP)) merged[APPS_GROUP] = apps_values(stored.get(APPS_GROUP)) + # 口令不回(password 恒为 null),只说配没配(password_set) + merged[MAIL_GROUP] = _mail_public(stored.get(MAIL_GROUP)) for key, value in stored.items(): merged.setdefault(key, value) return merged @@ -914,8 +957,10 @@ async def put_settings( if problem := notify_problem(payload.values[NOTIFY_GROUP]): raise HTTPException(422, problem) # 存规范化之后的:末尾带 / 的地址拼链接会多出一个斜杠 - payload.values[NOTIFY_GROUP] = notify_values(payload.values[NOTIFY_GROUP]) - if public_url.env_locked() and payload.values[NOTIFY_GROUP] != await notify_settings(session): + # 只给了一部分的(只改汇总时间)补上存着的另几项,不把对外访问地址冲成没填 + payload.values[NOTIFY_GROUP] = notify_values({**await notify_settings(session), **payload.values[NOTIFY_GROUP]}) + current_url = (await notify_settings(session))[public_url.SETTING_KEY] + if public_url.env_locked() and payload.values[NOTIFY_GROUP][public_url.SETTING_KEY] != current_url: # 环境变量指定了对外访问地址:改这一项不会生效,照实拒绝,不悄悄存一个用不上的值。原样写回(整组回写)不算改 raise CodedHTTPException( 409, f"对外访问地址由环境变量 {public_url.ENV_NAME} 指定({public_url.env_url()}),不能在设置里修改。" @@ -924,10 +969,18 @@ async def put_settings( if problem := apps_problem(payload.values[APPS_GROUP]): raise HTTPException(422, problem) payload.values[APPS_GROUP] = apps_values(payload.values[APPS_GROUP]) + if MAIL_GROUP in payload.values: + from app.notify import mail + + saved_mail = await session.get(Setting, MAIL_GROUP) + saved_value = saved_mail.value if saved_mail is not None else None + if problem := mail.settings_problem(payload.values[MAIL_GROUP], saved_value): + raise HTTPException(422, problem) + payload.values[MAIL_GROUP] = mail.stored_value(payload.values[MAIL_GROUP], saved_value) if found := await _provider_problem(session, payload.values): raise CodedHTTPException(422, found[1], PROVIDER_INVALID, field=found[0]) for key, value in payload.values.items(): - if key in (TRUST_KEY, "limits", PUBLIC_URL_GROUP, JUDGE_SPEND, VERIFY_SPEND, *_hidden_groups()): + if key in (TRUST_KEY, "limits", PUBLIC_URL_GROUP, JUDGE_SPEND, VERIFY_SPEND, *_hidden_groups()) or _internal(key): # 信任三档只走 PUT /api/tools/trust:设置页整组回写时带着一份旧的, # 会把刚在工具页改的档位冲掉。limits 是环境变量定的、public_url 是算出来的,写进库也不生效。 # 裁判、核查的每日计数是引擎记的账,只有引擎自己写。登录设置只经 /api/auth/settings 改(_hidden_groups) @@ -944,6 +997,35 @@ async def put_settings( return await get_settings(session) +@router.post("/settings/mail/test") +async def test_mail(session: AsyncSession = Depends(get_session)) -> dict[str, Any]: + """按存着的邮件服务设置发一封测试邮件,发到管理员自己的账号邮箱(只发到认证过的账号,不收任意地址:不让这个接口成了 + 邮件中继)。没启用也能测(配好了先试一封再启用);不重试。{ok, message}:成功说「已交给邮件服务器」,是否送达以邮件服务器 + 为准(退信不追踪)。关登录时没有账号、账号没有邮箱:422(mail_test_no_address),说清怎么办。""" + from app.api.deps import current_principal + from app.db.models import User + from app.notify import mail + from app.notify.core import Message + + principal = current_principal() + if principal.mode == "off" or not principal.user_id: + raise CodedHTTPException(422, "关闭登录时没有账号邮箱,测试邮件发不到人:请建一个全局的邮件渠道、填上收件地址," + "用渠道的「发送测试消息」试", MAIL_TEST_NO_ADDRESS) + user = await session.get(User, principal.user_id) + to = (user.email or "").strip() if user is not None else "" + if not mail.address_ok(to): + raise CodedHTTPException(422, f"{mail.NO_EMAIL},再发测试邮件", MAIL_TEST_NO_ADDRESS) + row = await session.get(Setting, MAIL_GROUP) + cfg = mail.config_of(row.value if row is not None else None) + if not cfg.ready: + return {"ok": False, "message": f"请先填写并保存「{mail.LABEL['host']}」和「{mail.LABEL['from_addr']}」,再发测试邮件"} + msg = Message(title="邮件服务测试", ok=True, lines=[("说明", "这是一封测试邮件。收到即表示邮件服务配置正确")]) + outcome = await mail.send(cfg, [to], msg, label="测试邮件", retry=False) + if outcome.ok: + return {"ok": True, "message": f"{mail.HANDED_OFF}(发到 {to}),是否送达以邮件服务器为准,请到收件箱确认"} + return {"ok": False, "message": outcome.error or "发送失败"} + + @router.get("/settings/judge/spend") async def get_judge_spend(session: AsyncSession = Depends(get_session)) -> dict[str, Any]: """今天(本地日期)结论句裁判花了多少,设置页放在每日上限旁边。只读:计数只有裁判自己记。 diff --git a/backend/app/notify/__init__.py b/backend/app/notify/__init__.py index cc70c81e..13c2eb47 100644 --- a/backend/app/notify/__init__.py +++ b/backend/app/notify/__init__.py @@ -1,6 +1,7 @@ """通知的发送核心(附录 C,BE-2 提供)。从 scheduler/webhook.py 抽出:四种格式、签名、发送、脱敏(core.py); webhook.py 照原名再导出,定时任务的通知存储不迁移(F14)。新对象(待办、实例、动作)的订阅和投递记录在 -service.py:渠道(notify_channels)、投递记录(notify_deliveries)。""" +service.py:渠道(notify_channels)、投递记录(notify_deliveries)。渠道的第五种格式「邮件」走 mail.py(SMTP),订阅、 +投递记录、消息结构都和 Webhook 同一套,只是在这里按格式分到另一条发送路上。""" from __future__ import annotations from typing import Any @@ -22,6 +23,10 @@ async def send(channel: Any, message: dict[str, Any]) -> Any: lines=[(str(k), str(v)) for k, v in message.get("lines") or []], links=[(str(k), str(v)) for k, v in message.get("links") or []], ok=bool(message.get("ok"))) + if channel.format == "email": + from app.notify import mail + + return await mail.deliver(channel, message, msg) if not url: return Outcome(False, None, 0, "渠道的地址无法解密(加密密钥可能已更换),请重新填写地址") if getattr(channel, "scope", None) == "user": diff --git a/backend/app/notify/core.py b/backend/app/notify/core.py index d354f5d4..8ea196e5 100644 --- a/backend/app/notify/core.py +++ b/backend/app/notify/core.py @@ -1,6 +1,7 @@ """通知的发送核心:四种格式、签名、发送、脱敏(从 scheduler/webhook.py 抽出,定时任务和待办、实例、动作共用一份)。 -四种格式:通用 JSON、飞书(文本消息)、企业微信(markdown)、钉钉(markdown)。配了签名密钥时:通用格式在请求头 +四种格式:通用 JSON、飞书(文本消息)、企业微信(markdown)、钉钉(markdown)。通知渠道另有第五种「邮件」,发送在 +notify/mail.py(SMTP,不经这里的 HTTP 推送),消息结构(Message、text_of)共用这里的。配了签名密钥时:通用格式在请求头 里带 HMAC-SHA256 签名,飞书、钉钉按各自平台的算法签(飞书放在请求体,钉钉放在地址上);企业微信的 key 本来就在 地址里,没有另外的签名。入站钩子(app/process/hooks.py)验的也是通用格式这一套签名。 @@ -37,8 +38,11 @@ from app.db.base import utcnow logger = logging.getLogger(__name__) FORMATS = ("generic", "feishu", "wecom", "dingtalk") +#: 通知渠道能选的格式:Webhook 的四种,加上邮件(notify/mail.py,04 细案 B2,第五种格式)。定时任务的 Webhook 只有前四种 +#: (它只会发 HTTP),所以邮件不进 FORMATS:scheduler/webhook 再导出的、定时任务接口校验的都是那一份 +CHANNEL_FORMATS = (*FORMATS, "email") #: 格式在报错里的叫法,和界面上的选项一致 -FORMAT_LABEL = {"generic": "通用", "feishu": "飞书", "wecom": "企业微信", "dingtalk": "钉钉"} +FORMAT_LABEL = {"generic": "通用", "feishu": "飞书", "wecom": "企业微信", "dingtalk": "钉钉", "email": "邮件"} #: 单次请求的超时(秒) TIMEOUT_S = 10.0 #: 失败后再试 3 次,每次之前等这么久(秒)。4xx(429 除外)不重试:地址或参数错了,重试也一样 diff --git a/backend/app/notify/digest.py b/backend/app/notify/digest.py new file mode 100644 index 00000000..574bb2ca --- /dev/null +++ b/backend/app/notify/digest.py @@ -0,0 +1,185 @@ +"""每天的待办汇总、访问密钥快到期:什么时候推(docs/design/next-round/04-对外集成与触达.md B3)。 + +调度循环每一拍调一次(scheduler/loop.process_due 的步骤列表);拼消息、推送在 notify/service(notify_digest、 +notify_key_expiring),推送放在后台(service.spawn):接收端挂住时超时加重试要几十秒,不能拖着这一拍别的步骤。 + +待办汇总(task.digest): +- 每天到设置里的 notify.digest_at(缺省 09:00,按服务端所在时区)之后的第一拍,给订阅了 task.digest 的个人渠道的拥有者各发 + 一条(没有待办的人不发); +- 按日期在 settings 表里认领一行(键 STATE_PREFIX + "digest:<日期>",主键冲突就是已经认领过):同一天只发一次,重启了也不 + 重发。认领之后推送失败不补发——通知本来就是至多一次,投递记录里照实记失败。停机错过的那一天不补; +- 认领行只留最近 KEEP_DAYS 天。 + +访问密钥快到期(key.expiring): +- 到期前 7 天、1 天各推一次(THRESHOLDS):个人访问密钥推给拥有者的个人渠道,服务账号的推给订阅了的全局渠道; +- 去重靠审计:推之前先记一条 access_key.expiring(data.days 是哪一档),记过的不再推(进程里另记一份,省得每拍查审计); +- 不推的:建的时候离到期已经不到这一档(自己设了短有效期,知道它快到期);轮换下来、24 小时后到期的旧密钥(新的那把已经 + 在用);撤销了的;拥有者停用了的;个人密钥开关关着时的个人密钥(本来就不认了)。 + +这两种状态(认领行、自动建过邮件渠道的人)存在 settings 表里,键都以 STATE_PREFIX 开头:它们是通知自己记的账,不是用户设置, +GET /api/settings 不列、PUT 不收(api/settings.py)。 +""" +from __future__ import annotations + +import logging +import re +from datetime import datetime, timedelta +from typing import Any + +from sqlalchemy import delete, select +from sqlalchemy.exc import IntegrityError + +from app.db.base import SessionLocal, utcnow +from app.db.models import NotifyChannel, Setting, User + +logger = logging.getLogger(__name__) + +#: settings 表里通知自己记的账(不是用户设置)的键的开头 +STATE_PREFIX = "notify_state:" +DIGEST_CLAIM = STATE_PREFIX + "digest:" +#: 汇总的缺省发送时间(服务端所在时区) +DEFAULT_AT = "09:00" +#: 认领行留几天 +KEEP_DAYS = 7 +#: 访问密钥到期前几天各推一次 +THRESHOLDS = (7, 1) + +_TIME = re.compile(r"^([01]\d|2[0-3]):([0-5]\d)$") +#: 这个进程里已经认领(或者看到别人认领了)的日期:同一天不再去插认领行 +_claimed: set[str] = set() +#: 这个进程里已经推过(或者审计里已经有)的 (密钥 id, 档) +_noted: set[tuple[str, int]] = set() + + +def time_ok(value: Any) -> bool: + return isinstance(value, str) and bool(_TIME.match(value.strip())) + + +def at_of(stored_notify: Any) -> tuple[int, int]: + """设置 notify 一组里的 digest_at → (时, 分);没填、存坏了按缺省。""" + raw = stored_notify.get("digest_at") if isinstance(stored_notify, dict) else None + match = _TIME.match(raw.strip()) if isinstance(raw, str) else None + if match is None: + match = _TIME.match(DEFAULT_AT) + assert match is not None + return int(match.group(1)), int(match.group(2)) + + +async def _claim(day: str, now: datetime) -> bool: + """认领这一天的汇总:插一行,主键冲突就是已经有人认领过。顺带清掉 KEEP_DAYS 天以前的认领行。""" + key = DIGEST_CLAIM + day + async with SessionLocal() as session: + if await session.get(Setting, key) is not None: + return False + session.add(Setting(key=key, value={"claimed_at": now.isoformat()})) + try: + await session.commit() + except IntegrityError: + return False + floor = DIGEST_CLAIM + (now.astimezone().date() - timedelta(days=KEEP_DAYS)).isoformat() + await session.execute(delete(Setting).where(Setting.key.like(DIGEST_CLAIM + "%"), Setting.key < floor)) + await session.commit() + return True + + +async def digest_due(now: datetime | None = None) -> list[str]: + """到点了就认领今天、给订阅了汇总的人各发一条(后台推)。交回这一拍要发给谁(用户 id),没到点、已经发过是空的。""" + from app.api.settings import NOTIFY_GROUP + from app.notify import service + + now = now or utcnow() + local = now.astimezone() + day = local.date().isoformat() + if day in _claimed: + return [] + async with SessionLocal() as session: + row = await session.get(Setting, NOTIFY_GROUP) + if (local.hour, local.minute) < at_of(row.value if row is not None else None): + return [] + claimed = await _claim(day, now) + _claimed.add(day) + if not claimed: + return [] + async with SessionLocal() as session: + rows = (await session.execute(select(NotifyChannel.owner_id, NotifyChannel.events).where( + NotifyChannel.scope == "user", NotifyChannel.enabled.is_(True), + NotifyChannel.owner_id.is_not(None)))).all() + owners = list(dict.fromkeys(owner for owner, events in rows if "task.digest" in (events or []))) + for owner in owners: + service.spawn(_digest_one(owner)) + if owners: + logger.info("今天(%s)的待办汇总:发给 %d 个人", day, len(owners)) + return owners + + +async def _digest_one(user_id: str) -> None: + from app.notify import service + + try: + await service.notify_digest(user_id) + except Exception: # noqa: BLE001 - 一个人的汇总出错不影响别人,待办本身也不受影响 + logger.exception("给用户 %s 发待办汇总失败", user_id) + + +async def keys_due(now: datetime | None = None) -> list[str]: + """访问密钥快到期了的,按档推一次(见模块说明)。交回这一拍推了哪几把(密钥 id)。""" + from app.auth import personal_keys_enabled + from app.auth.audit import record + from app.auth.keys import SERVICE_KIND + from app.db.models import AuditEvent + from app.db.models_identity import AccessKey + from app.notify import service + + now = now or utcnow() + horizon = now + timedelta(days=max(THRESHOLDS)) + async with SessionLocal() as session: + rows = (await session.execute(select(AccessKey, User).join(User, User.id == AccessKey.owner_user_id).where( + AccessKey.revoked_at.is_(None), AccessKey.expires_at > now, AccessKey.expires_at <= horizon))).all() + if not rows: + return [] + ids = [key.id for key, _ in rows] + replaced = set((await session.execute(select(AccessKey.rotated_from).where( + AccessKey.rotated_from.in_(ids)))).scalars()) + out: list[str] = [] + for key, owner in rows: + if owner.disabled or key.id in replaced: + continue + if owner.kind != SERVICE_KIND and not personal_keys_enabled(): + continue + expires = service._utc(key.expires_at) + left = expires - now + days = next(d for d in sorted(THRESHOLDS) if left <= timedelta(days=d)) + created = service._utc(key.created_at) if key.created_at else None + if created is not None and created > expires - timedelta(days=days): + continue + if (key.id, days) in _noted: + continue + async with SessionLocal() as session: + seen = (await session.execute(select(AuditEvent.data).where( + AuditEvent.action == "access_key.expiring", AuditEvent.subject_id == key.id))).scalars() + if any(isinstance(d, dict) and d.get("days") == days for d in seen): + _noted.add((key.id, days)) + continue + await record(session, "access_key.expiring", "access_key", key.id, actor=None, verified=False, + data={"days": days, "expires_at": expires.isoformat(), "name": key.name, + "owner": owner.username}) + await session.commit() + _noted.add((key.id, days)) + service.spawn(_key_one(key.id, days)) + out.append(key.id) + return out + + +async def _key_one(key_id: str, days: int) -> None: + from app.notify import service + + try: + await service.notify_key_expiring(key_id, days) + except Exception: # noqa: BLE001 + logger.exception("访问密钥 %s 快到期的提醒推送失败", key_id) + + +def forget() -> None: + """测试用:清掉进程里记的(认领过的日期、推过的密钥档)。""" + _claimed.clear() + _noted.clear() diff --git a/backend/app/notify/mail.py b/backend/app/notify/mail.py new file mode 100644 index 00000000..2318bfe7 --- /dev/null +++ b/backend/app/notify/mail.py @@ -0,0 +1,424 @@ +"""邮件:通知渠道的第五种格式(docs/design/next-round/04-对外集成与触达.md B2,总方案 X12、集成-I4)。 + +为什么改写应用平台的 Q6(「待办通知只用 Webhook 的四种格式,不做邮件——SMTP 要另配服务器、还要处理退信」): +- 待办派给了人却找不到人:群机器人群里人人看得见,算不上个人;用户表里本来就有 email(单点登录时自动填,管理员在 + 「设置 → 身份」里也能改),邮件是真正能触达个人、成本最低的办法; +- Q6 担心的两件事这里都照实处理,不假装做到:SMTP 是可选配置(设置里的 mail 一组,缺省关,不配就没有邮件这一格式); + 退信不追踪——投递记录成功时写的是「已交给邮件服务器」,是否进了收件箱以邮件服务器为准,界面上也照这样写; +- IM 应用消息(企业微信、飞书、钉钉的应用消息)这次不做:要企业应用凭据、令牌刷新和用户 id 映射,和 IM 登录是同一套, + 放到一起做。 + +一种说法:邮件只是渠道的一种格式,订阅、投递记录(notify_deliveries)、测试、消息结构(core.Message:只有标题、状态、 +条数、链接,不放数字)都走同一套;不另建邮件表。 + +收件人: +- 个人邮件渠道(scope=user)不填地址,只发到拥有者在名录里的账号邮箱(users.email):可以填任意地址的话,个人渠道就成了 + 谁都能用的垃圾邮件中继,而且发到的不是认证过的身份。账号没有邮箱时记一条失败的投递并写明原因(NO_EMAIL); +- 全局、应用的邮件渠道(搭建者以上能建)填固定的收件地址,最多 MAX_RECIPIENTS 个,和全局 Webhook 同一个信任级别。 + 地址同 Webhook 一样加密落库(渠道表不改结构:个人渠道存一个占位)。 + +发送:标准库 smtplib,放在 asyncio.to_thread 里(不多一个依赖),单次超时 TIMEOUT_S 秒。4xx(临时错误)和连不上按 +1/2/4 秒重试,5xx(地址、认证、内容被拒)不重试——重试也一样。报错里只写主机名和服务器的回话,绝不出现口令(scrub)。 +测试把 _smtp_factory 换成假的(同 core._transport 的做法)。 + +邮件内容:纯文本一份、简单的 HTML 一份(全部转义);带 List-Unsubscribe(指向「设置 → 通知」,没有对外访问地址时不带: +这个头只认完整的地址),Auto-Submitted: auto-generated。 + +设置的 mail 一组只有管理员能改(整个设置接口的改动都要管理员);口令用 core/crypto 加密存,读设置时只说配没配。 +内网的 SMTP 由管理员配置,不做 SSRF 检查(同全局 Webhook)。 +""" +from __future__ import annotations + +import asyncio +import html +import logging +import re +import smtplib +import socket +import ssl +from collections.abc import Callable +from dataclasses import dataclass +from email.message import EmailMessage +from email.utils import formataddr, formatdate, make_msgid +from typing import Any + +from app.notify.core import Message, Outcome, text_of + +logger = logging.getLogger(__name__) + +#: 设置里的这一组 +GROUP = "mail" +#: 加密方式:starttls(587,明文连上再升级)、ssl(465,一上来就是加密连接)、none(内网明文中继) +SECURITY = ("starttls", "ssl", "none") +SECURITY_LABEL = {"starttls": "STARTTLS", "ssl": "SSL/TLS", "none": "不加密"} +DEFAULTS: dict[str, Any] = {"enabled": False, "host": None, "port": 587, "security": "starttls", "username": None, + "password": None, "from_addr": None, "from_name": "AgentLab", + "default_for_assignees": False} +LABEL = {"enabled": "启用邮件", "host": "SMTP 服务器", "port": "端口", "security": "加密方式", "username": "用户名", + "password": "口令", "from_addr": "发件地址", "from_name": "发件人名称", + "default_for_assignees": "默认发邮件给待办处理人"} +#: 单次连接、读写的超时(秒) +TIMEOUT_S = 10.0 +#: 临时错误、连不上时再试 3 次,每次之前等这么久(秒) +RETRY_DELAYS: tuple[float, ...] = (1.0, 2.0, 4.0) +#: 全局、应用的邮件渠道最多几个收件地址 +MAX_RECIPIENTS = 20 +#: 个人邮件渠道 url 列存的占位(加密后落库):收件人一律取拥有者的账号邮箱 +PERSONAL_PLACEHOLDER = "account-email" +#: 管理员开了「默认发邮件给待办处理人」时,自动建的个人邮件渠道默认订阅这几项(本人之后可以改、可以关) +ASSIGNEE_EVENTS = ("task.opened", "action.unknown", "review.done") +ASSIGNEE_CHANNEL_NAME = "待办邮件(自动建)" + +NO_EMAIL = "你的账号没有邮箱地址,请联系管理员在「设置 → 身份」里填写" +NOT_ENABLED = "邮件服务没有启用:请管理员在「设置 → 通知 → 邮件服务」里配置并启用" +HANDED_OFF = "已交给邮件服务器" + +#: 测试用:换成假的 SMTP 工厂,(host, port, security, timeout) → 有 starttls / login / send_message / quit / close 的对象 +_smtp_factory: Callable[[str, int, str, float], Any] | None = None + +# 地址只认最常见的写法:本地部分不含空白和尖括号,域名至少有一个点。换行一律拒收(防邮件头注入) +_ADDRESS = re.compile(r"^[^@\s<>\"(),;:\\]+@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$") +_HOST = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$|^\[[0-9A-Fa-f:.]+\]$") + + +# -------------------------------------------------------------------------- +# 地址、设置 +# -------------------------------------------------------------------------- + + +def address_ok(text: Any) -> bool: + return isinstance(text, str) and len(text) <= 254 and bool(_ADDRESS.match(text)) + + +def parse_recipients(raw: Any) -> list[str]: + """全局、应用邮件渠道填的收件地址(逗号、分号、空白、换行分隔都行)→ 去重后的列表。不对抛 ValueError(中文说明)。""" + if isinstance(raw, list): + parts = [str(p) for p in raw] + elif isinstance(raw, str): + parts = re.split(r"[\s,;,;、]+", raw) + else: + parts = [] + out = list(dict.fromkeys(p.strip() for p in parts if p and p.strip())) + if not out: + raise ValueError("请填写至少一个收件地址,多个地址用逗号或换行分隔") + if len(out) > MAX_RECIPIENTS: + raise ValueError(f"收件地址最多 {MAX_RECIPIENTS} 个,当前填了 {len(out)} 个;人多的请改用邮件组的地址") + bad = [p for p in out if not address_ok(p)] + if bad: + raise ValueError(f"收件地址「{bad[0][:80]}」不是有效的邮件地址") + return out + + +def _text(value: Any) -> str | None: + return value.strip() if isinstance(value, str) and value.strip() else None + + +def _port_ok(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and 1 <= value <= 65535 + + +def settings_problem(value: Any, saved: Any = None) -> str | None: + """设置页写进来的 mail 一组有什么问题,没问题是 None。saved 是库里存着的那份(口令没给就沿用它)。 + + 启用时要配全:服务器、发件地址;没启用时可以只填一部分先存着。发件人名称、主机名里不许有换行(邮件头注入)。""" + if not isinstance(value, dict): + return f"「邮件服务」设置格式有误,需要是一个对象,当前为「{value}」" + for key, item in value.items(): + if key == "password_set": + continue # GET 给的只读项,整组回写时带着它不算错 + if key not in DEFAULTS: + return "「邮件服务」设置中不支持「" + key + "」,可设置的项:" + "、".join(f"「{LABEL[k]}」" for k in DEFAULTS) + label = LABEL[key] + if key in ("enabled", "default_for_assignees"): + if not isinstance(item, bool): + return f"「{label}」只能是开或关,当前为「{item}」" + elif key == "port": + if not _port_ok(item): + return f"「{label}」需要是 1 到 65535 之间的整数,当前为「{item}」" + elif key == "security": + if item not in SECURITY: + return f"「{label}」只能是 {'、'.join(SECURITY_LABEL.values())} 之一" + elif item is not None and not isinstance(item, str): + return f"「{label}」需要是文字,当前为「{item}」" + elif isinstance(item, str) and any(c in item for c in "\r\n"): + return f"「{label}」里不能有换行" + elif key == "host" and _text(item) and not _HOST.match(item.strip()): + return f"「{label}」需要是主机名或 IP 地址(例如 smtp.example.com),不带协议和端口,当前为「{item}」" + elif key == "from_addr" and _text(item) and not address_ok(item.strip()): + return f"「{label}」不是有效的邮件地址,当前为「{item}」" + elif key in ("username", "from_name") and isinstance(item, str) and len(item) > 200: + return f"「{label}」最长 200 个字" + elif key == "password" and isinstance(item, str) and len(item) > 500: + return f"「{label}」过长" + merged = {**public_value(saved), **{k: v for k, v in value.items() if k in DEFAULTS and k != "password"}} + if merged.get("enabled"): + if not _text(merged.get("host")): + return f"启用邮件之前请填写「{LABEL['host']}」" + if not _text(merged.get("from_addr")): + return f"启用邮件之前请填写「{LABEL['from_addr']}」" + return None + + +def stored_value(value: dict[str, Any], saved: Any = None) -> dict[str, Any]: + """校验过的 mail 一组 → 落库的样子:补齐默认、去掉首尾空白;口令没给(或给 null)沿用存着的,给空串表示去掉, + 给了新的就加密。""" + from app.core.crypto import encrypt + + old = saved if isinstance(saved, dict) else {} + out = {**DEFAULTS, **{k: v for k, v in old.items() if k in DEFAULTS}} + for key, item in value.items(): + if key not in DEFAULTS or key == "password": + continue + out[key] = item.strip() if isinstance(item, str) else item + for key in ("host", "username", "from_addr", "from_name"): + out[key] = _text(out.get(key)) + out["from_name"] = out["from_name"] or DEFAULTS["from_name"] + given = value.get("password") + if isinstance(given, str): + out["password"] = encrypt(given) if given else None + return out + + +def public_value(stored: Any) -> dict[str, Any]: + """读设置时给的样子(GET /api/settings 的 mail 一组):口令不回,只说配没配(password_set)。存坏了的项按默认。""" + stored = stored if isinstance(stored, dict) else {} + out: dict[str, Any] = {} + for key, default in DEFAULTS.items(): + item = stored.get(key) + if key in ("enabled", "default_for_assignees"): + out[key] = item if isinstance(item, bool) else default + elif key == "port": + out[key] = item if _port_ok(item) else default + elif key == "security": + out[key] = item if item in SECURITY else default + elif key == "password": + out[key] = None + else: + out[key] = _text(item) or default + out["password_set"] = bool(stored.get("password")) + return out + + +@dataclass(frozen=True) +class MailConfig: + """发信用的配置(口令已解密,只在发送时用)。""" + + enabled: bool + host: str | None + port: int + security: str + username: str | None + password: str | None + from_addr: str | None + from_name: str + default_for_assignees: bool + + @property + def ready(self) -> bool: + """配全了能发:服务器、发件地址都有。启不启用另看 enabled。""" + return bool(self.host and self.from_addr) + + +def config_of(stored: Any) -> MailConfig: + from app.core.crypto import decrypt + + shown = public_value(stored) + secret = stored.get("password") if isinstance(stored, dict) else None + return MailConfig(enabled=shown["enabled"], host=shown["host"], port=shown["port"], security=shown["security"], + username=shown["username"], password=decrypt(secret) if isinstance(secret, str) else None, + from_addr=shown["from_addr"], from_name=shown["from_name"], + default_for_assignees=shown["default_for_assignees"]) + + +async def load_config(session: Any = None) -> MailConfig: + from app.db.base import SessionLocal + from app.db.models import Setting + + if session is None: + async with SessionLocal() as own: + row = await own.get(Setting, GROUP) + else: + row = await session.get(Setting, GROUP) + return config_of(row.value if row is not None else None) + + +# -------------------------------------------------------------------------- +# 邮件内容 +# -------------------------------------------------------------------------- + + +_FOOTER = "这封邮件由 AgentLab 自动发出,请勿直接回复。不想再收到:在 AgentLab「设置 → 通知」里关掉或删除这个渠道" + + +def build(cfg: MailConfig, to: list[str], msg: Message, *, unsubscribe: str | None) -> EmailMessage: + """一封邮件:纯文本一份、HTML 一份(全部转义)。unsubscribe 是「设置 → 通知」的完整地址,没有就不带退订头。""" + em = EmailMessage() + em["Subject"] = f"【AgentLab】{msg.title}"[:200] + em["From"] = formataddr((cfg.from_name or DEFAULTS["from_name"], cfg.from_addr or "")) + em["To"] = ", ".join(to) + em["Date"] = formatdate(localtime=True) + em["Message-ID"] = make_msgid(domain=(cfg.from_addr or "agentlab.invalid").rpartition("@")[2] or None) + # 自动发出的通知:自动回复不该回给它(RFC 3834) + em["Auto-Submitted"] = "auto-generated" + if unsubscribe: + em["List-Unsubscribe"] = f"<{unsubscribe}>" + footer = _FOOTER + (f":{unsubscribe}" if unsubscribe else "") + em.set_content(f"{text_of(msg)}\n\n——\n{footer}\n") + em.add_alternative(_html(msg, unsubscribe), subtype="html") + return em + + +def _html(msg: Message, unsubscribe: str | None) -> str: + esc = html.escape + color = "#1a7f37" if msg.ok else "#9a6700" + rows = "".join( + f'{esc(k)}' + f'{esc(v)}' + for k, v in msg.lines) + links = "".join(f'

{esc(label)}

' + for label, url in msg.links if url.startswith(("http://", "https://"))) + tail = esc(_FOOTER) + (f':设置 → 通知' if unsubscribe else "") + return ('' + f'

【AgentLab】{esc(msg.title)}

' + f'{rows}
{links}' + f'

{tail}

') + + +# -------------------------------------------------------------------------- +# 发送 +# -------------------------------------------------------------------------- + + +def _default_factory(host: str, port: int, security: str, timeout: float) -> Any: + if security == "ssl": + return smtplib.SMTP_SSL(host, port, timeout=timeout, context=ssl.create_default_context()) + return smtplib.SMTP(host, port, timeout=timeout) + + +def scrub(text: str, cfg: MailConfig) -> str: + """报错里不出现口令(服务器回话里偶尔会把收到的内容原样带回来)。""" + if cfg.password and len(cfg.password) >= 3: + text = text.replace(cfg.password, "…") + return text + + +def _reply(error: Any) -> str: + raw = getattr(error, "smtp_error", b"") + text = raw.decode("utf-8", "replace") if isinstance(raw, bytes) else str(raw or "") + return " ".join(text.split())[:200] + + +def _send_once(cfg: MailConfig, to: list[str], em: EmailMessage) -> Outcome: + """连一次、发一次(同步,在线程里跑)。结果按 SMTP 的回码分:2xx 已交给邮件服务器;4xx、连不上值得再试;5xx 不再试。""" + host = f"{cfg.host}:{cfg.port}" + factory = _smtp_factory or _default_factory + client = None + try: + client = factory(cfg.host or "", cfg.port, cfg.security, TIMEOUT_S) + if cfg.security == "starttls": + client.starttls(context=ssl.create_default_context()) + if cfg.username: + client.login(cfg.username, cfg.password or "") + refused = client.send_message(em, from_addr=cfg.from_addr, to_addrs=to) or {} + except smtplib.SMTPNotSupportedError: + return Outcome(False, None, 1, f"邮件服务器 {host} 不支持 STARTTLS:请把「加密方式」改成 SSL/TLS 或不加密") + except smtplib.SMTPRecipientsRefused as e: + codes = [code for code, _ in (e.recipients or {}).values()] + temporary = any(400 <= int(c) < 500 for c in codes) + return Outcome(False, None, 1, f"邮件服务器 {host} 拒收了全部 {len(codes) or len(to)} 个收件地址" + + ("(临时错误)" if temporary else ""), retryable=temporary) + except smtplib.SMTPAuthenticationError as e: + code = int(e.smtp_code or 0) + return Outcome(False, None, 1, scrub(f"邮件服务器 {host} 不接受这组用户名和口令({code})" + + (f":{_reply(e)}" if _reply(e) else ""), cfg)[:300], + retryable=400 <= code < 500) + except smtplib.SMTPResponseException as e: + code = int(e.smtp_code or 0) + detail = _reply(e) + text = scrub(f"邮件服务器 {host} 回复 {code}" + (f":{detail}" if detail else ""), cfg)[:300] + # SMTPConnectError 也走这里(连上了但对方回了错误码);回码不是 5xx 的都当临时错误 + return Outcome(False, None, 1, text, retryable=not 500 <= code < 600) + except smtplib.SMTPServerDisconnected: + return Outcome(False, None, 1, f"邮件服务器 {host} 断开了连接", retryable=True) + except ssl.SSLError: + return Outcome(False, None, 1, f"和邮件服务器 {host} 建立加密连接失败:请核对「加密方式」和端口(SSL/TLS 常用 465," + "STARTTLS 常用 587)") + except (socket.timeout, TimeoutError): + return Outcome(False, None, 1, f"连接邮件服务器 {host} 超时({TIMEOUT_S:g} 秒)", retryable=True) + except socket.gaierror: + return Outcome(False, None, 1, f"找不到邮件服务器 {cfg.host}(域名无法解析)", retryable=True) + except OSError: + return Outcome(False, None, 1, f"无法连接邮件服务器 {host}", retryable=True) + except Exception as e: # noqa: BLE001 - 拼信、编码出错也只记一条失败,不让推送任务抛出去 + logger.exception("发邮件时出错(%s)", host) + return Outcome(False, None, 1, f"向邮件服务器 {host} 发信时出错({type(e).__name__})") + finally: + if client is not None: + try: + client.quit() + except Exception: # noqa: BLE001 - 已经断开的连接 quit 会抛,关掉就行 + try: + client.close() + except Exception: # noqa: BLE001 + pass + if refused: + # 部分地址被拒:其余的已经交给邮件服务器,算成功,照实写有几个没收 + return Outcome(True, None, 1, f"{HANDED_OFF},其中 {len(refused)} 个收件地址被拒收") + return Outcome(True, None, 1) + + +async def send(cfg: MailConfig, to: list[str], msg: Message, *, label: str, unsubscribe: str | None = None, + retry: bool = True, delays: tuple[float, ...] | None = None) -> Outcome: + """发一封邮件(含重试)。label 是日志里的「谁的推送」。没配全的照实回失败,不连服务器。""" + if not cfg.ready: + return Outcome(False, None, 0, NOT_ENABLED) + em = build(cfg, to, msg, unsubscribe=unsubscribe) + waits = (RETRY_DELAYS if delays is None else delays) if retry else () + last = Outcome(False, None, 0, "没有发出邮件") + for attempt in range(len(waits) + 1): + if attempt: + await asyncio.sleep(waits[attempt - 1]) + last = await asyncio.to_thread(_send_once, cfg, to, em) + last.attempts = attempt + 1 + if last.ok: + return last + logger.warning("%s(邮件服务器 %s)第 %d 次发信失败:%s", label, cfg.host, attempt + 1, last.error) + if not last.retryable: + break + return last + + +async def recipients_of(session: Any, channel: Any) -> tuple[list[str], str | None]: + """这个邮件渠道发给谁:(地址, 发不了的原因)。个人渠道取拥有者的账号邮箱,其余解密填的那几个。""" + from app.core.crypto import decrypt + from app.db.models import User + + if getattr(channel, "scope", None) == "user": + user = await session.get(User, channel.owner_id) if channel.owner_id else None + email = (user.email or "").strip() if user is not None else "" + if not email or not address_ok(email): + return [], NO_EMAIL + return [email], None + try: + return parse_recipients(decrypt(channel.url) or ""), None + except ValueError as e: + return [], f"收件地址无法使用:{e}" + + +async def deliver(channel: Any, message: dict[str, Any], msg: Message) -> Outcome: + """按渠道发一封邮件(notify/__init__.send 的邮件分支):配置没启用、收件人取不出来都记成失败、写明原因。""" + from app.core import public_url + from app.db.base import SessionLocal + + async with SessionLocal() as session: + cfg = await load_config(session) + to, problem = await recipients_of(session, channel) + base = await public_url.refresh(session) + if not cfg.enabled: + return Outcome(False, None, 0, NOT_ENABLED) + if problem: + return Outcome(False, None, 0, problem) + return await send(cfg, to, msg, label=f"通知渠道「{channel.name}」", + unsubscribe=f"{base}/settings/notify" if base else None, + retry=bool(message.get("retry", True))) diff --git a/backend/app/notify/service.py b/backend/app/notify/service.py index b2b1f218..cde5b874 100644 --- a/backend/app/notify/service.py +++ b/backend/app/notify/service.py @@ -35,7 +35,7 @@ from typing import Any from sqlalchemy import select from app.db.base import SessionLocal, utcnow -from app.db.models import App, AppVersion, NotifyChannel, NotifyDelivery, Run, Task, TaskAssignment, User +from app.db.models import App, AppVersion, NotifyChannel, NotifyDelivery, Run, Setting, Task, TaskAssignment, User from app.notify.core import Message, Outcome, host_of logger = logging.getLogger(__name__) @@ -74,10 +74,11 @@ EVENTS: dict[str, Event] = { # 定时评测有题从通过变成不通过 / 评测没跑完(01 第 15 项):题集名、通过数、退步题数、链接;不放期望值和答案里的数 "eval.regressed": Event("评测有退步", ()), "eval.failed": Event("评测失败", ()), - # 每天一条待办汇总(04 B3):只推个人渠道;条数、最早的时限、最多 5 条(节点在图上的名字)、去待办箱的链接 - "task.digest": Event("每天的待办汇总", ()), - # 访问密钥到期前 7 天、1 天各一次(04 B3):个人密钥推本人,服务账号的推全局渠道;密钥名、到期日、链接 - "key.expiring": Event("访问密钥快到期", ()), + # 每天一条待办汇总(04 B3,notify_digest):只推个人渠道;条数、最早的时限、最多 5 条(节点在图上的名字)、去待办箱的链接 + "task.digest": Event("每天的待办汇总", ("user",)), + # 访问密钥到期前 7 天、1 天各一次(04 B3,notify_key_expiring):个人密钥推本人的个人渠道,服务账号的推全局渠道; + # 密钥名、到期日、链接 + "key.expiring": Event("访问密钥快到期", ("global", "user")), } EVENT_LABEL = {name: event.label for name, event in EVENTS.items()} @@ -133,14 +134,23 @@ async def personal_url_problem(url: str) -> str | None: return None -def channel_out(row: NotifyChannel) -> dict[str, Any]: - """渠道的接口形状(typesApp.ts 的 NotifyChannelRow):地址只回主机名,密钥只说配没配。""" +def channel_out(row: NotifyChannel, owner_email: str | None = None) -> dict[str, Any]: + """渠道的接口形状(typesApp.ts 的 NotifyChannelRow):地址只回主机名,密钥只说配没配。 + + 邮件渠道(notify/mail.py)没有主机名:mail_to 是发给谁——个人渠道是拥有者的账号邮箱(owner_email,没有邮箱是空的), + 全局、应用的是填的那几个收件地址(搭建者以上才看得到这些渠道,改地址时要整份重填,所以照原样给)。""" from app.core.crypto import decrypt url = decrypt(row.url) or "" - return {"id": row.id, "name": row.name, "scope": row.scope, "owner_id": row.owner_id, "format": row.format, - "url_host": host_of(url) if url else "", "signed": bool(row.secret), "events": list(row.events or []), - "enabled": row.enabled} + out = {"id": row.id, "name": row.name, "scope": row.scope, "owner_id": row.owner_id, "format": row.format, + "url_host": host_of(url) if url and row.format != "email" else "", "signed": bool(row.secret), + "events": list(row.events or []), "enabled": row.enabled} + if row.format == "email": + if row.scope == "user": + out["mail_to"] = [owner_email] if owner_email else [] + else: + out["mail_to"] = [p.strip() for p in url.split(",") if p.strip()] + return out def generic_body(message: dict[str, Any], msg: Message) -> dict[str, Any]: @@ -237,8 +247,61 @@ async def _where(session: Any, run: Run | None) -> str: return f"工作流「{run.workflow_name}」" if run.workflow_name else "" +#: 管理员开了「默认发邮件给待办处理人」(设置 mail.default_for_assignees,04 细案 B2、集成-I5)之后,给哪些人自动建过个人 +#: 邮件渠道:设置表里一行 {用户 id: 建的时间}(通知自己记的账,键的开头同 notify/digest.STATE_PREFIX,设置接口不列)。只在 +#: 第一次派到待办时建一次——本人删掉了就是不要,不再替他建回来 +MAIL_AUTO_KEY = "notify_state:mail_auto" +_mail_auto_lock = asyncio.Lock() + + +async def ensure_assignee_mail(users: list[str]) -> int: + """派到待办的人还没有个人邮件渠道时自动给他建一个(默认订阅 mail.ASSIGNEE_EVENTS),本人之后可以改订阅、可以关。 + + 只在开了登录(个人渠道挂在认证过的账号上)、邮件服务启用、管理员开了这个开关时做;服务账号、停用的人不建。缺省关:刚配好 + SMTP 的部署不该突然往外发邮件(照实说、不意外)。交回这次建了几个。同一时刻只做一份:两条待办同时派给同一个人不建两个。""" + from app.auth import current_mode + from app.core.crypto import encrypt + from app.notify import mail + + if not users or current_mode() == "off": + return 0 + async with _mail_auto_lock, SessionLocal() as session: + cfg = await mail.load_config(session) + if not (cfg.enabled and cfg.default_for_assignees): + return 0 + ids = list((await session.execute(select(User.id).where( + User.username.in_(users), User.kind != "service", User.disabled.is_(False)))).scalars()) + record = await session.get(Setting, MAIL_AUTO_KEY) + done = dict(record.value or {}) if record is not None else {} + fresh = [uid for uid in ids if uid not in done] + if not fresh: + return 0 + have = set((await session.execute(select(NotifyChannel.owner_id).where( + NotifyChannel.scope == "user", NotifyChannel.format == "email", + NotifyChannel.owner_id.in_(fresh)))).scalars()) + made = 0 + for uid in fresh: + if uid not in have: + session.add(NotifyChannel(name=mail.ASSIGNEE_CHANNEL_NAME, scope="user", owner_id=uid, format="email", + url=encrypt(mail.PERSONAL_PLACEHOLDER), events=list(mail.ASSIGNEE_EVENTS))) + made += 1 + done[uid] = utcnow().isoformat() + if record is None: + session.add(Setting(key=MAIL_AUTO_KEY, value=done)) + else: + record.value = done + await session.commit() + return made + + async def notify_task(task_id: str, event: str, *, users: list[str]) -> int: - """待办的通知(task.opened / reassigned / escalated / reminded)。users 是这次要通知到的人(派给的用户名)。返回推了几个渠道。""" + """待办的通知(task.opened / reassigned / escalated / reminded)。users 是这次要通知到的人(派给的用户名)。返回推了几个渠道。 + + 管理员开了「默认发邮件给待办处理人」时,先给还没有个人邮件渠道的处理人建一个(ensure_assignee_mail),这一条就推得到。""" + try: + await ensure_assignee_mail(users) + except Exception: # noqa: BLE001 - 自动建渠道出错不挡这条待办的通知 + logger.exception("给待办 %s 的处理人自动建邮件渠道失败", task_id) async with SessionLocal() as session: task = await session.get(Task, task_id) if task is None: @@ -357,3 +420,109 @@ async def notify_action(action_id: str, event: str) -> int: "subject": {"action_id": action_id, "run_id": rec.run_id, "task_id": task_id}} return await _push(channels, message, subject_kind="action", subject_id=action_id, event=event, run_id=rec.run_id, task_id=task_id) + + +# -------------------------------------------------------------------------- +# 每天的待办汇总、访问密钥快到期(04 细案 B3):什么时候推由 notify/digest.py 在调度循环里定,这里只拼消息、推送 +# -------------------------------------------------------------------------- + +#: 汇总里最多列几条 +DIGEST_ITEMS = 5 + + +def _utc(moment: Any) -> Any: + """库里的时间一律按 UTC 认(待办的时限是不带时区的 UTC),比较前统一成带时区的。""" + from datetime import timezone + + return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc) + + +def _local(moment: Any) -> str: + return _utc(moment).astimezone().strftime("%Y-%m-%d %H:%M") + + +async def notify_digest(user_id: str) -> int: + """给一个人发今天的待办汇总(task.digest):只推他自己订阅了这件事的个人渠道。没有待办不发(不每天发一条「0 条」)。 + + 内容(P4、X6):条数、最早的时限、最多 DIGEST_ITEMS 条(节点在图上的名字,加所在应用和时限)、去待办箱的链接;不放按运行 + 数据渲染出来的待办标题。「派给我的」和待办箱同一个判断(process/tasks._mine)。返回推了几个渠道。""" + from app.auth import current_mode + from app.auth.sessions import principal_of + from app.process.tasks import _mine + + event = "task.digest" + async with SessionLocal() as session: + user = await session.get(User, user_id) + if user is None or user.disabled or user.kind == "service": + return 0 + channels = [c for c in await _channels(session, event, None, [user.username]) + if c.scope == "user" and c.owner_id == user.id] + if not channels: + return 0 + principal = await principal_of(session, user, current_mode()) + query = select(Task).where(Task.status == "pending") + if (clause := _mine(principal)) is not None: + query = query.where(clause) + tasks = list((await session.execute(query)).scalars()) + if not tasks: + return 0 + # 有时限的在前、时限早的在前;没有时限的按开出的先后 + far = _utc(utcnow().replace(year=9999)) + tasks.sort(key=lambda t: (_utc(t.due_at) if t.due_at else far, _utc(t.created_at) if t.created_at else far)) + runs = {r.id: r for r in (await session.execute(select(Run).where( + Run.id.in_({t.run_id for t in tasks[:DIGEST_ITEMS]})))).scalars()} + names = {a.id: a.name for a in (await session.execute(select(App).where( + App.id.in_({t.app_id for t in tasks[:DIGEST_ITEMS] if t.app_id})))).scalars()} + base = await _base_url(session) + lines = [("状态", "待处理"), ("条数", f"{len(tasks)} 条")] + dues = [t.due_at for t in tasks if t.due_at is not None] + if dues: + lines.append(("最早时限", _local(min(dues, key=_utc)))) + for task in tasks[:DIGEST_ITEMS]: + run = runs.get(task.run_id) + node = _label(run.graph if run else None, task.node_id) if task.node_id else task.title[:80] + where = f"应用「{names[task.app_id]}」" if task.app_id in names else ( + f"工作流「{run.workflow_name}」" if run is not None and run.workflow_name else "") + tail = [where] if where else [] + if task.due_at is not None: + tail.append(f"时限 {_local(task.due_at)}") + lines.append(("待办", f"「{node}」" + (f"({','.join(tail)})" if tail else ""))) + if len(tasks) > DIGEST_ITEMS: + lines.append(("另有", f"{len(tasks) - DIGEST_ITEMS} 条,见待办箱")) + message = {"event": event, "status": "pending", "title": f"你有 {len(tasks)} 条待办", + "lines": lines, "links": [("去待办箱", f"{base}/apps?tab=inbox")] if base else [], + "subject": {"user_id": user_id, "tasks": len(tasks)}} + return await _push(channels, message, subject_kind="digest", subject_id=user_id, event=event, run_id=None, + task_id=None) + + +async def notify_key_expiring(key_id: str, days: int) -> int: + """访问密钥快到期了(key.expiring,到期前 7 天、1 天各一次,由 notify/digest 判断时候到没到、记审计)。 + + 个人访问密钥推给拥有者自己订阅了这件事的个人渠道;服务账号的密钥推给订阅了它的全局渠道(服务账号不能登录,没有个人 + 渠道)。消息只有密钥名、开头一截、到期时间和去轮换的链接,不放密钥本身。返回推了几个渠道。""" + from app.db.models_identity import AccessKey + + event = "key.expiring" + async with SessionLocal() as session: + key = await session.get(AccessKey, key_id) + if key is None: + return 0 + owner = await session.get(User, key.owner_user_id) + if owner is None: + return 0 + service = owner.kind == "service" + found = await _channels(session, event, None, [] if service else [owner.username]) + channels = [c for c in found if (c.scope == "global" if service else c.scope == "user" and c.owner_id == owner.id)] + if not channels: + return 0 + base = await _base_url(session) + left = "1 天内" if days <= 1 else f"{days} 天后" + lines = [("状态", "快到期"), ("密钥", f"「{key.name[:80]}」({key.prefix}…)"), + ("拥有者", f"服务账号 {owner.username}" if service else (owner.display_name or owner.username)), + ("到期", _local(key.expires_at))] + message = {"event": event, "status": "expiring", "title": f"访问密钥「{key.name[:80]}」将在{left}到期", + "lines": lines, "links": [("去轮换", f"{base}/settings/keys")] if base else [], + "subject": {"key_id": key_id, "days": days}} + return await _push(channels, message, subject_kind="access_key", subject_id=key_id, event=event, run_id=None, + task_id=None) diff --git a/backend/app/scheduler/loop.py b/backend/app/scheduler/loop.py index 422bacd3..38baf29e 100644 --- a/backend/app/scheduler/loop.py +++ b/backend/app/scheduler/loop.py @@ -123,14 +123,17 @@ async def tick(now: datetime | None = None) -> None: async def process_due(now: datetime | None = None) -> None: """流程的一拍(app/process):待办的提醒、升级、超时驳回(永不自动批准);结果未知的写入按退避去查; 上传了却一直没提交的文件过了时限清掉(app/files,每小时一次);访问密钥的请求记录批量写库、过了保留期的清掉 - (auth/keys.py,每小时清一次:每个请求同步写一行会和运行抢 SQLite 的写锁,所以攒到这一拍写)。 + (auth/keys.py,每小时清一次:每个请求同步写一行会和运行抢 SQLite 的写锁,所以攒到这一拍写);每天到点发待办汇总、 + 访问密钥到期前 7 天和 1 天各提醒一次(notify/digest.py,04 细案 B3:按日期认领、按审计去重,推送放在后台,不拖这一拍)。 各自出错只记日志:这一拍出错不该让调度循环停下,下一拍照常再查。""" from app.auth import keys from app.files import store as files_store + from app.notify import digest from app.process import actions, tasks for name, step in (("待办的时限处理", tasks.tick), ("写入结果的查询", actions.tick), - ("没有提交的上传文件清理", files_store.cleanup_tick), ("访问密钥的请求记录", keys.tick)): + ("没有提交的上传文件清理", files_store.cleanup_tick), ("访问密钥的请求记录", keys.tick), + ("每天的待办汇总", digest.digest_due), ("访问密钥快到期的提醒", digest.keys_due)): try: done = await step(now) if isinstance(done, list) and done: diff --git a/backend/tests/test_notify_digest.py b/backend/tests/test_notify_digest.py new file mode 100644 index 00000000..b6465971 --- /dev/null +++ b/backend/tests/test_notify_digest.py @@ -0,0 +1,251 @@ +"""每天的待办汇总、访问密钥快到期(notify/digest.py + notify/service,docs/design/next-round/04-对外集成与触达.md B3)。 + +守的是: +- 待办汇总到设置的时间(notify.digest_at,按服务端时区)才发,只发给订阅了 task.digest 的个人渠道;内容是条数、最早时限、 + 最多 5 条节点在图上的名字(不是按运行数据渲染出来的待办标题)、去待办箱的链接,不放数; +- 同一天只发一次,重启(进程里记的清掉)也不重发:靠 settings 表里按日期认领的一行;第二天照常发; +- 没有待办的人不发; +- 访问密钥到期前 7 天、1 天各推一次,不多推;个人密钥推本人的个人渠道,服务账号的推全局渠道;每次推都记审计; +- 轮换下来的旧密钥、撤销了的、建的时候就已经在这一档里的(自己设了短有效期),不推。 + +都用 Webhook 渠道和 httpx.MockTransport 接收;人名、应用名是虚构的。 +""" +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone + +import httpx +import pytest +from sqlalchemy import delete, select + +from app.core.crypto import encrypt +from app.db.base import SessionLocal, utcnow +from app.db.models import App, AuditEvent, NotifyChannel, NotifyDelivery, Run, Setting, Task, TaskAssignment, User +from app.notify import core, digest, service +from tests.fakes import auth_env + +URL = "https://hooks.example.com/agentlab/digest-91c2" + + +@pytest.fixture +def received(monkeypatch): + got: list[dict] = [] + + def handler(request: httpx.Request) -> httpx.Response: + got.append(json.loads(request.content)) + return httpx.Response(200, json={"ok": True}) + + monkeypatch.setattr(core, "_transport", httpx.MockTransport(handler)) + monkeypatch.setattr(core, "RETRY_DELAYS", (0, 0, 0)) + + async def public(url: str) -> None: + return None + + monkeypatch.setattr(service, "personal_url_problem", public) + return got + + +@pytest.fixture(autouse=True) +async def login_on(received): + """开着账号密码登录(个人渠道挂在认证过的账号上)。依赖 received:收尾时等推送推完,假的接收端还在。""" + await auth_env.reset() + digest.forget() + await auth_env.turn_on_local() + async with SessionLocal() as session: + session.add(Setting(key="notify", value={"public_base_url": "https://agentlab.example.com", + "digest_at": "09:00"})) + await session.commit() + yield + await service.drain() + async with SessionLocal() as session: + for model in (NotifyDelivery, NotifyChannel, TaskAssignment, Task): + await session.execute(delete(model)) + await session.execute(delete(Run).where(Run.workflow_name == "汇总测试")) + await session.execute(delete(App).where(App.name == "报销审批")) + await session.execute(delete(Setting).where(Setting.key.like(digest.STATE_PREFIX + "%"))) + await session.execute(delete(Setting).where(Setting.key == "notify")) + await session.commit() + digest.forget() + await auth_env.reset() + + +def local(hour: int, minute: int, *, day: int = 16) -> datetime: + """服务端所在时区的某个时刻(汇总按服务端时区算)→ UTC。""" + return datetime(2026, 10, day, hour, minute).astimezone().astimezone(timezone.utc) + + +async def personal(user: User, events: list[str]) -> NotifyChannel: + async with SessionLocal() as session: + row = NotifyChannel(name=f"个人-{user.username}", scope="user", owner_id=user.id, format="generic", + url=encrypt(URL), events=events) + session.add(row) + await session.commit() + return row + + +async def tasks_for(username: str, n: int, *, label: str = "主管审批") -> None: + graph = {"nodes": [{"id": "review", "type": "human", "data": {"label": label}}], "edges": []} + async with SessionLocal() as session: + app = App(name="报销审批", published_version=1) + session.add(app) + run = Run(workflow_name="汇总测试", status="interrupted", graph=graph, input={"amount": 45678}) + session.add(run) + await session.flush() + for i in range(n): + task = Task(run_id=run.id, node_id="review", app_id=app.id, kind="approve", + title=f"审批 45678 元的报销 #{i}", due_at=(utcnow() + timedelta(hours=i + 1)).replace(tzinfo=None)) + session.add(task) + await session.flush() + session.add(TaskAssignment(task_id=task.id, principal_kind="user", principal=username)) + await session.commit() + + +# -------------------------------------------------------------------------- +# 待办汇总 +# -------------------------------------------------------------------------- + + +async def test_digest_is_sent_once_a_day_at_the_set_time(received): + ana = await auth_env.make_user("ana", "user") + bo = await auth_env.make_user("bo", "user") + await personal(ana, ["task.digest"]) + await personal(bo, ["task.opened"]) # 没订阅汇总 + await tasks_for("ana", 7) + await tasks_for("bo", 2) + + assert await digest.digest_due(local(8, 59)) == [], "还没到点" + assert await digest.digest_due(local(9, 0)) == [ana.id] + await service.drain() + [body] = received + assert body["event"] == "task.digest" and body["title"] == "你有 7 条待办" + text = body["text"] + assert "条数:7 条" in text and "最早时限" in text + assert text.count("「主管审批」") == 5, "最多列 5 条,用节点在图上的名字" + assert "另有:2 条" in text and "报销审批" in text + assert "45678" not in json.dumps(body, ensure_ascii=False), "不放按运行数据渲染出来的标题、不放数" + assert body["links"] == [{"label": "去待办箱", "url": "https://agentlab.example.com/apps?tab=inbox"}] + + assert await digest.digest_due(local(9, 15)) == [], "同一天只发一次" + digest.forget() # 重启:进程里记的没了 + assert await digest.digest_due(local(10, 0)) == [], "重启了也不重发:认领在 settings 表里" + await service.drain() + assert len(received) == 1 + async with SessionLocal() as session: + rows = (await session.execute(select(NotifyDelivery).where(NotifyDelivery.event == "task.digest"))).scalars() + assert [(r.subject_kind, r.subject_id, r.status) for r in rows] == [("digest", ana.id, "ok")] + + assert await digest.digest_due(local(9, 1, day=17)) == [ana.id], "第二天照常" + + +async def test_digest_follows_the_configured_time_and_skips_people_with_nothing_pending(received): + cy = await auth_env.make_user("cy", "user") + await personal(cy, ["task.digest"]) + async with SessionLocal() as session: + row = await session.get(Setting, "notify") + row.value = {**row.value, "digest_at": "17:30"} + await session.commit() + assert await digest.digest_due(local(9, 0)) == [] + assert await digest.digest_due(local(17, 30)) == [cy.id] + await service.drain() + assert received == [], "没有待办不发「0 条」" + + +async def test_claim_rows_are_not_listed_as_settings(received): + await digest.digest_due(local(9, 0)) + async with auth_env.client() as c: + root = await auth_env.login("root") + body = (await root.get("/api/settings")).json() + assert not [k for k in body if k.startswith(digest.STATE_PREFIX)] + + +# -------------------------------------------------------------------------- +# 访问密钥快到期 +# -------------------------------------------------------------------------- + + +async def _expiring_audits(key_id: str) -> list[int]: + async with SessionLocal() as session: + rows = (await session.execute(select(AuditEvent).where(AuditEvent.action == "access_key.expiring", + AuditEvent.subject_id == key_id))).scalars() + return sorted(r.data["days"] for r in rows) + + +async def _age(key, days: float) -> None: + from app.db.models_identity import AccessKey + + async with SessionLocal() as session: + row = await session.get(AccessKey, key.id) + row.created_at = utcnow() - timedelta(days=days) + await session.commit() + + +async def test_personal_key_reminds_its_owner_at_7_and_1_days(received): + await auth_env.personal_keys(True) + dee = await auth_env.make_user("dee", "user") + mine = await personal(dee, ["key.expiring"]) + async with SessionLocal() as session: + session.add(NotifyChannel(name="全局", scope="global", format="generic", url=encrypt(URL), + events=["key.expiring"])) + await session.commit() + key, secret = await auth_env.make_key(dee, days=30, name="周报脚本") + await _age(key, 60) + now = utcnow() + assert await digest.keys_due(now) == [], "离到期还有 30 天" + assert await digest.keys_due(now + timedelta(days=23, hours=1)) == [key.id] + await service.drain() + [body] = received + assert body["event"] == "key.expiring" and "周报脚本" in body["title"] and "7 天后" in body["title"] + assert secret not in json.dumps(body) and key.prefix in body["text"] + assert body["links"] == [{"label": "去轮换", "url": "https://agentlab.example.com/settings/keys"}] + async with SessionLocal() as session: + to = (await session.execute(select(NotifyDelivery.channel_id))).scalars().all() + assert to == [mine.id], "个人密钥只推本人,不推全局渠道" + assert await digest.keys_due(now + timedelta(days=24)) == [], "这一档推过了" + digest.forget() + assert await digest.keys_due(now + timedelta(days=25)) == [], "重启后按审计认得出推过了" + assert await digest.keys_due(now + timedelta(days=29, hours=1)) == [key.id], "到期前 1 天再推一次" + assert await digest.keys_due(now + timedelta(days=29, hours=12)) == [] + assert await _expiring_audits(key.id) == [1, 7] + await service.drain() + assert len(received) == 2 and "1 天内" in received[1]["title"] + + +async def test_service_account_keys_remind_global_channels(received): + svc = await auth_env.make_service("svc-portal") + async with SessionLocal() as session: + glob = NotifyChannel(name="运维群", scope="global", format="generic", url=encrypt(URL), events=["key.expiring"]) + session.add(glob) + await session.commit() + key, _ = await auth_env.make_key(svc, days=5, name="门户拉数") + await _age(key, 30) + assert await digest.keys_due(utcnow()) == [key.id] + await service.drain() + [body] = received + assert "服务账号 svc-portal" in body["text"] + async with SessionLocal() as session: + assert (await session.execute(select(NotifyDelivery.channel_id))).scalars().all() == [glob.id] + + +async def test_keys_that_should_not_remind(received): + from app.db.models_identity import AccessKey + + svc = await auth_env.make_service("svc-bi") + async with SessionLocal() as session: + session.add(NotifyChannel(name="运维群", scope="global", format="generic", url=encrypt(URL), + events=["key.expiring"])) + await session.commit() + short, _ = await auth_env.make_key(svc, days=3, name="临时") # 建的时候就只有 3 天:不推 7 天那一档 + old, _ = await auth_env.make_key(svc, days=0.5, name="轮换下来的") + await _age(old, 90) + new, _ = await auth_env.make_key(svc, days=90, name="轮换上去的") + revoked, _ = await auth_env.make_key(svc, days=2, name="撤销了的") + await _age(revoked, 30) + async with SessionLocal() as session: + (await session.get(AccessKey, new.id)).rotated_from = old.id + (await session.get(AccessKey, revoked.id)).revoked_at = utcnow() + await session.commit() + assert await digest.keys_due(utcnow()) == [] + assert await digest.keys_due(utcnow() + timedelta(days=2, hours=1)) == [short.id], "1 天那一档照推" + await service.drain() + assert len(received) == 1 diff --git a/backend/tests/test_notify_events.py b/backend/tests/test_notify_events.py index 211d4dc1..98d166f7 100644 --- a/backend/tests/test_notify_events.py +++ b/backend/tests/test_notify_events.py @@ -30,8 +30,11 @@ from app.notify import service TERMS_TS = Path(__file__).resolve().parents[2] / "frontend" / "src" / "lib" / "terms.ts" URL = "https://hooks.example.com/agentlab/notify-events-5d1c" -#: 本轮登记的六个(总方案 X6):都还没有推送 +#: 本轮登记的六个(总方案 X6) THIS_ROUND = ("source.changed", "ledger.recall", "eval.regressed", "eval.failed", "task.digest", "key.expiring") +#: 其中已经实现了推送、打开了订阅的(推给哪几条路):每天的待办汇总只推个人渠道;访问密钥快到期,个人密钥推本人、服务账号的 +#: 推全局渠道(第 3 波 W3-d,notify/digest.py) +PUSHED_THIS_ROUND = {"task.digest": {"user"}, "key.expiring": {"global", "user"}} @pytest.fixture @@ -54,7 +57,10 @@ def test_this_rounds_events_are_registered_once_and_not_pushed_yet(): for name in THIS_ROUND: assert name in service.EVENTS, f"{name} 没有登记" assert service.EVENTS[name].label, name - assert service.EVENTS[name].scopes == (), f"{name} 还没有推送:不该有人订阅得到" + if name in PUSHED_THIS_ROUND: + assert set(service.EVENTS[name].scopes) == PUSHED_THIS_ROUND[name], name + else: + assert service.EVENTS[name].scopes == (), f"{name} 还没有推送:不该有人订阅得到" assert len({e.label for e in service.EVENTS.values()}) == len(service.EVENTS), "两个事件叫成了同一句" assert service.EVENT_LABEL == {k: v.label for k, v in service.EVENTS.items()} @@ -66,6 +72,7 @@ def test_events_that_are_pushed_today_keep_their_audience(): "task.opened": every, "run.finished": every, "run.failed": every, "action.unknown": every, # 发给提交人自己(和订阅了它的全局渠道),不归哪个应用 "review.done": {"global", "user"}, + **PUSHED_THIS_ROUND, } for event in service.EVENTS.values(): assert set(event.scopes) <= every diff --git a/backend/tests/test_notify_mail.py b/backend/tests/test_notify_mail.py new file mode 100644 index 00000000..d2146d6a --- /dev/null +++ b/backend/tests/test_notify_mail.py @@ -0,0 +1,421 @@ +"""邮件:通知渠道的第五种格式(notify/mail.py,docs/design/next-round/04-对外集成与触达.md B2)。 + +全程用假的 SMTP 工厂(mail._smtp_factory),不连任何邮件服务器。守的是: +- 个人邮件渠道只发到拥有者在名录里的账号邮箱;账号没有邮箱时记一条失败的投递、写明原因;个人渠道不收地址; +- 全局、应用的邮件渠道填固定的收件地址(最多 20 个,格式不对的拒收);邮件服务没启用时不收新的邮件渠道; +- 4xx(临时错误)和连不上按 1/2/4 秒重试,5xx 不重试;报错里绝不出现口令; +- 邮件里只有标题、状态、链接,纯文本 + 转义过的 HTML,带 List-Unsubscribe;投递记录成功写「已交给邮件服务器」(不说已送达); +- 设置的 mail 一组:口令加密存、读的时候只说配没配;启用时要配全;发件人名称不许换行;只有管理员能改; +- 发测试邮件只发到管理员自己的账号邮箱:关登录、账号没有邮箱时 422 说清怎么办; +- 管理员开了「默认发邮件给待办处理人」:第一次派到待办的人自动有一个个人邮件渠道(默认订阅三项),本人删了不再建回来; + 开关缺省关时不建。 + +人名、地址、主机都是虚构的。 +""" +from __future__ import annotations + +import json +import smtplib +from types import SimpleNamespace + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import delete, select + +from app.core.crypto import encrypt +from app.db.base import SessionLocal +from app.db.models import NotifyChannel, NotifyDelivery, Setting, User +from app.notify import mail, service +from app.notify.core import Message + +PASSWORD = "Sm7p-Pa55word-Zq" + + +class FakeSMTP: + """假的 SMTP 连接:记下登录、发出的信;box.failures 里排着的异常按顺序在 send_message 上抛。""" + + def __init__(self, box: SimpleNamespace) -> None: + self.box = box + + def starttls(self, context=None) -> None: + self.box.tls += 1 + + def login(self, username: str, password: str) -> None: + self.box.logins.append((username, password)) + if self.box.auth_error: + # 有的服务器会把收到的内容回显在错误里:报错里也不能出现口令 + raise smtplib.SMTPAuthenticationError(535, f"5.7.8 bad credentials {password}".encode()) + + def send_message(self, em, from_addr=None, to_addrs=None): + if self.box.failures: + raise self.box.failures.pop(0) + self.box.sent.append(SimpleNamespace(from_addr=from_addr, to=list(to_addrs or []), em=em)) + return {} + + def quit(self) -> None: + self.box.quits += 1 + + def close(self) -> None: + pass + + +@pytest.fixture +def smtp(monkeypatch): + box = SimpleNamespace(sent=[], logins=[], failures=[], connects=[], refuse=0, tls=0, quits=0, auth_error=False) + + def factory(host, port, security, timeout): + box.connects.append((host, port, security, timeout)) + if box.refuse: + box.refuse -= 1 + raise ConnectionRefusedError("refused") + return FakeSMTP(box) + + monkeypatch.setattr(mail, "_smtp_factory", factory) + monkeypatch.setattr(mail, "RETRY_DELAYS", (0, 0, 0)) + return box + + +@pytest.fixture(autouse=True) +async def clean(): + yield + await service.drain() + async with SessionLocal() as session: + await session.execute(delete(NotifyDelivery)) + await session.execute(delete(NotifyChannel)) + await session.execute(delete(Setting).where(Setting.key.in_([mail.GROUP, "notify", service.MAIL_AUTO_KEY]))) + await session.execute(delete(User).where(User.username.like("mail-%"))) + await session.commit() + + +async def configure(**over) -> None: + value = mail.stored_value({"enabled": True, "host": "smtp.example.com", "port": 587, "security": "starttls", + "username": "agentlab", "password": PASSWORD, "from_addr": "agentlab@example.com", + "from_name": "AgentLab", **over}) + async with SessionLocal() as session: + row = await session.get(Setting, mail.GROUP) + if row is None: + session.add(Setting(key=mail.GROUP, value=value)) + else: + row.value = value + notify = await session.get(Setting, "notify") + if notify is None: + session.add(Setting(key="notify", value={"public_base_url": "https://agentlab.example.com"})) + await session.commit() + + +async def person(name: str, email: str | None) -> User: + async with SessionLocal() as session: + user = User(username=f"mail-{name}", display_name=name, role="user", email=email) + session.add(user) + await session.commit() + await session.refresh(user) + return user + + +async def channel(scope: str, owner_id: str | None = None, to: str = mail.PERSONAL_PLACEHOLDER, + events: list[str] | None = None) -> NotifyChannel: + async with SessionLocal() as session: + row = NotifyChannel(name=f"邮件-{scope}", scope=scope, owner_id=owner_id, format="email", url=encrypt(to), + events=events or ["task.opened"]) + session.add(row) + await session.commit() + await session.refresh(row) + return row + + +MESSAGE = {"event": "task.opened", "status": "pending", "title": "新的待办:审批 付款", + "lines": [("状态", "待处理"), ("所在", "应用「报销 & 付款」")], + "links": [("去处理", "https://agentlab.example.com/apps/inbox/t1")]} + + +async def _deliveries() -> list[NotifyDelivery]: + async with SessionLocal() as session: + return list((await session.execute(select(NotifyDelivery))).scalars()) + + +# -------------------------------------------------------------------------- +# 发到谁 +# -------------------------------------------------------------------------- + + +async def test_personal_mail_goes_to_the_account_email_only(smtp): + await configure() + ana = await person("ana", "ana@example.com") + row = await channel("user", ana.id) + sent = await service._push([row], MESSAGE, subject_kind="task", subject_id="t1", event="task.opened", + run_id=None, task_id="t1") + assert sent == 1 + [mail_out] = smtp.sent + assert mail_out.to == ["ana@example.com"] and mail_out.from_addr == "agentlab@example.com" + assert smtp.logins == [("agentlab", PASSWORD)] and smtp.tls == 1 + em = mail_out.em + assert em["Subject"] == "【AgentLab】新的待办:审批 付款" + assert em["List-Unsubscribe"] == "" + assert em["Auto-Submitted"] == "auto-generated" + text = em.get_body(("plain",)).get_content() + html = em.get_body(("html",)).get_content() + assert "状态:待处理" in text and "https://agentlab.example.com/apps/inbox/t1" in text + assert "<b>付款</b>" in html and "付款" not in html, "HTML 里的内容一律转义" + assert "报销 & 付款" in html + [delivery] = await _deliveries() + assert (delivery.status, delivery.error, delivery.http_status) == ("ok", None, None) + + +async def test_personal_mail_without_account_email_is_a_failed_delivery_with_the_reason(smtp): + await configure() + bo = await person("bo", None) + row = await channel("user", bo.id) + await service._push([row], MESSAGE, subject_kind="task", subject_id="t1", event="task.opened", run_id=None, + task_id="t1") + assert smtp.sent == [] and smtp.connects == [], "没有邮箱就不连服务器" + [delivery] = await _deliveries() + assert delivery.status == "failed" and delivery.error == mail.NO_EMAIL + assert "设置 → 身份" in delivery.error + + +async def test_global_mail_goes_to_the_fixed_addresses(smtp): + await configure() + row = await channel("global", to="ops@example.com, lead@example.com") + await service._push([row], MESSAGE, subject_kind="task", subject_id="t1", event="task.opened", run_id=None, + task_id="t1") + assert smtp.sent[0].to == ["ops@example.com", "lead@example.com"] + + +async def test_disabled_mail_service_records_why(smtp): + await configure(enabled=False) + row = await channel("global", to="ops@example.com") + await service._push([row], MESSAGE, subject_kind="task", subject_id="t1", event="task.opened", run_id=None, + task_id="t1") + [delivery] = await _deliveries() + assert delivery.status == "failed" and delivery.error == mail.NOT_ENABLED and smtp.connects == [] + + +# -------------------------------------------------------------------------- +# 重试与报错 +# -------------------------------------------------------------------------- + + +def _cfg() -> mail.MailConfig: + return mail.config_of(mail.stored_value({"enabled": True, "host": "smtp.example.com", "port": 587, + "security": "starttls", "username": "agentlab", "password": PASSWORD, + "from_addr": "agentlab@example.com"})) + + +async def test_temporary_errors_and_refused_connections_are_retried(smtp): + smtp.failures = [smtplib.SMTPDataError(451, b"4.3.0 try again later")] + smtp.refuse = 1 + out = await mail.send(_cfg(), ["ana@example.com"], Message(title="t"), label="测试") + assert out.ok is True and out.attempts == 3, "连不上一次、451 一次,第三次交给了邮件服务器" + assert len(smtp.sent) == 1 + + +async def test_permanent_errors_are_not_retried(smtp): + smtp.failures = [smtplib.SMTPDataError(550, b"5.7.1 message rejected")] + out = await mail.send(_cfg(), ["ana@example.com"], Message(title="t"), label="测试") + assert out.ok is False and out.attempts == 1 and out.retryable is False + assert "550" in out.error and "smtp.example.com" in out.error + + +async def test_retries_stop_after_three(smtp): + smtp.refuse = 10 + out = await mail.send(_cfg(), ["ana@example.com"], Message(title="t"), label="测试") + assert out.ok is False and out.attempts == 4 and len(smtp.connects) == 4 + assert out.error == "无法连接邮件服务器 smtp.example.com:587" + + +async def test_errors_never_contain_the_password(smtp, caplog): + smtp.auth_error = True + out = await mail.send(_cfg(), ["ana@example.com"], Message(title="t"), label="测试") + assert out.ok is False and out.attempts == 1, "认证失败(5xx)不重试" + assert PASSWORD not in (out.error or "") and "535" in out.error + assert PASSWORD not in caplog.text + smtp.auth_error = False + smtp.failures = [smtplib.SMTPSenderRefused(553, f"sender {PASSWORD} refused".encode(), "agentlab@example.com")] + out = await mail.send(_cfg(), ["ana@example.com"], Message(title="t"), label="测试") + assert PASSWORD not in (out.error or "") + + +async def test_partially_refused_recipients_are_said_plainly(smtp, monkeypatch): + monkeypatch.setattr(FakeSMTP, "send_message", lambda self, em, from_addr=None, to_addrs=None: {"x@example.com": (550, b"no")}) + out = await mail.send(_cfg(), ["ana@example.com", "x@example.com"], Message(title="t"), label="测试") + assert out.ok is True and out.error == "已交给邮件服务器,其中 1 个收件地址被拒收" + + +def test_ssl_security_uses_no_starttls(smtp): + cfg = mail.config_of(mail.stored_value({"enabled": True, "host": "smtp.example.com", "port": 465, + "security": "ssl", "from_addr": "agentlab@example.com"})) + out = mail._send_once(cfg, ["ana@example.com"], mail.build(cfg, ["ana@example.com"], Message(title="t"), + unsubscribe=None)) + assert out.ok and smtp.tls == 0 and smtp.logins == [], "没填用户名不登录" + assert smtp.connects[0][:3] == ("smtp.example.com", 465, "ssl") and smtp.connects[0][3] == mail.TIMEOUT_S + + +# -------------------------------------------------------------------------- +# 渠道接口 +# -------------------------------------------------------------------------- + + +@pytest.fixture +async def client(): + from app.main import app + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +async def test_mail_channels_need_the_mail_service_and_valid_addresses(client, smtp): + body = {"name": "运维邮件", "scope": "global", "format": "email", "url": "ops@example.com", "events": ["task.opened"]} + refused = await client.post("/api/notify/channels", json=body) + assert refused.status_code == 422 and refused.json()["detail"] == mail.NOT_ENABLED + await configure() + too_many = ", ".join(f"p{i}@example.com" for i in range(21)) + r = await client.post("/api/notify/channels", json={**body, "url": too_many}) + assert r.status_code == 422 and "最多 20 个" in r.json()["detail"] + r = await client.post("/api/notify/channels", json={**body, "url": "ops@example.com\nnot-an-address"}) + assert r.status_code == 422 and "not-an-address" in r.json()["detail"] + made = await client.post("/api/notify/channels", json={**body, "url": "ops@example.com;lead@example.com"}) + assert made.status_code == 201, made.text + row = made.json() + assert row["format"] == "email" and row["mail_to"] == ["ops@example.com", "lead@example.com"] + assert row["url_host"] == "" and row["signed"] is False + listed = (await client.get("/api/notify/channels")).json() + assert listed["mail"] == {"enabled": True, "account_email": None}, "关登录时没有账号邮箱" + tested = (await client.post(f"/api/notify/channels/{row['id']}/test")).json() + assert tested["ok"] is True and tested["message"].startswith("已交给邮件服务器") + assert "是否送达以邮件服务器为准" in tested["message"] + deliveries = (await client.get(f"/api/notify/deliveries?channel_id={row['id']}")).json()["items"] + assert [(d["event"], d["status"], d["format"]) for d in deliveries] == [("test", "ok", "email")] + # 只改订阅、不传地址:照旧 + kept = await client.patch(f"/api/notify/channels/{row['id']}", json={"events": ["task.opened", "action.unknown"]}) + assert kept.status_code == 200 and kept.json()["mail_to"] == ["ops@example.com", "lead@example.com"] + # 换成 Webhook 要填 Webhook 地址 + back = await client.patch(f"/api/notify/channels/{row['id']}", json={"format": "generic"}) + assert back.status_code == 422 and "Webhook 地址" in back.json()["detail"] + + +async def test_personal_mail_channel_takes_no_address(smtp): + from tests.fakes import auth_env + + await configure() + await auth_env.reset() + try: + await auth_env.turn_on_local() + await auth_env.make_user("mail-uma", "user", email="uma@example.com") + uma = await auth_env.login("mail-uma") + r = await uma.post("/api/notify/channels", json={"name": "我的邮件", "scope": "user", "format": "email", + "url": "someone@example.com", "events": ["task.opened"]}) + assert r.status_code == 422 and "账号邮箱" in r.json()["detail"], "个人渠道不能填任意地址(不当中继)" + r = await uma.post("/api/notify/channels", json={"name": "我的邮件", "scope": "user", "format": "email", + "events": ["task.opened", "task.digest"]}) + assert r.status_code == 201, r.text + assert r.json()["mail_to"] == ["uma@example.com"] + listed = (await uma.get("/api/notify/channels")).json() + assert listed["mail"] == {"enabled": True, "account_email": "uma@example.com"} + tested = (await uma.post(f"/api/notify/channels/{r.json()['id']}/test")).json() + assert tested["ok"] is True and smtp.sent[-1].to == ["uma@example.com"] + finally: + await auth_env.reset() + + +# -------------------------------------------------------------------------- +# 设置 +# -------------------------------------------------------------------------- + + +async def test_mail_settings_keep_the_password_encrypted_and_hidden(client): + put = await client.put("/api/settings", json={"values": {"mail": { + "enabled": True, "host": "smtp.example.com", "port": 465, "security": "ssl", "username": "agentlab", + "password": PASSWORD, "from_addr": "agentlab@example.com", "from_name": "流程助手"}}}) + assert put.status_code == 200, put.text + shown = put.json()["mail"] + assert shown["password"] is None and shown["password_set"] is True and PASSWORD not in json.dumps(put.json()) + async with SessionLocal() as session: + stored = (await session.get(Setting, "mail")).value + assert stored["password"] != PASSWORD and PASSWORD not in json.dumps(stored) + assert mail.config_of(stored).password == PASSWORD + # 整组回写(口令是 null、带着 password_set):口令照旧 + again = await client.put("/api/settings", json={"values": {"mail": {**shown, "from_name": "AgentLab"}}}) + assert again.status_code == 200 and again.json()["mail"]["password_set"] is True + async with SessionLocal() as session: + assert mail.config_of((await session.get(Setting, "mail")).value).password == PASSWORD + # 空串表示去掉 + cleared = await client.put("/api/settings", json={"values": {"mail": {"password": ""}}}) + assert cleared.json()["mail"]["password_set"] is False + assert cleared.json()["mail"]["host"] == "smtp.example.com", "只给一项的不冲掉别的" + + +@pytest.mark.parametrize("value, fragment", [ + ({"enabled": True}, "SMTP 服务器"), + ({"enabled": True, "host": "smtp.example.com"}, "发件地址"), + ({"host": "smtp://smtp.example.com"}, "主机名"), + ({"port": 70000}, "端口"), + ({"security": "tls"}, "加密方式"), + ({"from_addr": "not-an-address"}, "发件地址"), + ({"from_name": "AgentLab\r\nBcc: x@example.com"}, "换行"), + ({"relay": True}, "不支持「relay」"), +]) +async def test_mail_settings_are_validated(client, value, fragment): + put = await client.put("/api/settings", json={"values": {"mail": value}}) + assert put.status_code == 422, put.text + assert fragment in put.json()["detail"] + + +async def test_mail_settings_are_admin_only_and_test_mail_goes_to_the_admin(smtp): + from tests.fakes import auth_env + + await configure() + await auth_env.reset() + try: + async with auth_env.client() as off: + r = await off.post("/api/settings/mail/test") + assert r.status_code == 422 and r.json()["code"] == "mail_test_no_address", "关登录时没有账号邮箱" + root = await auth_env.turn_on_local() + r = await root.post("/api/settings/mail/test") + assert r.status_code == 422 and "没有邮箱地址" in r.json()["detail"] + async with SessionLocal() as session: + admin = (await session.execute(select(User).where(User.username == "root"))).scalar_one() + admin.email = "root@example.com" + await session.commit() + r = await root.post("/api/settings/mail/test") + assert r.status_code == 200 and r.json()["ok"] is True, r.text + assert "已交给邮件服务器" in r.json()["message"] and smtp.sent[-1].to == ["root@example.com"] + await auth_env.make_user("mail-bea", "builder") + bea = await auth_env.login("mail-bea") + assert (await bea.get("/api/settings")).json()["mail"]["password"] is None, "搭建者读得到,看不到口令" + assert (await bea.put("/api/settings", json={"values": {"mail": {"enabled": False}}})).status_code == 403 + assert (await bea.post("/api/settings/mail/test")).status_code == 403 + finally: + await auth_env.reset() + + +# -------------------------------------------------------------------------- +# 默认发邮件给待办处理人 +# -------------------------------------------------------------------------- + + +async def test_assignees_get_a_personal_mail_channel_only_when_the_admin_turns_it_on(smtp): + from tests.fakes import auth_env + + await auth_env.reset() + try: + await auth_env.turn_on_local() + cy = await person("cy", "cy@example.com") + await configure() + assert await service.ensure_assignee_mail([cy.username]) == 0, "缺省关:不建" + await configure(default_for_assignees=True) + assert await service.ensure_assignee_mail([cy.username]) == 1 + async with SessionLocal() as session: + [row] = (await session.execute(select(NotifyChannel).where(NotifyChannel.owner_id == cy.id))).scalars() + assert (row.scope, row.format, row.enabled) == ("user", "email", True) + assert row.events == ["task.opened", "action.unknown", "review.done"] + await session.delete(row) + await session.commit() + assert await service.ensure_assignee_mail([cy.username]) == 0, "本人删了就是不要:不再建回来" + finally: + await auth_env.reset() + + +async def test_assignee_mail_is_not_created_when_login_is_off(smtp): + dee = await person("dee", "dee@example.com") + await configure(default_for_assignees=True) + assert await service.ensure_assignee_mail([dee.username]) == 0, "关登录没有认证过的账号,个人渠道无从挂起" diff --git a/backend/tests/test_public_url.py b/backend/tests/test_public_url.py index c13667dc..fb96fcfc 100644 --- a/backend/tests/test_public_url.py +++ b/backend/tests/test_public_url.py @@ -109,13 +109,16 @@ async def test_env_locks_the_setting(monkeypatch): async with client() as c: body = (await c.get("/api/settings")).json() assert body["public_url"] == {"value": ENV_URL, "env_locked": True} - assert body[NOTIFY_GROUP] == {"public_base_url": None}, "notify 一组照旧是库里存的那一份" + assert body[NOTIFY_GROUP]["public_base_url"] is None, "notify 一组照旧是库里存的那一份" r = await put_address(c, SET_URL) assert r.status_code == 409, r.text assert r.json()["code"] == "public_url_env_locked" and r.json()["field"] == "notify.public_base_url" assert "AGENTLAB_PUBLIC_URL" in r.json()["detail"] # 没改这一项(整组原样写回)不算改:不挡同一请求里别的设置 assert (await put_address(c, None)).status_code == 200 + # 同一组里别的项(待办汇总的时间)照常能改:环境变量只锁对外访问地址这一项 + r = await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"digest_at": "08:15"}}}) + assert r.status_code == 200 and r.json()[NOTIFY_GROUP]["digest_at"] == "08:15" # 只读的 public_url 一组写不进库 r = await c.put("/api/settings", json={"values": {"public_url": {"value": SET_URL, "env_locked": False}}}) assert r.status_code == 200 diff --git a/backend/tests/test_settings_apps_auth.py b/backend/tests/test_settings_apps_auth.py index a139641c..57f7ca6c 100644 --- a/backend/tests/test_settings_apps_auth.py +++ b/backend/tests/test_settings_apps_auth.py @@ -33,16 +33,17 @@ async def clean(): async def test_daily_limit_is_a_registered_setting_with_default_200(): async with auth_env.client() as c: body = (await c.get("/api/settings")).json() - assert body[APPS_GROUP] == {"on_demand_daily_limit": 200, "user_personal_apps": False} + assert body[APPS_GROUP] == {"on_demand_daily_limit": 200, "user_personal_apps": False, "share_links": False} put = await c.put("/api/settings", json={"values": {APPS_GROUP: {"on_demand_daily_limit": 30}}}) assert put.status_code == 200, put.text - assert put.json()[APPS_GROUP] == {"on_demand_daily_limit": 30, "user_personal_apps": False} + assert put.json()[APPS_GROUP] == {"on_demand_daily_limit": 30, "user_personal_apps": False, "share_links": False} assert (await c.get("/api/settings")).json()[APPS_GROUP] == {"on_demand_daily_limit": 30, - "user_personal_apps": False} + "user_personal_apps": False, "share_links": False} async with SessionLocal() as session: - assert await apps_settings(session) == {"on_demand_daily_limit": 30, "user_personal_apps": False} - assert apps_values({"on_demand_daily_limit": "坏的", "user_personal_apps": "开"}) == { - "on_demand_daily_limit": 200, "user_personal_apps": False}, "存坏了的按默认" + assert await apps_settings(session) == {"on_demand_daily_limit": 30, "user_personal_apps": False, + "share_links": False} + assert apps_values({"on_demand_daily_limit": "坏的", "user_personal_apps": "开", "share_links": 1}) == { + "on_demand_daily_limit": 200, "user_personal_apps": False, "share_links": False}, "存坏了的按默认" @pytest.mark.parametrize("value, fragment", [ @@ -81,7 +82,7 @@ async def test_auth_settings_are_not_read_or_written_here(): await auth_env.make_user("bob", "builder") bob = await auth_env.login("bob") assert (await bob.get("/api/settings")).json()[APPS_GROUP] == {"on_demand_daily_limit": 200, - "user_personal_apps": False} + "user_personal_apps": False, "share_links": False} assert (await bob.put("/api/settings", json={"values": {APPS_GROUP: {"on_demand_daily_limit": 1}}}) ).status_code == 403, "设置页的应用一组只有管理员能改" assert (await bob.put("/api/settings", json={"values": {APPS_GROUP: {"user_personal_apps": True}}}) diff --git a/backend/tests/test_settings_notify.py b/backend/tests/test_settings_notify.py index 5e3d4312..64b1fddb 100644 --- a/backend/tests/test_settings_notify.py +++ b/backend/tests/test_settings_notify.py @@ -1,4 +1,5 @@ -"""通知设置(api/settings.py 的 notify 一组):定时任务的 Webhook 里拼运行链接用的对外访问地址。 +"""通知设置(api/settings.py 的 notify 一组):定时任务的 Webhook 里拼运行链接用的对外访问地址,和每天的待办汇总几点发 +(digest_at,缺省 09:00,04 细案 B3)。 - 默认没填(null),通知里就不附运行链接 - 只收 http(s) 开头的完整地址;末尾的 / 去掉、首尾空白去掉,空串当没填;带 ? 或 # 的拒收 @@ -31,9 +32,9 @@ def client() -> AsyncClient: async def test_default_is_no_link(): async with client() as c: body = (await c.get("/api/settings")).json() - assert body["notify"] == {"public_base_url": None} + assert body["notify"] == {"public_base_url": None, "digest_at": "09:00"} async with SessionLocal() as session: - assert await notify_settings(session) == {"public_base_url": None} + assert await notify_settings(session) == {"public_base_url": None, "digest_at": "09:00"} @pytest.mark.parametrize("given, stored", [ @@ -47,10 +48,10 @@ async def test_saved_and_normalized(given, stored): async with client() as c: put = await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"public_base_url": given}}}) assert put.status_code == 200, put.text - assert put.json()["notify"] == {"public_base_url": stored} - assert (await c.get("/api/settings")).json()["notify"] == {"public_base_url": stored} + assert put.json()["notify"] == {"public_base_url": stored, "digest_at": "09:00"} + assert (await c.get("/api/settings")).json()["notify"] == {"public_base_url": stored, "digest_at": "09:00"} async with SessionLocal() as session: - assert await notify_settings(session) == {"public_base_url": stored} + assert await notify_settings(session) == {"public_base_url": stored, "digest_at": "09:00"} @pytest.mark.parametrize("value, fragment", [ @@ -73,5 +74,27 @@ async def test_invalid_values_are_refused_in_chinese(value, fragment): def test_broken_stored_value_reads_as_unset(): - assert notify_values({"public_base_url": "不是地址"}) == {"public_base_url": None} - assert notify_values("坏掉的") == {"public_base_url": None} + assert notify_values({"public_base_url": "不是地址", "digest_at": "25:00"}) == {"public_base_url": None, + "digest_at": "09:00"} + assert notify_values("坏掉的") == {"public_base_url": None, "digest_at": "09:00"} + + +@pytest.mark.parametrize("given, ok", [("08:30", True), ("23:59", True), ("00:00", True), ("24:00", False), + ("9:00", False), ("09:60", False), (900, False), ("", False)]) +async def test_digest_time_is_hh_mm(given, ok): + async with client() as c: + put = await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"digest_at": given}}}) + if ok: + assert put.status_code == 200, put.text + assert put.json()["notify"]["digest_at"] == given + else: + assert put.status_code == 422 + assert "待办汇总的发送时间" in put.json()["detail"] + + +async def test_changing_digest_time_keeps_the_public_address(): + """只给汇总时间的一组:对外访问地址不被冲成没填(设置页的通知分别保存这两项)。""" + async with client() as c: + await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"public_base_url": "https://agentlab.example.com"}}}) + put = await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"digest_at": "17:45"}}}) + assert put.json()["notify"] == {"public_base_url": "https://agentlab.example.com", "digest_at": "17:45"} From f759c5c82d247a6e205192163a98fc3282d0c355 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 16:30:42 +0800 Subject: [PATCH 2/7] =?UTF-8?q?W3-d=20=E5=88=86=E4=BA=AB=E9=93=BE=E6=8E=A5?= =?UTF-8?q?=EF=BC=88=E5=90=8E=E7=AB=AF=EF=BC=89=EF=BC=9A=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=E3=80=81=E4=B8=8D=E7=94=A8=E7=99=BB=E5=BD=95=E3=80=81=E5=BF=85?= =?UTF-8?q?=E8=AE=BE=E5=88=B0=E6=9C=9F=E3=80=81=E5=8F=AA=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E5=87=BA=E6=95=B0=E7=9A=84=E8=A7=86=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么做(04 细案 C2,集成-I6):会话 cookie 是 SameSite=Lax,跨站的 iframe 里带不上,想把看板放进别的门户只能每人开账号且 必须同站;分享链接是跨站嵌入唯一走得通的办法。它把数据交出了授权范围,所以每一步往窄处取: - view_shares(db/models_apps.ViewShare):只存 key 的 sha256 和开头 6 位;latest 跟着最近一次封存走,fixed 存建分享那一刻 每个视图的运行 id、按建分享时的应用版本解析;到期必填;打开只计数 - POST / GET / DELETE /api/apps/{id}/shares:要应用的 manage,建要管理员开了 apps.share_links(缺省关);个人应用、没发布、 归档的不能分享;这一页没有最近封存的视图不收;到期最长 90 天;key 只在建的时候交回一次(/s#);建、撤销写审计 - GET /api/shares/page、/api/shares/report.html 是公开路由(中间件把 /api/shares/ 当公开前缀),只认请求头 X-AgentLab-Share,地址里带的 key 不认;撤销、到期回 410 写明;开关关掉时已有的一并 403;应用归档、页面没了 410 - apps/resolve.resolve_page 加 share 旁路:只解析 latest_sealed 视图(fixed 用存下的运行,趋势也停在那一期),按需、实例、 探索视图写明原因、从不发起运行(auto 的也不);流程组件和报告换成写明原因的说明块;页面缓存键另加分享的标识;out 另给 各视图取到的运行和可下载的报告 - 下载报告只认这条分享此刻解析出来的运行,内部就是 export_report_html(同样遮过、照常记导出审计,导出人记成不冒充任何人的 分享身份) - 分享接口都不放给访问密钥(不在 KEY_ROUTES,门禁 403) 安全头:/s 用 AGENTLAB_EMBED_ORIGINS 和 no-referrer(第 1 波 web_security 预留的规则,这次在真实页面托管下测)。C3 同站门户 嵌整站只做配置和文档:写进 web_security 的模块说明和 deploy/agentlab.env 的「嵌入」一节,「分享链接还没有上线」那句改掉。 --- backend/app/api/shares.py | 331 ++++++++++++++++++++++++- backend/app/apps/resolve.py | 129 +++++++++- backend/app/auth/middleware.py | 7 +- backend/app/db/models_apps.py | 34 +++ backend/app/web_security.py | 11 +- backend/tests/test_shares.py | 373 +++++++++++++++++++++++++++++ backend/tests/test_web_security.py | 2 +- deploy/agentlab.env | 10 +- 8 files changed, 882 insertions(+), 15 deletions(-) create mode 100644 backend/tests/test_shares.py diff --git a/backend/app/api/shares.py b/backend/app/api/shares.py index b001ac79..4bcc1b94 100644 --- a/backend/app/api/shares.py +++ b/backend/app/api/shares.py @@ -1,10 +1,331 @@ -"""分享链接(docs/design/next-round/04-对外集成与触达.md C2)。 +"""分享链接(docs/design/next-round/04-对外集成与触达.md C2,集成-I6):拿到链接、不用登录就能看应用的一页(只读,可以嵌进门户)。 -读分享页的接口不用登录,只认请求头里的分享 key(中间件把 /api/shares/ 当公开前缀,第 3 波 W3-d 加);建、撤销分享在 -/api/apps/{id}/shares 下,要应用的 manage 权限。第 3 波 W3-d 填。 +为什么要有:会话 cookie 是 SameSite=Lax,跨站的 iframe 里带不上——想把一块看板放进别的门户,只能让每个看的人都开账号, +而且门户和 AgentLab 必须同站(同站门户嵌整站见 deploy/agentlab.env 的 AGENTLAB_FRAME_ANCESTORS,C3)。分享链接是跨站嵌入 +唯一走得通的办法,所以做;但它等于把数据交出了授权范围,所以每一步都往窄处取: +- 管理员开关 apps.share_links,缺省关;关掉时已有的链接一并打不开(403 share_links_off),撤销照常能做; +- 只有应用的 manage 权限能建、能撤销;个人应用(personal.not_shareable)、没发布的、归档了的不能分享; +- 到期必填,最长 EXPIRES_MAX_DAYS 天;撤销、到期之后打开回 410,写明是撤销了还是到期了、什么时候; +- 只读:分享页只解析定时出数(latest_sealed)的视图,按需视图、实例、待办、发起按钮、记录列表换成写明原因的说明,从不 + 发起运行(apps/resolve.py 的 share 旁路);看到的就是使用者看到的那份遮罩视图(视图对所有人都遮); +- 出处不走在线的证据接口(那等于给匿名的人一个按运行读证据的身份):分享页给「下载报告」,GET /api/shares/report.html 只认 + 这条分享此刻解析出来的那几次运行,内部用的就是报告导出 export_report_html(同样遮过,文件自己离线点得开出处); +- key 放在链接的 # 后面(/s#):打开页面时不发给服务器、不进代理日志和 Referer;读的接口只认请求头 X-AgentLab-Share, + 地址里带的一概不认。库里只存 key 的 sha256 和开头一截; +- 建、撤销写审计(app.share_create / app.share_revoke);打开只计数(open_count、last_opened_at)。 + +接口: +- POST / GET /api/apps/{app_id}/shares、DELETE /api/apps/{app_id}/shares/{share_id}:登录即可进来,接口里要应用的 manage; +- GET /api/shares/page、GET /api/shares/report.html:公开路由(中间件把 /api/shares/ 当公开前缀,不用登录、不看会话), + 只认分享 key。 + +都不放给访问密钥(不在 auth/keys.KEY_ROUTES 里,门禁一律 403):分享是给人看的页面,集成要拉数走视图数据接口。 +安全头:分享页 /s 用嵌入白名单 AGENTLAB_EMBED_ORIGINS 和 Referrer-Policy: no-referrer(web_security.py);这一条分享没有允许 +嵌入(embed_allowed 为假)时,页面在别的页面的 iframe 里不显示内容(服务端在 /s 上只能按全站的白名单发头:那一刻还不知道是 +哪一条分享,key 不会发到服务器)。 """ from __future__ import annotations -from fastapi import APIRouter +import hashlib +import secrets +from datetime import datetime, timedelta, timezone +from typing import Any, Literal -router = APIRouter(prefix="/api/shares", tags=["shares"]) +from fastapi import APIRouter, Depends, Header, Query +from pydantic import BaseModel +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.coded import CodedHTTPException +from app.api.deps import Principal, actor_of, require_role +from app.db.base import get_session, utcnow +from app.db.models_apps import App, AppVersion, ViewShare + +router = APIRouter(tags=["shares"]) +_manage = APIRouter(prefix="/api/apps") +_public = APIRouter(prefix="/api/shares") + +#: 读接口认的请求头 +SHARE_HEADER = "X-AgentLab-Share" +#: 到期最长多少天 +EXPIRES_MAX_DAYS = 90 +#: key 的随机字节数、库里存的开头几位 +KEY_BYTES = 32 +PREFIX_LEN = 6 +#: 分享页的地址(key 接在 # 后面) +SHARE_PATH = "/s" +SHARE_FORMAT = "agentlab.share/1" + +SHARE_LINKS_OFF = "share_links_off" +SHARE_KEY_MISSING = "share_key_missing" +SHARE_NOT_FOUND = "share_not_found" +SHARE_REVOKED = "share_revoked" +SHARE_EXPIRED = "share_expired" +SHARE_GONE = "share_gone" +SHARE_EXPIRES_INVALID = "share_expires_invalid" +SHARE_NO_SEALED_VIEW = "share_no_sealed_view" +SHARE_RUN_NOT_SHARED = "share_run_not_shared" +SHARE_ID_NOT_FOUND = "share_id_not_found" + +LINKS_OFF_CREATE = "管理员没有打开分享链接:请管理员在「设置 → 偏好设置」的「应用」一节里打开「允许建分享链接」" +LINKS_OFF_OPEN = "管理员已关闭分享链接,这条链接暂时打不开" + + +def key_hash(key: str) -> str: + return hashlib.sha256(key.encode()).hexdigest() + + +def _when(moment: datetime | None) -> str: + if moment is None: + return "" + aware = moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc) + return aware.astimezone().strftime("%Y-%m-%d %H:%M") + + +def _aware(moment: datetime) -> datetime: + return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc) + + +def state_of(row: ViewShare, now: datetime | None = None) -> str: + """active 能打开;revoked 撤销了;expired 过了到期时间。""" + if row.revoked_at is not None: + return "revoked" + if _aware(row.expires_at) <= (now or utcnow()): + return "expired" + return "active" + + +def share_out(row: ViewShare, titles: dict[str, str] | None = None) -> dict[str, Any]: + """一条分享的接口形状(typesApp.ts 的 ViewShareRow):key 不回(只在建的时候给一次),给开头一截认是哪一条。""" + iso = lambda m: _aware(m).isoformat() if m else None # noqa: E731 + return {"id": row.id, "prefix": row.prefix, "app_id": row.app_id, "page_id": row.page_id, + "page_title": (titles or {}).get(row.page_id) or row.page_id, "mode": row.mode, + "app_version": row.app_version, "embed_allowed": row.embed_allowed, "created_by": row.created_by, + "created_by_name": row.created_by_name, "created_at": iso(row.created_at), + "expires_at": iso(row.expires_at), "revoked_at": iso(row.revoked_at), "revoked_by": row.revoked_by, + "open_count": row.open_count, "last_opened_at": iso(row.last_opened_at), "state": state_of(row)} + + +async def _enabled(session: AsyncSession) -> bool: + from app.api.settings import apps_settings + + return bool((await apps_settings(session)).get("share_links")) + + +def _titles(spec: dict[str, Any] | None) -> dict[str, str]: + return {str(p.get("id")): str(p.get("title") or p.get("id")) for p in (spec or {}).get("pages") or [] + if isinstance(p, dict) and p.get("id")} + + +# -------------------------------------------------------------------------- +# 建、列、撤销(要应用的 manage) +# -------------------------------------------------------------------------- + + +class ShareIn(BaseModel): + page_id: str + mode: Literal["latest", "fixed"] = "latest" + #: 到期时间,必填(不给回 422 share_expires_invalid);不带时区的按 UTC + expires_at: datetime | None = None + embed_allowed: bool = False + + +async def _managed(session: AsyncSession, app_id: str, x_actor: str | None) -> tuple[App, Principal]: + from app.api.apps import _app, _principal + + principal = _principal(x_actor) + app, _ = await _app(session, app_id, principal, "manage") + return app, principal + + +@_manage.post("/{app_id}/shares", status_code=201) +async def create_share(app_id: str, payload: ShareIn, session: AsyncSession = Depends(get_session), + x_actor: str | None = Header(default=None)) -> dict[str, Any]: + """建一条分享链接:要应用的 manage、管理员开了 apps.share_links。key 只在这里交回一次(path 是 /s#,url 是拼上 + 对外访问地址的完整链接,没设对外访问地址时为 null,界面按当前页面的地址拼)。""" + from app.api.apps import _current_version, _refuse_archived, _refuse_personal + from app.apps.audit import record + from app.apps.resolve import SharedView, share_runs, sealed_views + from app.apps.tree import page_of + from app.core import public_url + + app, principal = await _managed(session, app_id, x_actor) + if not await _enabled(session): + raise CodedHTTPException(403, LINKS_OFF_CREATE, SHARE_LINKS_OFF) + _refuse_personal(app, "建分享链接") + _refuse_archived(app, "建分享链接") + version = await _current_version(session, app) + now = utcnow() + if payload.expires_at is None: + raise CodedHTTPException(422, f"分享链接必须设到期时间,最长 {EXPIRES_MAX_DAYS} 天", SHARE_EXPIRES_INVALID, + field="expires_at") + expires = _aware(payload.expires_at) + if expires <= now + timedelta(minutes=1): + raise CodedHTTPException(422, "到期时间要在现在之后", SHARE_EXPIRES_INVALID, field="expires_at") + # 留一小时的余量:界面按本机时间算「90 天后」,本机比服务端快一点不该被拒 + if expires > now + timedelta(days=EXPIRES_MAX_DAYS, hours=1): + raise CodedHTTPException(422, f"分享链接最长 {EXPIRES_MAX_DAYS} 天到期,请选更早的到期时间", + SHARE_EXPIRES_INVALID, field="expires_at") + spec = version.spec or {} + if page_of(spec, payload.page_id) is None: + raise CodedHTTPException(404, f"应用的当前版本里没有页面「{payload.page_id}」", "app_page_not_found") + if not sealed_views(spec, payload.page_id): + raise CodedHTTPException(409, "这一页没有定时出数的视图(取最近一次封存运行的视图):分享链接只显示这种视图," + "分享出去什么都看不到", SHARE_NO_SEALED_VIEW) + runs: dict[str, str | None] | None = None + if payload.mode == "fixed": + found = await share_runs(session, spec, payload.page_id, SharedView(mode="latest", key="")) + runs = {vid: (run.id if run is not None else None) for vid, run in found.items()} + if not any(runs.values()): + raise CodedHTTPException(409, "这一页定时出数的视图都还没有封存的运行,没有可以固定的数:先用「始终最新」," + "或者等第一次出数之后再建", SHARE_NO_SEALED_VIEW) + key = secrets.token_urlsafe(KEY_BYTES) + row = ViewShare(key_hash=key_hash(key), prefix=key[:PREFIX_LEN], app_id=app.id, page_id=payload.page_id, + mode=payload.mode, app_version=version.version, runs=runs, embed_allowed=payload.embed_allowed, + created_by=actor_of(x_actor), created_by_name=principal.display_name or actor_of(x_actor), + created_at=now, expires_at=expires) + session.add(row) + await session.flush() + await record(session, action="app.share_create", subject_kind="view_share", subject_id=row.id, + actor=actor_of(x_actor), data={"app_id": app.id, "page_id": payload.page_id, "mode": payload.mode, + "app_version": version.version, "expires_at": expires.isoformat(), + "embed_allowed": payload.embed_allowed, "prefix": row.prefix}) + await session.commit() + path = f"{SHARE_PATH}#{key}" + base = public_url.public_base_url() + return {**share_out(row, _titles(spec)), "key": key, "path": path, "url": f"{base}{path}" if base else None} + + +@_manage.get("/{app_id}/shares") +async def list_shares(app_id: str, session: AsyncSession = Depends(get_session), + x_actor: str | None = Header(default=None)) -> dict[str, Any]: + """这个应用的分享链接(新的在前)。enabled 是管理员开没开(关着时界面不给新建,已有的照列、能撤销);embed_origins 是部署 + 配没配允许嵌入分享页的站点(AGENTLAB_EMBED_ORIGINS);max_days 是到期最长几天。""" + from app.api.apps import _previous + from app.core.config import settings + + app, _ = await _managed(session, app_id, x_actor) + rows = list((await session.execute(select(ViewShare).where(ViewShare.app_id == app.id) + .order_by(ViewShare.created_at.desc()))).scalars()) + version = await _previous(session, app) + titles = _titles(version.spec if version is not None else None) + return {"enabled": await _enabled(session), "embed_origins": bool(settings.embed_origins), + "max_days": EXPIRES_MAX_DAYS, "items": [share_out(r, titles) for r in rows]} + + +@_manage.delete("/{app_id}/shares/{share_id}") +async def revoke_share(app_id: str, share_id: str, session: AsyncSession = Depends(get_session), + x_actor: str | None = Header(default=None)) -> dict[str, Any]: + """撤销:之后打开回 410「链接已撤销」。撤销过的再撤销照旧交回它(不重复记审计)。管理员关了开关也能撤销。""" + from app.apps.audit import record + + app, _ = await _managed(session, app_id, x_actor) + row = await session.get(ViewShare, share_id) + if row is None or row.app_id != app.id: + raise CodedHTTPException(404, "分享链接不存在,可能已被删除", SHARE_ID_NOT_FOUND) + if row.revoked_at is None: + row.revoked_at, row.revoked_by = utcnow(), actor_of(x_actor) + await record(session, action="app.share_revoke", subject_kind="view_share", subject_id=row.id, + actor=actor_of(x_actor), data={"app_id": app.id, "page_id": row.page_id, "prefix": row.prefix}) + await session.commit() + return share_out(row) + + +# -------------------------------------------------------------------------- +# 打开(公开,只认请求头里的分享 key) +# -------------------------------------------------------------------------- + + +def _share_principal(row: ViewShare) -> Principal: + """分享身份:不是任何一个人。按使用者算(role=user),用户名为空:审计里不冒充谁,遮罩和使用者看到的一样。""" + from app.auth import current_mode + + return Principal(username=None, role="user", display_name=f"分享链接 {row.prefix}", verified=False, + mode=current_mode(), via="share") + + +async def _opened(session: AsyncSession, key: str | None) -> tuple[ViewShare, App, AppVersion]: + """按请求头里的 key 认出分享,核对开关、撤销、到期、应用还在不在,交回 (分享, 应用, 要解析的应用版本)。""" + from app.api.apps import _previous + + if not key or not key.strip(): + raise CodedHTTPException(400, "缺少分享 key:请用完整的分享链接打开(链接里 # 后面的那一段)", SHARE_KEY_MISSING) + row = (await session.execute(select(ViewShare).where(ViewShare.key_hash == key_hash(key.strip())))).scalar_one_or_none() + if row is None: + raise CodedHTTPException(404, "链接无效:可能复制得不完整,请向分享的人确认链接", SHARE_NOT_FOUND) + if not await _enabled(session): + raise CodedHTTPException(403, LINKS_OFF_OPEN, SHARE_LINKS_OFF) + state = state_of(row) + if state == "revoked": + raise CodedHTTPException(410, f"链接已撤销({_when(row.revoked_at)}):请向分享的人要新的链接", SHARE_REVOKED) + if state == "expired": + raise CodedHTTPException(410, f"链接已到期({_when(row.expires_at)}):请向分享的人要新的链接", SHARE_EXPIRED) + app = await session.get(App, row.app_id) + if app is None or app.status == "archived" or getattr(app, "scope", "shared") == "personal" \ + or not app.published_version: + raise CodedHTTPException(410, "分享的应用已归档或不再发布,这条链接打不开了", SHARE_GONE) + version = None + if row.mode == "fixed" and row.app_version: + version = (await session.execute(select(AppVersion).where( + AppVersion.app_id == app.id, AppVersion.version == row.app_version))).scalar_one_or_none() + version = version or await _previous(session, app) + if version is None: + raise CodedHTTPException(410, "分享的应用已归档或不再发布,这条链接打不开了", SHARE_GONE) + return row, app, version + + +def _shared(row: ViewShare) -> Any: + from app.apps.resolve import SharedView + + return SharedView(mode=row.mode, key=row.id, runs=dict(row.runs or {}) if row.mode == "fixed" else None) + + +@_public.get("/page") +async def share_page(session: AsyncSession = Depends(get_session), + share_key: str | None = Header(default=None, alias=SHARE_HEADER)) -> dict[str, Any]: + """分享页(agentlab.share/1):{format, share: {页脚要的:谁分享、什么时候、什么时候到期、能不能嵌入、模式}, view: + agentlab.view/1(share 旁路解析), downloads: [{view, run_id, reports, as_of_text}](下载报告用)}。打开一次计一次数。""" + from app.apps.resolve import ViewRefused, resolve_page + + row, app, version = await _opened(session, share_key) + try: + view = await resolve_page(session, app, page_id=row.page_id, principal=_share_principal(row), role=None, + version=version, share=_shared(row)) + except ViewRefused as e: + if e.code == "app_page_not_found": + raise CodedHTTPException(410, "分享的页面在应用的当前版本里已经没有了,这条链接打不开了", SHARE_GONE) from e + raise CodedHTTPException(e.status_code, e.message, e.code or SHARE_GONE) from e + await session.execute(update(ViewShare).where(ViewShare.id == row.id).values( + open_count=ViewShare.open_count + 1, last_opened_at=utcnow())) + await session.commit() + downloads = (view.pop("share", None) or {}).get("runs") or [] + return {"format": SHARE_FORMAT, + "share": {"mode": row.mode, "created_by": row.created_by_name or row.created_by, + "created_at": _aware(row.created_at).isoformat(), "expires_at": _aware(row.expires_at).isoformat(), + "embed_allowed": row.embed_allowed}, + "view": view, "downloads": downloads} + + +@_public.get("/report.html") +async def share_report(run: str = Query(...), report: str | None = None, session: AsyncSession = Depends(get_session), + share_key: str | None = Header(default=None, alias=SHARE_HEADER)) -> Any: + """下载报告:只认这条分享此刻解析出来的那几次运行(apps/resolve.share_runs),别的运行 id 一律 404。内部就是报告导出 + (api/evidence.export_report_html:同样遮过,报告文件自己离线点得开出处;导出照常记 report.exported 审计,导出人记成 + 分享身份——用户名为空,不冒充谁)。""" + from app.api.deps import reset_principal, set_principal + from app.api.evidence import export_report_html + from app.apps.resolve import share_runs + + row, _, version = await _opened(session, share_key) + found = await share_runs(session, version.spec or {}, row.page_id, _shared(row)) + if run not in {r.id for r in found.values() if r is not None}: + raise CodedHTTPException(404, "这次运行不在这条分享链接里", SHARE_RUN_NOT_SHARED) + token = set_principal(_share_principal(row)) + try: + return await export_report_html(run, report) + finally: + reset_principal(token) + + +router.include_router(_manage, dependencies=[require_role("user")]) +router.include_router(_public) diff --git a/backend/app/apps/resolve.py b/backend/app/apps/resolve.py index f044bdb7..4cb36c59 100644 --- a/backend/app/apps/resolve.py +++ b/backend/app/apps/resolve.py @@ -12,6 +12,14 @@ - instance:实例自己的那次运行。 印章(截至 / 口径 / 出具档位 / 来源)由这里算;定时任务改跑了新版本而应用还钉在旧版本这类错位照实写进 notes。 + +分享链接(docs/design/next-round/04-对外集成与触达.md C2,api/shares.py)是解析时的另一条旁路(resolve_page 的 share): +拿到链接、不用登录的人看到的就是使用者看到的那份遮罩视图,但只有定时出数的那部分—— +- 只解析 latest_sealed 视图(fixed 模式取建分享时存下的那几次运行 id,SharedView.runs);按需、实例、个人应用的探索视图换成 + 写明原因的空态(SHARE_SEALED_ONLY),从不发起运行(按需视图不自动发起,因为它根本不按按需解析); +- 流程组件(入口按钮、表单、实例、进度、待办、会签、生效记录、记录列表)换成写明原因的说明块(SHARE_PLACEHOLDER),报告 + 组件也换成说明(正文和出处在「下载报告」里:分享页不开在线证据,那等于给匿名的人一个按运行读证据的身份); +- 印章照常算,界面上常显;页面缓存的键另加分享的标识。 """ from __future__ import annotations @@ -76,6 +84,36 @@ _PERIOD = re.compile(r"(period|week|month|quarter|year|date|day|期|周|月|季| _DIGIT = re.compile(r"\d") +#: 分享链接里(share 旁路)不是定时出数的视图、流程组件、报告写什么 +SHARE_SEALED_ONLY = "分享链接只显示定时出数的视图:这一块的数要打开应用才看得到" +SHARE_PLACEHOLDER = { + "entry_button": "分享链接是只读的,不能在这里发起", + "form": "分享链接是只读的,不能在这里换参数", + "instance_list": "分享链接不列实例:要看实例请打开应用", + "instance_header": "分享链接不显示实例:要看实例请打开应用", + "instance_timeline": "分享链接不显示实例进度:要看请打开应用", + "task_list": "分享链接不列待办:待办只给派给的人,请打开应用处理", + "task_form": "分享链接不显示待办:待办只给派给的人,请打开应用处理", + "signoffs": "分享链接不显示会签进度:要看请打开应用", + "action_status": "分享链接不显示写入外部系统的记录:要看请打开应用", + "records_list": "分享链接不列登记的记录:要看请打开应用", + "report": "报告正文和每个数的出处在「下载报告」里:下载的文件离线也能点开出处", +} +SHARE_FIXED_NEW_VIEW = "这个视图是建分享之后才加的:固定在当时那几次运行的分享链接里没有它" +SHARE_FIXED_EMPTY = "建分享时这个视图还没有封存的运行" +SHARE_FIXED_GONE = "建分享时固定的那次运行已经不在了" + + +@dataclass(frozen=True) +class SharedView: + """分享链接的解析方式(api/shares.py 建好交进来)。mode:latest 始终取最近一次封存;fixed 取 runs 里存的运行 + ({视图 id: 运行 id,建分享时还没有封存运行的是 None})。key 进页面缓存的键(分享的 id)。""" + + mode: str + key: str + runs: dict[str, str | None] | None = None + + class ViewRefused(Exception): def __init__(self, status_code: int, message: str, code: str | None = None) -> None: super().__init__(message) @@ -567,6 +605,8 @@ class _Page: page: dict[str, Any] = field(default_factory=dict) #: 蓝图的试运行(trial 旁路):草稿里所有视图来源都解析到这次运行,流程组件也对着它 trial: Run | None = None + #: 分享链接(share 旁路):只解析定时出数的视图,流程组件换成说明 + share: SharedView | None = None pins: dict[str, PinState] = field(default_factory=dict) sources: dict[str, Source] = field(default_factory=dict) problems: list[dict[str, Any]] = field(default_factory=list) @@ -606,13 +646,16 @@ async def _backfilled(session: AsyncSession, app_id: str) -> int: async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, principal: Principal, role: str | None, instance: Run | None = None, params: dict[str, Any] | None = None, draft: bool = False, version: AppVersion | None = None, actor: str | None = None, - trial: Run | None = None) -> dict[str, Any]: + trial: Run | None = None, share: SharedView | None = None) -> dict[str, Any]: """解析一页,返回 agentlab.view/1。draft 为真时按草稿解析:不发起任何运行,视图取钉住版本已有的封存运行。 trial 是蓝图的一次试运行(docs/design/normal-user-apps.md N4,只和 draft 一起用):草稿里所有视图来源(最近封存、 按需、本实例)都解析到这次运行,流程组件(实例进度、待办)也对着它;印章的来源写「试运行」、trial 为真、说明里写 「这不是正式结果」;趋势只有这一个点。不改 agentlab.app/1 的视图来源,只是解析时的旁路。 + share 是分享链接(见模块说明,只和发布版一起用:草稿、个人应用、试运行都不能分享):只解析定时出数的视图,流程组件和 + 报告换成写明原因的说明,从不发起运行;out 里另给 share.runs(各视图取到的运行、有哪几份报告可下载)。 + 个人应用(N7)没有应用版本:草稿就是在用的那一份,不按草稿预览时也解析它(version 为空)。作者隐藏了的组件(hidden) 不下发;按草稿解析(编辑状态)时照常解析、带上 hidden,界面画成半透明。""" import copy @@ -625,8 +668,11 @@ async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, princip if page is None: raise ViewRefused(404, f"应用里没有页面「{page_id}」", "app_page_not_found") trial = trial if draft else None + if share is not None and (draft or personal or version is None or instance is not None): + raise ViewRefused(409, "分享链接只解析已发布的共享应用", "personal.not_shareable" if personal else VIEW_REFUSED) ctx = _Page(app=app, spec=spec, version=None if draft else version, principal=principal, role=role, - instance=instance or trial, params=params or {}, draft=draft, actor=actor, page=page, trial=trial) + instance=instance or trial, params=params or {}, draft=draft, actor=actor, page=page, trial=trial, + share=share) ctx.pins = await load_pins(session, spec) # 试运行的钉住项本来就没有版本(还没发布),不是「需要升级」 upgrade = needs_upgrade(ctx.pins) if trial is None else False @@ -644,9 +690,10 @@ async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, princip tuple((a, p.workflow.published_version if p.workflow else None, p.retired) for a, p in sorted(ctx.pins.items())), role, principal.username, principal.role, await _mask_fingerprint(session), - await _backfilled(session, app.id)) + await _backfilled(session, app.id), ("share", share.key) if share is not None else None) + # 分享链接里流程组件都换成了说明,页面上没有随人、随时变的东西:照样进缓存 cacheable = (not draft and instance is None and all(s.state in ("ok", "empty") for s in ctx.sources.values()) - and not _dynamic(page)) + and (share is not None or not _dynamic(page))) if cacheable and key in _PAGE_CACHE: _PAGE_CACHE.move_to_end(key) return copy.deepcopy(_PAGE_CACHE[key]) @@ -673,6 +720,7 @@ async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, princip "problems": ctx.problems, **({"trial": {"run_id": trial.id, "status": trial.status, "status_text": STATUS_TEXT.get(trial.status, trial.status)}} if trial is not None else {}), + **({"share": {"mode": share.mode, "runs": _share_runs_out(ctx)}} if share is not None else {}), } if cacheable: _PAGE_CACHE[key] = copy.deepcopy(out) @@ -681,6 +729,49 @@ async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, princip return out +def _share_runs_out(ctx: _Page) -> list[dict[str, Any]]: + """分享页上「下载报告」用:各视图取到的、成功封存的那次运行,带有哪几份没被改过的报告(节点 id)。同一次运行只列一次。""" + from app.api.evidence import _reports + + out: list[dict[str, Any]] = [] + seen: set[str] = set() + for vid, source in ctx.sources.items(): + if source.state != "ok" or source.run is None or source.sealed is None or source.run.id in seen: + continue + seen.add(source.run.id) + reports = [r.node_id for r in _reports(source.sealed) if r.hash_ok is not False and r.doc is not None] + out.append({"view": vid, "run_id": source.run.id, "reports": reports, + "as_of_text": (source.stamp or {}).get("as_of_text")}) + return out + + +async def share_runs(session: AsyncSession, spec: dict[str, Any], page_id: str, share: SharedView) -> dict[str, Run | None]: + """这条分享链接现在解析得到的运行:{视图 id: 运行}(只有这一页上定时出数的视图,取不到的是 None)。下载报告只认这几次 + (api/shares.py),和页面上 _source 的分享旁路同一个取法。""" + page = page_of(spec, page_id) + if page is None: + return {} + ctx = _Page(app=None, spec=spec, version=None, principal=Principal(username=None, role="user", via="share"), + role=None, instance=None, params={}, draft=False, actor=None, page=page, share=share) + ctx.pins = await load_pins(session, spec) + out: dict[str, Run | None] = {} + for vid in _views_of(page): + if (((spec.get("views") or {}).get(vid) or {}).get("source") or {}).get("kind") != "latest_sealed": + continue + source = await _source(session, ctx, vid) + out[vid] = source.run if source.state == "ok" else None + return out + + +def sealed_views(spec: dict[str, Any], page_id: str) -> list[str]: + """这一页上取最近封存运行(定时出数)的视图:能不能建分享链接看它(一个都没有,分享出去什么都看不到)。""" + page = page_of(spec, page_id) + if page is None: + return [] + return [vid for vid in _views_of(page) + if (((spec.get("views") or {}).get(vid) or {}).get("source") or {}).get("kind") == "latest_sealed"] + + _DYNAMIC = frozenset({"instance_list", "task_list", "instance_header", "instance_timeline", "task_form", "signoffs", "action_status", "entry_button", "records_list"}) @@ -724,6 +815,12 @@ async def _source(session: AsyncSession, ctx: _Page, vid: str) -> Source: return Source(id=vid, kind="", state="error", message=f"视图「{vid}」不存在") if ctx.trial is not None: return await _trial_source(session, ctx, vid, kind, src) + if ctx.share is not None: + if kind != "latest_sealed": + # 分享链接只显示定时出数的视图:按需视图不发起、实例和探索视图不取 + return Source(id=vid, kind=kind, state="empty", message=SHARE_SEALED_ONLY) + if ctx.share.mode == "fixed": + return await _fixed_source(session, ctx, vid, src) state = ctx.pins.get(src.get("pin")) if kind != "instance" else None run: Run | None = None if kind == "instance": @@ -755,6 +852,21 @@ async def _source(session: AsyncSession, ctx: _Page, vid: str) -> Source: return source +async def _fixed_source(session: AsyncSession, ctx: _Page, vid: str, src: dict[str, Any]) -> Source: + """fixed 模式的分享链接:取建分享时这个视图的那次运行(还得是成功封存的正式运行),之后封存的新运行不换上来。""" + assert ctx.share is not None + runs = ctx.share.runs or {} + state = ctx.pins.get(src.get("pin")) + if vid not in runs: + return Source(id=vid, kind="latest_sealed", state="empty", pin=state, message=SHARE_FIXED_NEW_VIEW) + if runs[vid] is None: + return Source(id=vid, kind="latest_sealed", state="empty", pin=state, message=SHARE_FIXED_EMPTY) + run = await session.get(Run, runs[vid]) + if run is None or run.status != "succeeded" or run.run_class != "formal": + return Source(id=vid, kind="latest_sealed", state="empty", pin=state, message=SHARE_FIXED_GONE) + return Source(id=vid, kind="latest_sealed", state="ok", run=run, pin=state, origin=src.get("from")) + + async def _exploratory_source(session: AsyncSession, ctx: _Page, vid: str, state: PinState) -> Source: """个人应用的视图(docs/design/normal-user-apps.md N7):这个应用、这个拥有者、这张图下最近一次成功封存的刷新 (blueprints/personal.exploratory_runs)。只在个人应用里合法(应用检查报 app.view_source_personal):共享应用里写了的 @@ -867,6 +979,10 @@ async def _node(session: AsyncSession, ctx: _Page, comp: dict[str, Any]) -> dict return None if comp.get("visible") and not await _visible(session, ctx, comp): return None + if ctx.share is not None and (reason := SHARE_PLACEHOLDER.get(str(comp.get("type")))): + # 分享链接是只读的:流程组件、报告换成一块写明原因的说明(前端照说明块画),不取实例、待办、报告正文 + return {"id": cid, "type": "notice", "props": {"text": reason, "tone": "info"}, + "share_placeholder": comp.get("type")} node: dict[str, Any] = {"id": cid, "type": comp.get("type"), "props": dict(comp.get("props") or {}), **({"hidden": True} if comp.get("hidden") else {})} entry = BY_NAME.get(str(comp.get("type"))) @@ -1082,6 +1198,11 @@ async def _series(session: AsyncSession, ctx: _Page, source: Source, cid: str, r runs = await trend_runs(session, app_id, pin.pin.get("workflow_id"), pin.pin.get("version"), pin.pin.get("graph_hash"), origin, n) segments.append((int(pin.pin.get("version")), str(pin.pin.get("graph_hash")), runs)) + if ctx.share is not None and ctx.share.mode == "fixed" and source.run is not None: + # 固定在建分享那一刻的分享链接:趋势也停在那一期,之后封存的不画进来 + cutoff = period_of(source.run) + segments = [(v, h, [r for r in rows if cutoff is None or (period_of(r) or cutoff) <= cutoff]) + for v, h, rows in segments] series: list[dict[str, Any]] = [] unit = "" for version, _, rows in segments: diff --git a/backend/app/auth/middleware.py b/backend/app/auth/middleware.py index c5cbed02..40152483 100644 --- a/backend/app/auth/middleware.py +++ b/backend/app/auth/middleware.py @@ -7,7 +7,8 @@ 和加这一层之前一模一样。 开登录: - 会话 cookie → Principal(认证过的用户名、角色、组);没有会话是匿名(username=None),X-Actor 一律不看; -- /api 下除了登录本身、健康检查、入站钩子,匿名一律 401。各路由的角色要求另由 main.py 的路由级依赖管,这里兜底: +- /api 下除了登录本身、健康检查、入站钩子、分享链接的读接口(/api/shares/,只认请求头里的分享 key,api/shares.py),匿名 + 一律 401。各路由的角色要求另由 main.py 的路由级依赖管,这里兜底: 哪个路由忘了挂依赖,也不会对没登录的人敞开; - 接口说明(FastAPI 自带的 /docs、/redoc、/openapi.json)和 /api 一样拦:匿名 401,不到搭建者 403。它们是 FastAPI 自己 挂的路由,挂不上路由级依赖,所以角色也在这里判。以前只拦 /api/ 开头的,开了登录接口全貌也对匿名的人敞开; @@ -38,6 +39,8 @@ _PUBLIC = frozenset({"/api/health", "/api/auth/me", "/api/auth/login", "/api/aut "/api/auth/oidc/callback", "/api/auth/jwks", "/api/auth/bootstrap"}) #: 只认签名、不走会话的入站钩子(也不查 CSRF:调用方是外部系统,没有浏览器的 Origin) _HOOKS = "/api/hooks/" +#: 分享链接的读接口(04 细案 C2):不用登录,接口里只认请求头 X-AgentLab-Share,会话有没有都不看。只有 GET:非 GET 照常查 CSRF +_SHARES = "/api/shares/" #: FastAPI 自带的接口说明(main.py 里 FastAPI() 的缺省地址):开登录时要搭建者 _DOCS = ("/docs", "/redoc", "/openapi.json") _SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) @@ -153,7 +156,7 @@ class AuthMiddleware: await send({"type": "websocket.close", "code": 1008}) return if (scope["type"] == "http" and is_api and not principal.username and path not in _PUBLIC - and not path.startswith(_HOOKS)): + and not path.startswith(_HOOKS) and not path.startswith(_SHARES)): await _reply(send, 401, "请先登录", AUTH_REQUIRED) return if scope["type"] == "http" and _is_docs(path) and (refused := refusal(principal, "builder")) is not None: diff --git a/backend/app/db/models_apps.py b/backend/app/db/models_apps.py index a088b6d9..b07b1deb 100644 --- a/backend/app/db/models_apps.py +++ b/backend/app/db/models_apps.py @@ -146,3 +146,37 @@ class ApiReceipt(Base): status: Mapped[str] = mapped_column(String(20)) detail: Mapped[str | None] = mapped_column(Text, default=None) received_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow) + + +class ViewShare(Base): + """分享链接(docs/design/next-round/04-对外集成与触达.md C2,集成-I6):拿到链接、不用登录就能看应用的一页(只读, + 可以嵌进门户)。 + + 链接写成 /s#<分享 key>:key 在 # 后面,打开页面时不发给服务器、不进访问日志和 Referer,页面读出来再放进请求头 + X-AgentLab-Share(api/shares.py)。库里只存 key 的 sha256(key_hash)和开头一截(prefix,认是哪一条),同访问密钥。 + + mode:latest 始终取最近一次封存;fixed 取建分享那一刻每个视图的运行(runs:{视图 id: 运行 id},还没有封存运行的是 + null),解析建分享时的应用版本(app_version)。到期必填、最长 90 天;撤销、到期之后打开回 410 并写明。建、撤销写审计; + 打开只计数(open_count、last_opened_at),不一次写一行。embed_allowed 为假时分享页不肯在别的页面的 iframe 里显示(服务端 + 在 /s 上只能按全站的嵌入白名单 AGENTLAB_EMBED_ORIGINS 发头:打开页面那一刻还不知道是哪一条分享)。""" + + __tablename__ = "view_shares" + + id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id) + key_hash: Mapped[str] = mapped_column(String(64), unique=True) + prefix: Mapped[str] = mapped_column(String(16)) + app_id: Mapped[str] = mapped_column(ForeignKey("apps.id", ondelete="CASCADE"), index=True) + page_id: Mapped[str] = mapped_column(String(100)) + mode: Mapped[str] = mapped_column(String(20), default="latest") # latest | fixed + app_version: Mapped[int | None] = mapped_column(Integer, default=None) + runs: Mapped[dict[str, Any] | None] = mapped_column(default=None) + embed_allowed: Mapped[bool] = mapped_column(default=False) + created_by: Mapped[str | None] = mapped_column(String(100), default=None) + #: 建分享的人当时的显示名:分享页页脚写「由 X 分享」 + created_by_name: Mapped[str | None] = mapped_column(String(100), default=None) + created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow) + expires_at: Mapped[datetime] = mapped_column(UTCDateTime) + revoked_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None) + revoked_by: Mapped[str | None] = mapped_column(String(100), default=None) + open_count: Mapped[int] = mapped_column(Integer, default=0) + last_opened_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None) diff --git a/backend/app/web_security.py b/backend/app/web_security.py index a7ae8646..855e12ce 100644 --- a/backend/app/web_security.py +++ b/backend/app/web_security.py @@ -9,7 +9,7 @@ - Referrer-Policy: strict-origin-when-cross-origin(分享页 /s 用 no-referrer:分享的 key 在地址的 # 后面,本来不随 Referer 走,这里再收一道); - Content-Security-Policy: frame-ancestors。缺省只有 'self';AGENTLAB_FRAME_ANCESTORS 列出允许嵌整站的同站门户;分享页 - /s 只认 AGENTLAB_EMBED_ORIGINS(分享链接第 3 波才加,路由在那时挂,头的规则先在这里定好); + /s 只认 AGENTLAB_EMBED_ORIGINS(分享链接,api/shares.py;页面是前端不进外壳的那一页,由 mount_web 照常托管); - 只有 frame-ancestors 是 'self' 时另加 X-Frame-Options: SAMEORIGIN,给不认 CSP 的老浏览器。XFO 写不了白名单,有白名单 时只发 CSP:两个都发的话,只认 XFO 的老浏览器会把白名单里的门户也拦掉(认 CSP 的浏览器以 frame-ancestors 为准)。 接口自己设过的同名头(文件下载的 nosniff)不覆盖、不重复。 @@ -19,6 +19,15 @@ index.html 里的内联脚本算 sha256(script_policy),只在页面这一个响应上发;观察一个版本没有误报,再改成强制执行。没配 上报地址:违规只在浏览器的控制台里看得到。接口返回的 HTML(导出的报告、可验证报告)文件里自带 meta CSP,不发它,也不动。 +嵌入的两种办法(04 细案 C3,只做配置和文档): +- 同站门户嵌整站:门户和 AgentLab 在同一个可注册域名下(portal.corp.example 和 agentlab.corp.example)时,把门户的源写进 + AGENTLAB_FRAME_ANCESTORS。会话 cookie 是 SameSite=Lax,同站的 iframe 里会随请求带上:看的人照常登录、单点登录照常,权限 + 和直接打开一样。不另做嵌入票据; +- 跨站嵌:嵌整站明确不支持——跨站的 iframe 里带不上 Lax 的会话 cookie,登录不了;改成 SameSite=None 会削弱整站防跨站请求 + 伪造的那一层,换来的嵌入在 Safari、Firefox 上默认仍然被拦。跨站要嵌,用分享链接(只读、必设到期、可撤销,/s 只认 + AGENTLAB_EMBED_ORIGINS)。 +deploy/agentlab.env 的「嵌入」一节写的是同一套。 + HSTS 交给终结 https 的反向代理:这里不知道对外是不是 https、要不要连子域名一起管,发错了浏览器会按 max-age 记很久, 撤不回来。 """ diff --git a/backend/tests/test_shares.py b/backend/tests/test_shares.py new file mode 100644 index 00000000..4eb41c99 --- /dev/null +++ b/backend/tests/test_shares.py @@ -0,0 +1,373 @@ +"""分享链接(api/shares.py + apps/resolve.py 的 share 旁路,docs/design/next-round/04-对外集成与触达.md C2)。 + +守的是: +- 管理员开关缺省关,关着建不了、已有的打不开;只有应用的 manage 能建;个人应用、没发布的、这一页没有定时出数的视图都建不了; +- 到期必填,最长 90 天; +- 读的接口不用登录,只认请求头 X-AgentLab-Share:地址里带 key 不认;库里只有 key 的哈希和开头一截; +- 只解析定时出数(最近封存)的视图:按需视图写明原因、从不发起运行(声明了 auto 的也不);流程组件、报告换成写明原因的说明; +- fixed 模式钉在建分享时的那几次运行上(趋势也停在那一期),latest 跟着最新的封存走; +- 下载报告只认这条分享解析出来的运行,内部就是报告导出(遮罩值写「已遮罩」); +- 撤销、到期回 410 并写明;建、撤销写审计,打开只计数; +- 访问密钥一律调不了分享的接口; +- /s 上的安全头:嵌入白名单用 AGENTLAB_EMBED_ORIGINS、Referrer-Policy: no-referrer。 + +应用、工作流、人名都是虚构的。 +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from datetime import timedelta +from pathlib import Path + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import delete, func, select + +from app.core.config import BACKEND_DIR +from app.db.base import SessionLocal, utcnow +from app.db.models import AuditEvent, Run, Setting +from app.db.models_apps import ViewShare +from tests.fakes import auth_env +from tests.test_access_key_use import KPI, formal, published, wait, workflow +from tests.test_access_key_use import world # noqa: F401 - 开登录、建管理员、收尾清掉应用 + +HEADER = "X-AgentLab-Share" + + +@pytest.fixture(autouse=True) +async def switch(world): # noqa: F811 + await set_switch(True) + yield + async with SessionLocal() as s: + await s.execute(delete(ViewShare)) + await s.execute(delete(Setting).where(Setting.key == "apps")) + await s.commit() + + +async def set_switch(on: bool) -> None: + async with SessionLocal() as s: + row = await s.get(Setting, "apps") + if row is None: + s.add(Setting(key="apps", value={"share_links": on})) + else: + row.value = {**row.value, "share_links": on} + await s.commit() + + +def anonymous(**headers: str) -> AsyncClient: + """不带会话 cookie、不带 CSRF 头的客户端:拿到链接的人就是这样打开的。""" + from app.main import app + + return AsyncClient(transport=ASGITransport(app=app), base_url="http://test", headers=headers) + + +def board(wf: str, h: str, *, body: list | None = None, views: dict | None = None) -> dict: + return {"format": "agentlab.app/1", "name": "周报看板", + "pins": [{"alias": "main", "workflow_id": wf, "version": 1, "graph_hash": h, "use": "view"}], + "views": views or {"latest": {"source": {"kind": "latest_sealed", "pin": "main", "from": "formal"}}}, + "pages": [{"id": "home", "title": "首页", "body": body or [ + {"id": "gmv", "type": "metric", "props": {"label": "销售额"}, + "bind": {"view": "latest", "metric": "card.gmv"}}, + {"id": "trend", "type": "series", "bind": {"view": "latest", "series": "card.gmv"}}]}]} + + +def soon(days: float = 30) -> str: + return (utcnow() + timedelta(days=days)).isoformat() + + +async def share(app_id: str, **body) -> dict: + admin = await auth_env.login("root") + r = await admin.post(f"/api/apps/{app_id}/shares", json={"page_id": "home", "expires_at": soon(), **body}) + assert r.status_code == 201, r.text + return r.json() + + +# -------------------------------------------------------------------------- +# 建:开关、权限、到期 +# -------------------------------------------------------------------------- + + +async def test_switch_and_manage_are_both_required(world): # noqa: F811 + wf, h = await workflow(KPI) + await auth_env.make_user("pia", "user") + app_id = await published(world, board(wf, h), [("user", "pia", "observe")]) + admin = await auth_env.login("root") + await set_switch(False) + r = await admin.post(f"/api/apps/{app_id}/shares", json={"page_id": "home", "expires_at": soon()}) + assert r.status_code == 403 and r.json()["code"] == "share_links_off" and "允许建分享链接" in r.json()["detail"] + listed = (await admin.get(f"/api/apps/{app_id}/shares")).json() + assert listed["enabled"] is False and listed["items"] == [] and listed["max_days"] == 90 + await set_switch(True) + pia = await auth_env.login("pia") + r = await pia.post(f"/api/apps/{app_id}/shares", json={"page_id": "home", "expires_at": soon()}) + assert r.status_code == 403 and r.json()["code"] == "app_forbidden", "查看全部实例也不够,要管理权限" + made = await share(app_id) + assert made["path"] == f"/s#{made['key']}" and made["state"] == "active" and made["mode"] == "latest" + async with SessionLocal() as s: + row = await s.get(ViewShare, made["id"]) + assert row.key_hash != made["key"] and made["key"] not in json.dumps( + {c.name: str(getattr(row, c.name)) for c in ViewShare.__table__.columns}), "库里没有 key 的明文" + assert row.prefix == made["key"][:6] and made["prefix"] == row.prefix + listed = (await admin.get(f"/api/apps/{app_id}/shares")).json() + assert [i["id"] for i in listed["items"]] == [made["id"]] and "key" not in listed["items"][0] + assert listed["items"][0]["page_title"] == "首页" + + +@pytest.mark.parametrize("expires, fragment", [(None, "必须设到期时间"), (-1, "现在之后"), (91, "最长 90 天")]) +async def test_expiry_is_required_and_at_most_90_days(world, expires, fragment): # noqa: F811 + wf, h = await workflow(KPI) + app_id = await published(world, board(wf, h), []) + admin = await auth_env.login("root") + body = {"page_id": "home", **({"expires_at": soon(expires)} if expires is not None else {})} + r = await admin.post(f"/api/apps/{app_id}/shares", json=body) + assert r.status_code == 422 and r.json()["code"] == "share_expires_invalid" and fragment in r.json()["detail"] + ok = await admin.post(f"/api/apps/{app_id}/shares", json={"page_id": "home", "expires_at": soon(90)}) + assert ok.status_code == 201 + + +async def test_pages_without_sealed_views_and_personal_apps_cannot_be_shared(world): # noqa: F811 + wf, h = await workflow(KPI) + on_demand = board(wf, h, views={"latest": {"source": {"kind": "on_demand", "pin": "main"}}}, + body=[{"id": "gmv", "type": "metric", "bind": {"view": "latest", "metric": "card.gmv"}}]) + app_id = await published(world, on_demand, []) + admin = await auth_env.login("root") + r = await admin.post(f"/api/apps/{app_id}/shares", json={"page_id": "home", "expires_at": soon()}) + assert r.status_code == 409 and r.json()["code"] == "share_no_sealed_view" + r = await admin.post(f"/api/apps/{app_id}/shares", json={"page_id": "nope", "expires_at": soon()}) + assert r.status_code == 404 + draft_only = (await admin.post("/api/apps", json={"name": "草稿", "spec": board(wf, h)})).json()["id"] + world["apps"].append(draft_only) + r = await admin.post(f"/api/apps/{draft_only}/shares", json={"page_id": "home", "expires_at": soon()}) + assert r.status_code == 409 and r.json()["code"] == "app_unpublished" + from app.db.models import App + + async with SessionLocal() as s: + (await s.get(App, draft_only)).scope = "personal" + await s.commit() + r = await admin.post(f"/api/apps/{draft_only}/shares", json={"page_id": "home", "expires_at": soon()}) + assert r.json()["code"] in ("personal.not_shareable", "app_not_found"), r.text + + +# -------------------------------------------------------------------------- +# 打开 +# -------------------------------------------------------------------------- + + +async def test_the_page_is_public_but_only_trusts_the_header(world): # noqa: F811 + wf, h = await workflow(KPI) + app_id = await published(world, board(wf, h), []) + run = await formal(wf) + made = await share(app_id) + async with anonymous() as c: + missing = await c.get("/api/shares/page", params={"key": made["key"]}) + assert missing.status_code == 400 and missing.json()["code"] == "share_key_missing", "地址里带的 key 不认" + wrong = await c.get("/api/shares/page", headers={HEADER: made["key"][:-2] + "zz"}) + assert wrong.status_code == 404 and wrong.json()["code"] == "share_not_found" + r = await c.get("/api/shares/page", headers={HEADER: made["key"]}) + assert r.status_code == 200, r.text + body = r.json() + assert body["format"] == "agentlab.share/1" + assert body["share"]["created_by"] == "root" and body["share"]["mode"] == "latest" + view = body["view"] + assert view["format"] == "agentlab.view/1" and view["sources"]["latest"]["run_id"] == run.id + gmv = view["tree"][0]["data"] + assert gmv["state"] == "ok" and "4,321.5" in gmv["text"], "看到的就是使用者看到的那份" + assert view["sources"]["latest"]["stamp"]["sealed"] is True, "印章照常算" + assert body["downloads"] == [{"view": "latest", "run_id": run.id, "reports": [], + "as_of_text": view["sources"]["latest"]["stamp"]["as_of_text"]}] + + +async def test_only_sealed_views_and_flow_components_become_placeholders(world): # noqa: F811 + wf, h = await workflow(KPI) + spec = board(wf, h, views={ + "latest": {"source": {"kind": "latest_sealed", "pin": "main", "from": "formal"}}, + "ask": {"source": {"kind": "on_demand", "pin": "main", "auto": True}}}, body=[ + {"id": "gmv", "type": "metric", "bind": {"view": "latest", "metric": "card.gmv"}}, + {"id": "asked", "type": "metric", "bind": {"view": "ask", "metric": "card.gmv"}}, + {"id": "todo", "type": "task_list"}, + {"id": "mine", "type": "instance_list"}]) + app_id = await published(world, spec, []) + await formal(wf) + made = await share(app_id) + async with SessionLocal() as s: + before = await s.scalar(select(func.count(Run.id))) + async with anonymous(**{HEADER: made["key"]}) as c: + view = (await c.get("/api/shares/page")).json()["view"] + async with SessionLocal() as s: + assert await s.scalar(select(func.count(Run.id))) == before, "打开分享页从不发起运行(声明了 auto 的按需视图也不)" + by = {n["id"]: n for n in view["tree"]} + assert by["gmv"]["data"]["state"] == "ok" + assert by["asked"]["data"]["state"] == "empty" and "只显示定时出数的视图" in by["asked"]["data"]["message"] + for cid, kind in (("todo", "task_list"), ("mine", "instance_list")): + assert by[cid]["type"] == "notice" and by[cid]["share_placeholder"] == kind and by[cid]["props"]["text"] + assert "data" not in by[cid], "不取实例、待办" + + +async def test_fixed_mode_stays_on_the_runs_it_was_created_with(world): # noqa: F811 + wf, h = await workflow(KPI) + app_id = await published(world, board(wf, h), []) + first = await formal(wf, "W1") + fixed = await share(app_id, mode="fixed") + latest = await share(app_id) + second = await formal(wf, "W2") + async with anonymous(**{HEADER: fixed["key"]}) as c: + pinned = (await c.get("/api/shares/page")).json() + other = await c.get("/api/shares/report.html", params={"run": second.id}) + assert other.status_code == 404 and other.json()["code"] == "share_run_not_shared" + async with anonymous(**{HEADER: latest["key"]}) as c: + moving = (await c.get("/api/shares/page")).json() + assert pinned["view"]["sources"]["latest"]["run_id"] == first.id + points = pinned["view"]["tree"][1]["data"]["series"][-1]["points"] + assert [p["run_id"] for p in points] == [first.id], "趋势也停在建分享的那一期" + assert moving["view"]["sources"]["latest"]["run_id"] == second.id + + +async def test_report_download_only_for_the_shares_runs_and_masked(world, monkeypatch, tmp_path): # noqa: F811 + import sqlite3 + + from app.data.engine import engines + from app.engine.launch import launch_run + from tests import test_report_masked_metrics as vipmod + + path = tmp_path / "vip.db" + db = sqlite3.connect(path) + db.executescript("CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, phone TEXT);") + db.executemany("INSERT INTO customers VALUES (?, ?, ?)", vipmod.CUSTOMERS) + db.commit() + db.close() + source_id = await vipmod.set_source(path, {"mask_columns": ["phone"]}) + await engines.invalidate(source_id) + world["sources"].append("vip") + vipmod.script(monkeypatch, vipmod.CLEAN) + wf, h = await workflow(vipmod.graph(on_violation="flag")) + spec = board(wf, h, body=[{"id": "n", "type": "metric", "bind": {"view": "latest", "metric": "card.n_cust"}}, + {"id": "doc", "type": "report", "bind": {"view": "latest", "report": "write"}}]) + app_id = await published(world, spec, []) + async with SessionLocal() as s: + launched = await launch_run(s, workflow_id=wf, run_class="formal", version=1, input_payload={}, + actor="编辑", origin_kind="launcher") + run = await wait(launched.id) + assert run.status == "succeeded", run.error + made = await share(app_id) + kpi_wf, _ = await workflow(KPI) + stranger = await formal(kpi_wf) + async with anonymous(**{HEADER: made["key"]}) as c: + body = (await c.get("/api/shares/page")).json() + by = {n["id"]: n for n in body["view"]["tree"]} + assert by["doc"]["type"] == "notice" and "下载报告" in by["doc"]["props"]["text"], "报告正文不在线打开" + [download] = body["downloads"] + assert download["run_id"] == run.id and download["reports"] == ["write"] + report = await c.get("/api/shares/report.html", params={"run": run.id}) + assert report.status_code == 200, report.text + assert "attachment" in report.headers["content-disposition"] + assert vipmod.leaks(report.text) == [], "报告里没有遮罩列的原值" + refused = await c.get("/api/shares/report.html", params={"run": stranger.id}) + assert refused.status_code == 404 and refused.json()["code"] == "share_run_not_shared" + no_header = await anonymous().get("/api/shares/report.html", params={"run": run.id}) + assert no_header.status_code == 400 + async with SessionLocal() as s: + exported = (await s.execute(select(AuditEvent).where(AuditEvent.action == "report.exported", + AuditEvent.subject_id == run.id))).scalars().all() + assert [(e.actor, e.verified) for e in exported] == [(None, False)], "导出记审计,导出人不冒充谁" + + +async def test_revoked_and_expired_links_say_so_with_410(world): # noqa: F811 + wf, h = await workflow(KPI) + app_id = await published(world, board(wf, h), []) + await formal(wf) + gone = await share(app_id) + old = await share(app_id) + admin = await auth_env.login("root") + r = await admin.delete(f"/api/apps/{app_id}/shares/{gone['id']}") + assert r.status_code == 200 and r.json()["state"] == "revoked" and r.json()["revoked_by"] == "root" + async with SessionLocal() as s: + (await s.get(ViewShare, old["id"])).expires_at = utcnow() - timedelta(minutes=5) + await s.commit() + async with anonymous() as c: + r = await c.get("/api/shares/page", headers={HEADER: gone["key"]}) + assert r.status_code == 410 and r.json()["code"] == "share_revoked" and "链接已撤销" in r.json()["detail"] + r = await c.get("/api/shares/page", headers={HEADER: old["key"]}) + assert r.status_code == 410 and r.json()["code"] == "share_expired" and "链接已到期" in r.json()["detail"] + live = await share(app_id) + await set_switch(False) + async with anonymous(**{HEADER: live["key"]}) as c: + r = await c.get("/api/shares/page") + assert r.status_code == 403 and r.json()["code"] == "share_links_off", "管理员关掉开关,已有的一并打不开" + assert (await admin.delete(f"/api/apps/{app_id}/shares/{live['id']}")).status_code == 200, "关着也能撤销" + + +async def test_create_and_revoke_are_audited_and_opening_only_counts(world): # noqa: F811 + wf, h = await workflow(KPI) + app_id = await published(world, board(wf, h), []) + await formal(wf) + made = await share(app_id, embed_allowed=True) + async with anonymous(**{HEADER: made["key"]}) as c: + for _ in range(3): + assert (await c.get("/api/shares/page")).status_code == 200 + admin = await auth_env.login("root") + await admin.delete(f"/api/apps/{app_id}/shares/{made['id']}") + await admin.delete(f"/api/apps/{app_id}/shares/{made['id']}") + async with SessionLocal() as s: + row = await s.get(ViewShare, made["id"]) + actions = (await s.execute(select(AuditEvent.action, AuditEvent.actor).where( + AuditEvent.subject_id == made["id"]).order_by(AuditEvent.at))).all() + assert row.open_count == 3 and row.last_opened_at is not None and row.embed_allowed is True + assert actions == [("app.share_create", "root"), ("app.share_revoke", "root")], "打开不记审计;撤销两次只记一次" + + +async def test_access_keys_cannot_reach_share_routes(world): # noqa: F811 + wf, h = await workflow(KPI) + app_id = await published(world, board(wf, h), []) + made = await share(app_id) + svc = await auth_env.make_service() + _, secret = await auth_env.make_key(svc) + c = auth_env.keyed(secret, **{HEADER: made["key"]}) + for method, path in (("GET", "/api/shares/page"), ("GET", f"/api/apps/{app_id}/shares"), + ("POST", f"/api/apps/{app_id}/shares")): + r = await c.request(method, path, json={"page_id": "home", "expires_at": soon()} if method == "POST" else None) + assert r.status_code == 403 and r.json()["code"] == "key_route_forbidden", (method, path) + + +# -------------------------------------------------------------------------- +# /s 的安全头 +# -------------------------------------------------------------------------- + + +_PROBE = r""" +import asyncio, json, httpx +from app.main import app + +async def main(): + t = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=t, base_url="http://t") as c: + out = {} + for p in ("/s", "/apps/x", "/api/shares/page"): + r = await c.get(p) + out[p] = {"status": r.status_code, **{k: r.headers.get(k) for k in ( + "content-security-policy", "x-frame-options", "referrer-policy")}} + print(json.dumps(out)) + +asyncio.run(main()) +""" + + +def test_share_page_uses_the_embed_whitelist_and_no_referrer(tmp_path: Path) -> None: + dist = tmp_path / "dist" + (dist / "assets").mkdir(parents=True) + (dist / "index.html").write_text("
") + env = {**os.environ, "AGENTLAB_DATA_DIR": str(tmp_path / "data"), "AGENTLAB_WEB_DIST": str(dist), + "AGENTLAB_SECRET_KEY": "test-only-not-a-real-key", "AGENTLAB_EMBED_ORIGINS": "https://bi.example.org", + "AGENTLAB_FRAME_ANCESTORS": ""} + proc = subprocess.run([sys.executable, "-c", _PROBE], cwd=BACKEND_DIR, env=env, capture_output=True, text=True, + timeout=120) + assert proc.returncode == 0, proc.stderr[-2000:] + out = json.loads(proc.stdout.strip().splitlines()[-1]) + assert out["/s"]["status"] == 200, "分享页由页面托管给出(不进外壳由前端定)" + assert out["/s"]["content-security-policy"] == "frame-ancestors 'self' https://bi.example.org" + assert out["/s"]["x-frame-options"] is None and out["/s"]["referrer-policy"] == "no-referrer" + assert out["/apps/x"]["content-security-policy"] == "frame-ancestors 'self'", "别的页面不跟着放开嵌入" + assert out["/apps/x"]["x-frame-options"] == "SAMEORIGIN" + assert out["/api/shares/page"]["status"] == 400, "关登录时也只认请求头" diff --git a/backend/tests/test_web_security.py b/backend/tests/test_web_security.py index cc74f582..48be7279 100644 --- a/backend/tests/test_web_security.py +++ b/backend/tests/test_web_security.py @@ -4,7 +4,7 @@ - 页面、/api 的响应(连同登录检查自己回的 401)都带 nosniff、Referrer-Policy 和 CSP frame-ancestors; - frame-ancestors 缺省 'self',只有 'self' 时另加 X-Frame-Options: SAMEORIGIN;配了 AGENTLAB_FRAME_ANCESTORS 白名单时 以 CSP 为准、不发 XFO(XFO 写不了白名单,两个都发的话老浏览器按 XFO 把白名单里的门户也拦了); -- 分享页 /s 用 AGENTLAB_EMBED_ORIGINS、Referrer-Policy: no-referrer(路由第 3 波才加,头的规则先守住); +- 分享页 /s 用 AGENTLAB_EMBED_ORIGINS、Referrer-Policy: no-referrer(真实页面托管下的头另见 test_shares.py); - 严格的 script-src 只发 Content-Security-Policy-Report-Only,只在页面(index.html)上发,哈希就是构建产物里内联 脚本的 sha256;接口返回的 HTML(导出的报告自带 meta CSP)不带它; - 接口自己设过的同名头不覆盖、不重复;WebSocket 不经过它; diff --git a/deploy/agentlab.env b/deploy/agentlab.env index d121040b..d524a69f 100644 --- a/deploy/agentlab.env +++ b/deploy/agentlab.env @@ -38,10 +38,16 @@ BIND_ADDR=0.0.0.0 # X-Frame-Options: SAMEORIGIN),别的站点嵌进去会被浏览器拦下,防点击劫持。HSTS 不在这里加,交给终结 https 的反向代理。 # 同站的门户(和 AgentLab 在同一个可注册域名下,比如 portal.example.internal 和 agentlab.example.internal)要嵌整站时, # 把门户的地址写在下面,逗号分隔,只写 协议://主机[:端口],主机可以写 *.example.internal。填了之后只发 -# frame-ancestors、不再发 X-Frame-Options。登录用的 cookie 只随同站的请求带上,跨站的门户嵌整站登录不了。 +# frame-ancestors、不再发 X-Frame-Options。同站时登录用的会话 cookie(SameSite=Lax)会随 iframe 里的请求带上,看的人在 +# 门户里照常登录、单点登录照常;嵌进去的就是整站,权限和直接打开一样。 +# 跨站的门户(不在同一个可注册域名下)嵌整站不支持:浏览器不把会话 cookie 带进跨站的 iframe,登录不了;为此把 cookie +# 改成 SameSite=None 会削弱整站防跨站请求伪造的那一层,Safari、Firefox 默认还是会拦。跨站要嵌,请用下面的分享链接。 # 写错了(带路径、没写协议、只写 *)服务启动时报错。 # AGENTLAB_FRAME_ANCESTORS=https://portal.example.internal -# 分享页(/s)允许被哪些站点嵌,写法同上。分享页只认这一项、不认上面那一项。分享链接还没有上线,现在填了不起作用 +# 分享页(/s,分享链接打开的只读页面)允许被哪些站点嵌,写法同上。分享页只认这一项、不认上面那一项;不填时只有 AgentLab +# 自己的页面能嵌。分享链接要管理员在「设置 → 偏好设置」的「应用」一节里打开「允许建分享链接」,再由管理应用的人在 +# 应用编辑的「岗位与授权」里建(必设到期、最长 90 天,可撤销);建的时候没勾「允许嵌入」的那一条,在别的页面的 iframe 里 +# 不显示内容。分享页另带 Referrer-Policy: no-referrer # AGENTLAB_EMBED_ORIGINS=https://bi.example.internal # ---- 数据 ---- From f887cdc30a79c5ec125ae803932629b2f61d635e Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 16:44:06 +0800 Subject: [PATCH 3/7] =?UTF-8?q?W3-d=20=E9=82=AE=E4=BB=B6=E3=80=81=E5=BE=85?= =?UTF-8?q?=E5=8A=9E=E6=B1=87=E6=80=BB=E3=80=81=E5=88=86=E4=BA=AB=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E7=9A=84=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 设置 → 通知:渠道多一种格式「邮件」,邮件服务没启用时不列;个人邮件渠道不填地址,写「发到账号邮箱 …」(账号没有邮箱照实写 发不出去),全局、应用的填收件地址;投递记录里邮件成功写「已交给邮件服务器」、悬停说明退信不追踪;新增「邮件服务」一节 (管理员改、搭建者只看:口令留空不改、可清除,发测试邮件要先保存)和「每天的待办汇总」的发送时间 - 设置 → 偏好设置的「应用」一节:「允许建分享链接」开关(缺省关,写明关掉时已有的一并打不开) - 应用编辑 → 岗位与授权:分享链接的管理(选页面、始终最新 / 固定这一期、有效期 1–90 天、允许嵌入;链接只显示这一次; 列出状态、到期、打开次数,撤销先确认);开关关着时不给新建,已有的照列能撤销;个人应用写明不能分享 - /s 分享页:不进外壳(App 在 /s 上整个跳过认人、取目录、心跳、读主题,请求层在 /s 上也不送去登录页),key 从 # 后面读、 放进请求头;同一个 ViewRenderer 渲染、操作全禁用;印章常显(「数据的来历」逐个列视图的印章);点数字说明出处在下载的 报告里;页脚「只读 · 由 X 于 … 分享 · … 到期」;撤销、到期、关了开关、链接不完整按服务端的话写明;没允许嵌入的分享在别的 页面的 iframe 里不显示内容、给「在新窗口打开」 - ViewRenderer 加 onEvidence:点数字交给调用方(分享页),不开在线证据面板 - check-copy 加「快照链接」一条(总方案 X10:写「分享链接」),带自检 --- backend/app/api/settings.py | 2 +- frontend/src/App.tsx | 11 + frontend/src/api/apps.ts | 14 +- frontend/src/api/capabilities.ts | 8 +- frontend/src/api/client.ts | 2 + frontend/src/api/http.ts | 3 +- frontend/src/api/shares.ts | 42 +++ frontend/src/apps/ViewRenderer.tsx | 14 +- frontend/src/lib/terms.ts | 6 +- frontend/src/lib/termsApp.ts | 119 +++++++ frontend/src/pages/SettingsPage.tsx | 20 +- frontend/src/pages/apps/SharePage.tsx | 178 ++++++++++ .../src/pages/apps/edit/AccessSections.tsx | 172 +++++++++- frontend/src/pages/settings/NotifyTab.tsx | 316 ++++++++++++++++-- frontend/src/typesApp.ts | 74 +++- scripts/check-copy.mjs | 6 +- 16 files changed, 936 insertions(+), 51 deletions(-) create mode 100644 frontend/src/api/shares.ts create mode 100644 frontend/src/pages/apps/SharePage.tsx diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index c1f0fdaa..bc6f3e25 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -646,7 +646,7 @@ def notify_problem(value: Any) -> str | None: from app.notify.digest import time_ok if not time_ok(item): - return f"「{NOTIFY_SETTING_LABEL[key]}」需要写成 时:分(00:00 到 23:59,例如 09:00),当前为「{item}」" + return f"「{NOTIFY_SETTING_LABEL[key]}」需要是 24 小时制的时间,例如 09:00(从 00:00 到 23:59),当前为「{item}」" elif problem := _base_url_problem(item): return problem return None diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f312c5f4..e4f8696f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,6 +32,7 @@ import { NewAppStart } from './pages/blueprints/NewAppStart' import { BlueprintPage } from './pages/blueprints/BlueprintPage' import { personalAppsOn } from './pages/blueprints/shared' import { LoginPage } from './pages/LoginPage' +import { SharePage } from './pages/apps/SharePage' import { Logo } from './components/Logo' import { CommandPalette, PAGES, ShortcutHelp, flushThemePref, openShortcutHelp, toggleCommandPalette, toggleNotify, @@ -56,7 +57,17 @@ import { APP_TEXT, AUTH_MODE_LABEL, AUTH_TEXT, ROLE_LABEL, RUN_COMPARE_TEXT, TAS /** 启动时认人(/auth/me)最多等多久再取目录 */ const AUTH_WAIT_MS = 2500 +/** + * 分享页(/s#<分享 key>,04 细案 C2)不在外壳里,比登录页还早一步分开:拿到链接的人没有登录这回事,外壳的认人、取目录、 + * 心跳、读主题一样都不做(开了登录时它们会 401,再被请求层送去登录页)。整页加载时定一次:分享页上没有通往外壳的链接 + */ +const SHARE_PAGE = typeof window !== 'undefined' && window.location.pathname === '/s' + export default function App() { + return SHARE_PAGE ? : +} + +function Shell() { const loaded = useCatalog((s) => s.loaded) const backend = useCatalog((s) => s.backend) const everOk = useCatalog((s) => s.lastOkAt != null) diff --git a/frontend/src/api/apps.ts b/frontend/src/api/apps.ts index 221d6964..ecda1b52 100644 --- a/frontend/src/api/apps.ts +++ b/frontend/src/api/apps.ts @@ -6,10 +6,11 @@ */ import type { AppBindings, AppDetail, AppGrant, AppHookRow, AppList, HookReceipts, AppSpec, AppSummary, AppVersionRow, BackfillPlan, BackfillResult, - ComponentCatalog, Disposition, InstanceRow, LintIssue, PublishResult, ViewModel, + ComponentCatalog, Disposition, InstanceRow, LintIssue, PublishResult, ViewModel, ViewShareCreated, ViewShareList, + ViewShareRow, } from '../typesApp' import type { ConversationDetail, EvidenceSegmentDetail } from '../types' -import { del, get, patch, post, put, qs, type RequestOptions } from './http' +import { del, get, patch, post, put, qs, request, type RequestOptions } from './http' const at = (id: string) => `/apps/${encodeURIComponent(id)}` const hookAt = (id: string, name: string) => `${at(id)}/hooks/${encodeURIComponent(name)}` @@ -74,6 +75,15 @@ export const apps = { deleteHook: (id: string, name: string) => del(hookAt(id, name)), /** 钩子最近收到的请求(manage):状态、原因、发起的实例或解决的待办,加近 1 小时验签失败几次(04 细案 A6) */ hookReceipts: (id: string, name: string) => get(`${hookAt(id, name)}/receipts`), + /** + * 分享链接(manage,服务端 api/shares.py):列、建、撤销。建要管理员开了「允许建分享链接」,到期必填(最长 max_days 天); + * key 只在建的这一次交回 + */ + shares: (id: string) => get(`${at(id)}/shares`), + createShare: (id: string, body: { page_id: string; mode: 'latest' | 'fixed'; expires_at: string; embed_allowed: boolean }) => + post(`${at(id)}/shares`, body), + revokeShare: (id: string, shareId: string) => + request(`${at(id)}/shares/${encodeURIComponent(shareId)}`, { method: 'DELETE' }), /** * 应用编辑器里助手的对话(D-e,manage):取这个应用的那段对话,没有就建(kind=app、归属这个应用)。带着历史轮次; * 老服务端只回对话本身时由调用方再按 id 取一次 diff --git a/frontend/src/api/capabilities.ts b/frontend/src/api/capabilities.ts index 08fde911..95d67e4a 100644 --- a/frontend/src/api/capabilities.ts +++ b/frontend/src/api/capabilities.ts @@ -5,7 +5,7 @@ import type { CatalogReviewAction, CatalogSource, CatalogStatus } from '../types' import type { CapabilityEffect, CapabilityReviewOutcome, CapabilityReviewRow, CapabilityRow, EndpointRow, NotifyChannelRow, - NotifyEventRow, + NotifyEventRow, NotifyMailState, } from '../typesApp' import { del, get, patch, post, put, qs, type RequestOptions } from './http' @@ -122,8 +122,10 @@ export const endpoints = { } export const notify = { - channels: () => get<{ items: NotifyChannelRow[] }>('/notify/channels'), - createChannel: (body: { name: string; scope: string; owner_id?: string | null; format: string; url: string; + /** mail:邮件服务启没启用、本人的账号邮箱(老服务端不给) */ + channels: () => get<{ items: NotifyChannelRow[]; mail?: NotifyMailState }>('/notify/channels'), + /** 邮件渠道:个人的不带 url(只发到账号邮箱),全局、应用的 url 是收件地址(逗号或换行分隔,最多 20 个) */ + createChannel: (body: { name: string; scope: string; owner_id?: string | null; format: string; url?: string; secret?: string | null; events: string[] }) => post('/notify/channels', body), updateChannel: (id: string, body: Record) => patch(`/notify/channels/${encodeURIComponent(id)}`, body), removeChannel: (id: string) => del(`/notify/channels/${encodeURIComponent(id)}`), diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 0930d931..d37f2e4b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -689,6 +689,8 @@ export const api = { /** 今天(本地日期)结论句裁判花了多少:设置页放在每日上限旁边。只读,计数只有裁判自己记 */ judgeSpend: () => get<{ date?: string; usd?: number; calls?: number; unpriced_calls?: number daily_max_usd?: number | null }>('/settings/judge/spend'), + /** 按存着的邮件服务设置发一封测试邮件,发到管理员自己的账号邮箱(管理员;关登录、没有邮箱时 422) */ + mailTest: () => post<{ ok: boolean; message: string }>('/settings/mail/test'), }, // ---- 工具 ---- diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts index d7903892..3e280d5d 100644 --- a/frontend/src/api/http.ts +++ b/frontend/src/api/http.ts @@ -104,7 +104,8 @@ export function actorHeader(): Record { function toLogin(path?: string) { if (typeof window === 'undefined' || path === '/auth/login' || path === '/auth/me') return const { pathname, search } = window.location - if (pathname === '/login') return + // 分享页(/s)上没有登录这回事:不送去登录页 + if (pathname === '/login' || pathname === '/s') return const back = `${pathname}${search}` window.location.assign(back && back !== '/' ? `/login?next=${encodeURIComponent(back)}` : '/login') } diff --git a/frontend/src/api/shares.ts b/frontend/src/api/shares.ts new file mode 100644 index 00000000..49390180 --- /dev/null +++ b/frontend/src/api/shares.ts @@ -0,0 +1,42 @@ +/** + * 分享页(/s)的两个接口:GET /api/shares/page、GET /api/shares/report.html(服务端 api/shares.py,04 细案 C2)。 + * + * 分享 key 在链接的 # 后面(/s#),打开页面时不发给服务器;这里读出来放进请求头 X-AgentLab-Share,服务端只认请求头。 + * 不经 http.request:那条路会带署名头、把 401 当成「会话没了」整页跳去登录页——分享页上没有登录这回事,也不该在外壳的连接 + * 状态里报来报去。报错照样翻成 ApiError({detail, code}),页面按 code 写「链接已撤销 / 已到期」。 + * + * 只从 ./http、./bundle 导入(同 reportExport.ts,避免和 client.ts 循环导入)。 + */ +import type { SharePayload } from '../typesApp' +import { ApiError, BASE, NETWORK_MESSAGE, codeOf, describeDetail } from './http' +import { dispositionName } from './bundle' + +export const SHARE_HEADER = 'X-AgentLab-Share' + +async function call(path: string, key: string): Promise { + let res: Response + try { + res = await fetch(BASE + path, { headers: { [SHARE_HEADER]: key }, credentials: 'omit', referrerPolicy: 'no-referrer' }) + } catch (e: any) { + throw new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: `${e?.name ?? 'Error'}: ${e?.message ?? e}` }) + } + if (res.ok) return res + const text = await res.text().catch(() => '') + let body: any = null + try { body = JSON.parse(text) } catch { /* 不是 JSON(代理回的页面) */ } + const detail = body?.detail + throw new ApiError(res.status, detail ? describeDetail(detail, path) : `请求失败(HTTP ${res.status})`, + { detail, code: codeOf(body), body, raw: `${res.status} ${text.slice(0, 500)}` }) +} + +export const shares = { + /** 分享页的视图模型、页脚、能下载报告的运行 */ + page: async (key: string): Promise => (await call('/shares/page', key)).json(), + /** 下载报告(附件):只认这条分享解析出来的运行;文件名取服务端给的,没给是 null */ + report: async (key: string, runId: string, reportNode?: string): Promise<{ blob: Blob; name: string | null }> => { + const q = new URLSearchParams({ run: runId }) + if (reportNode) q.set('report', reportNode) + const res = await call(`/shares/report.html?${q.toString()}`, key) + return { blob: await res.blob(), name: dispositionName(res.headers.get('Content-Disposition')) } + }, +} diff --git a/frontend/src/apps/ViewRenderer.tsx b/frontend/src/apps/ViewRenderer.tsx index 26ce0974..5e43dbfc 100644 --- a/frontend/src/apps/ViewRenderer.tsx +++ b/frontend/src/apps/ViewRenderer.tsx @@ -11,6 +11,9 @@ * 隐藏(normal-user-apps N6):作者隐藏的组件,发布版解析时服务端就跳过;按草稿解析时照样下发、带 hidden。这里只看这个 * 标记:应用页上直接改的编辑状态(editing)画成半透明、标「已隐藏」,别处(编辑器的预览)照使用者看到的样子不画。 * 编辑状态另给草稿里隐藏了的组件 id(editing.hidden):服务端没带标记的也认得出来。 + * + * 分享页(pages/apps/SharePage,04 细案 C2)也用它:传 preview(不发起、不刷新)和 onEvidence——点数字不打开在线出处(那要 + * 按运行读证据的身份,分享页上没有),交给分享页说明「出处在下载的报告里」。 */ import { createContext, useCallback, useContext, useMemo, useState } from 'react' import type { ReactNode } from 'react' @@ -35,6 +38,8 @@ export interface ViewRendererProps { onReload?: () => void /** 应用页上直接改的编辑状态:隐藏的组件画成半透明(不传时隐藏的组件不画) */ editing?: ViewEditMarks + /** 点数字看出处时不开证据面板、交给调用方(分享页:出处在下载的报告里) */ + onEvidence?: (evidence: ViewEvidence, label: string, text?: string) => void } /** 编辑状态的标记:草稿里隐藏了的组件 id */ @@ -47,11 +52,12 @@ const EditMarksCtx = createContext(null) /** 服务端按草稿解析时给隐藏的组件带的标记(ViewNode.hidden,发布版里没有这样的组件),加上这一次刚隐藏的 */ const isHidden = (node: ViewNode, marks: ViewEditMarks | null) => node.hidden === true || !!marks?.hidden.has(node.id) -export function ViewRenderer({ model, appId, runId, preview = false, onNavigate, onReload, editing }: ViewRendererProps) { +export function ViewRenderer({ model, appId, runId, preview = false, onNavigate, onReload, editing, onEvidence }: ViewRendererProps) { const [evidence, setEvidence] = useState(null) const openEvidence = useCallback((ev: ViewEvidence, label: string, text?: string) => { - setEvidence({ evidence: ev, label, text }) - }, []) + if (onEvidence) onEvidence(ev, label, text) + else setEvidence({ evidence: ev, label, text }) + }, [onEvidence]) // 视图模型换了(刷新、重新取)就是新的一代:画坏了的组件跟着重试一次 const gen = useMemo(() => String(++generation), [model]) const render = useCallback((nodes: ViewNode[] | undefined): ReactNode => @@ -81,7 +87,7 @@ export function ViewRenderer({ model, appId, runId, preview = false, onNavigate, )} {render(model.tree)} - setEvidence(null)} /> + {!onEvidence && setEvidence(null)} />} ) } diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index e93f53d7..41775fe0 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -5611,7 +5611,8 @@ export const NOTIFY_TEXT = { urlHint: '地址加密保存,之后只显示主机名', secret: '签名密钥(可选)', format: '格式', - formats: { generic: '通用 JSON', feishu: '飞书群机器人', wecom: '企业微信群机器人', dingtalk: '钉钉群机器人' } as Record, + // email:邮件(第五种格式,服务端 notify/mail.py):要先在「邮件服务」一节配好并启用 + formats: { generic: '通用 JSON', feishu: '飞书群机器人', wecom: '企业微信群机器人', dingtalk: '钉钉群机器人', email: '邮件' } as Record, scope: '范围', scopes: { global: '全局', app: '应用', user: '个人' } as Record, events: '订阅的事件', @@ -6085,6 +6086,9 @@ export const APP_SETTING_TEXT = { onDemandLimitInvalid: '请填写 0 到 100,000 之间的整数', personalApps: '使用者可以建个人应用', personalAppsHint: '开启后,使用者可以从说需求、场景模板或问数据的回答建一个只有自己看得见的应用:只读、结果遮罩,每人每天的刷新次数按上面的每日上限算;想给别人用要提交给搭建者审核发布。搭建者及以上不受这一项限制', + // 分享链接(服务端 apps.share_links,04 细案 C2):默认关 + shareLinks: '允许建分享链接', + shareLinksHint: '开启后,管理应用的人可以在应用编辑的「岗位与授权」里给一页建分享链接:拿到链接的人不用登录就能看(只读、只显示定时出数的视图、数据和使用者看到的一样遮过),必须设到期、最长 90 天、随时可以撤销。关掉时已有的分享链接一并打不开', } /** diff --git a/frontend/src/lib/termsApp.ts b/frontend/src/lib/termsApp.ts index ef185807..43d29ef6 100644 --- a/frontend/src/lib/termsApp.ts +++ b/frontend/src/lib/termsApp.ts @@ -802,3 +802,122 @@ export const HOOK_RECEIPT_TEXT = { ? `近 1 小时验签失败 ${n} 次:验签失败的请求不留记录,只计数` : '近 1 小时没有验签失败的请求'), } + +/** + * 邮件(通知渠道的第五种格式,服务端 notify/mail.py,04 细案 B2):设置 → 通知里的「邮件服务」「每天的待办汇总」两节、 + * 渠道里的邮件格式。照实说:成功只写「已交给邮件服务器」,退信不追踪,不说已送达 + */ +export const MAIL_TEXT = { + section: '邮件服务', + hint: '通知渠道选「邮件」时用这台邮件服务器发信。口令加密保存,之后只显示「已设置」', + readOnly: '只有管理员能修改邮件服务', + enabled: '启用邮件', + enabledHint: '关着时通知渠道里没有「邮件」这一格式;已有的邮件渠道发不出去,投递记录里写明原因', + host: 'SMTP 服务器', + hostHint: '主机名或 IP 地址,例如 smtp.example.com,不带协议和端口', + port: '端口', + security: '加密方式', + securityLabel: { starttls: 'STARTTLS(常用端口 587)', ssl: 'SSL/TLS(常用端口 465)', none: '不加密(只在内网中继时用)' } as Record, + username: '用户名', + usernameHint: '服务器不要求登录时留空', + password: '口令', + passwordSet: '已设置;留空表示不修改', + passwordUnset: '未设置', + passwordClear: '清除已保存的口令', + fromAddr: '发件地址', + fromName: '发件人名称', + defaultAssignees: '默认发邮件给待办处理人', + defaultAssigneesHint: '打开后,第一次有待办派给某人时,自动给他建一个个人邮件渠道(订阅待办打开、动作结果未知、审核有结果),发到他的账号邮箱;本人之后可以改订阅或关掉、删掉。缺省关:刚配好邮件服务时不会突然往外发邮件', + save: '保存邮件服务', + saved: '已保存邮件服务设置', + invalidPort: '请填写 1 到 65535 之间的整数', + leaveBody: '邮件服务有没保存的改动,离开将丢失这些改动。', + test: '发测试邮件', + testHint: '按已保存的设置发一封到你自己的账号邮箱', + testDirty: '先保存再发测试邮件', + handoff: '已交给邮件服务器', + handoffHint: '「已交给邮件服务器」不等于已经进了收件箱:退信不追踪,是否送达以邮件服务器为准', + // 每天的待办汇总(服务端 notify/digest.py) + digest: '每天的待办汇总', + digestHint: '每天这个时间(按服务端所在时区)给订阅了「每天的待办汇总」的个人渠道发一条:还有几条待办、最早的时限、最多 5 条和去待办箱的链接。没有待办的人那天不发', + digestAt: '发送时间', + digestSave: '保存发送时间', + digestSaved: (at: string) => `每天 ${at} 发待办汇总`, + digestReadOnly: '只有管理员能修改发送时间', + // 渠道里的邮件格式 + personalTo: (email: string | null) => (email ? `发到账号邮箱 ${email}` : '你的账号没有邮箱地址,发不出去:请联系管理员在「设置 → 身份」里填写'), + personalToHint: '个人邮件渠道只发到你的账号邮箱,不能填别的地址', + recipients: '收件地址', + recipientsHint: '多个地址用逗号或换行分隔,最多 20 个;人多的请填邮件组的地址', + to: (list: string[]) => (list.length + ? `发到 ${list.slice(0, 2).join('、')}${list.length > 2 ? ` 等 ${list.length} 个地址` : ''}` : ''), + off: '邮件服务没有启用,暂时不能选邮件:请管理员在下面的「邮件服务」里配置并启用', +} + +/** + * 分享链接(服务端 api/shares.py,04 细案 C2):应用编辑 → 岗位与授权里的管理。叫「分享链接」,不叫快照的那种说法: + * 「快照」专指查询快照 + */ +export const SHARE_TEXT = { + section: '分享链接', + hint: '拿到链接的人不用登录就能看这一页:只读,只显示定时出数的视图,看到的数据和使用者一样遮过;出处给「下载报告」。必须设到期,随时可以撤销', + off: '管理员还没有打开分享链接:在「设置 → 偏好设置」的「应用」一节里打开「允许建分享链接」后才能建', + offWithRows: '管理员已关闭分享链接:下面这些暂时打不开,仍然可以撤销', + personal: '个人应用只给你自己用,不能分享', + unpublished: '发布之后才能建分享链接', + create: '新建分享链接', + empty: '还没有分享链接', + page: '分享哪一页', + pageHint: '只显示这一页上定时出数的视图;入口、表单、实例、待办和报告正文在分享页上换成说明', + mode: '数据', + modeLatest: '始终最新:跟着最近一次封存走', + modeFixed: '固定在现在这一期:之后出的数不换上来', + modeShort: { latest: '始终最新', fixed: '固定这一期' } as Record, + expires: '有效期', + expiresDays: (n: number) => `${n} 天`, + expiresHint: (max: number) => `必须设到期,最长 ${max} 天`, + embed: '允许嵌入到别的站点', + embedHint: '勾上之后这条链接能放进门户的 iframe(只限部署配置里允许嵌入分享页的站点);不勾的在别的页面里不显示内容', + embedNoOrigins: '部署没有配置允许嵌入分享页的站点(AGENTLAB_EMBED_ORIGINS),勾了也只能在 AgentLab 自己的页面里嵌', + submit: '建分享链接', + created: '分享链接已建好', + once: '链接只显示这一次,请现在复制下来;之后只看得到开头几位', + dismiss: '我已复制', + state: { active: '有效', revoked: '已撤销', expired: '已到期' } as Record, + embedded: '可嵌入', + opened: (n: number) => (n ? `打开过 ${n} 次` : '还没打开过'), + by: (who: string) => `由 ${who} 建`, + until: (at: string) => `${at} 到期`, + revoke: '撤销', + revokeTitle: (prefix: string) => `撤销分享链接 ${prefix}…?`, + revokeBody: '撤销之后,拿着这条链接的人打开会看到「链接已撤销」,不能恢复;要再分享请建一条新的。', + revoked: '已撤销分享链接', +} + +/** 分享页(/s,不在外壳里):拿到分享链接的人看到的页面 */ +export const SHARE_PAGE_TEXT = { + loading: '正在打开分享的页面…', + readOnly: '只读', + footer: (who: string, at: string, until: string) => `只读 · 由 ${who} 于 ${at} 分享 · ${until} 到期`, + someone: '管理应用的人', + fixed: '固定在分享时的那一期', + latest: '跟着最近一次封存更新', + stamps: '数据的来历', + stampsHint: '每个视图取的是哪一次运行、截至什么时候、按哪个口径、出具档位', + download: '下载报告', + downloadHint: '下载的报告里每个数都点得开出处(口径、来源、计算方式),离线也能看', + downloading: '正在下载…', + downloaded: '已下载报告', + noReports: '这一页的数据没有可下载的报告', + evidenceTitle: '出处在下载的报告里', + evidence: '分享页不打开在线出处:点下面的「下载报告」,下载的报告里每个数都点得开出处,离线也能看', + close: '知道了', + noKey: '链接不完整:缺少 # 后面的那一段,请向分享的人确认链接', + errorTitle: { + share_revoked: '链接已撤销', share_expired: '链接已到期', share_links_off: '分享链接已关闭', + share_gone: '分享的页面打不开了', share_not_found: '链接无效', share_key_missing: '链接不完整', + } as Record, + failed: '分享的页面打不开', + framed: '这条分享链接没有允许嵌入到别的页面,请直接打开链接查看', + openDirect: '在新窗口打开', +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index c51630c6..a058f038 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -682,6 +682,8 @@ function PrefsTab() { // 应用一组(D-j):按需视图每个应用每天最多发起几次;使用者能不能建个人应用(N7,默认关) appsDaily: string appsPersonal: boolean + // 允许建分享链接(服务端 apps.share_links,04 细案 C2,默认关) + appsShare: boolean } | null>(null) const [spend, setSpend] = useState<{ usd?: number; calls?: number; unpriced_calls?: number } | null>(null) // 核查一组(双盲复算、反方审稿、审批质询)的草稿另放一份,和上面的共用保存条;verifySaves 是存过几次,今日核查花费跟着再取 @@ -735,6 +737,7 @@ function PrefsTab() { // 服务端没给这一项时按默认的 200 显示(同服务端的缺省值) appsDaily: String(values?.apps?.on_demand_daily_limit ?? APPS_DAILY_DEFAULT), appsPersonal: values?.apps?.user_personal_apps === true, + appsShare: values?.apps?.share_links === true, } const cur = draft ?? saved const stepCap = Number(values?.limits?.max_agent_steps) || 100 @@ -834,7 +837,8 @@ function PrefsTab() { const catalogDraft = draftChanged ? { provider: cur.draftProvider || null, model: cur.draftModel.trim() || null } : null // 应用一组同样整组写:别的键(以后加的)照原样带回去 const apps = appsChanged - ? { ...(values.apps ?? {}), on_demand_daily_limit: Number(cur.appsDaily.trim()), user_personal_apps: cur.appsPersonal } + ? { ...(values.apps ?? {}), on_demand_daily_limit: Number(cur.appsDaily.trim()), user_personal_apps: cur.appsPersonal, + share_links: cur.appsShare } : null if (runChanged || judge || verify || catalogDraft || apps) { const run = { @@ -1201,6 +1205,17 @@ function PrefsTab() { + @@ -1228,6 +1243,7 @@ function PrefsTab() { jClick: '每次点击的金额上限', jDaily: '每日金额上限', draftProvider: DRAFT_MODEL_SETTING_TEXT.provider, draftModel: DRAFT_MODEL_SETTING_TEXT.model, appsDaily: APP_SETTING_TEXT.onDemandLimit, appsPersonal: APP_SETTING_TEXT.personalApps, + appsShare: APP_SETTING_TEXT.shareLinks, })[k]).concat(verifyLabels(verifyKeys)).join('、')} @@ -1261,7 +1277,7 @@ function PrefsTab() { } /** 应用一组在草稿里的几项:保存时整组写 apps */ -const APPS_DRAFT = new Set(['appsDaily', 'appsPersonal']) +const APPS_DRAFT = new Set(['appsDaily', 'appsPersonal', 'appsShare']) /** 按需视图每日上限的缺省值和最大值(同服务端 settings.APPS_DEFAULTS、APPS_DAILY_LIMIT_MAX) */ const APPS_DAILY_DEFAULT = 200 diff --git a/frontend/src/pages/apps/SharePage.tsx b/frontend/src/pages/apps/SharePage.tsx new file mode 100644 index 00000000..10ee5a25 --- /dev/null +++ b/frontend/src/pages/apps/SharePage.tsx @@ -0,0 +1,178 @@ +/** + * 分享页(/s#<分享 key>,不在外壳里,同登录页的写法;04 细案 C2):拿到分享链接的人不用登录就能看应用的一页。 + * + * - key 在 # 后面:打开页面时不发给服务器、不进访问日志和 Referer,这里读出来放进请求头(api/shares.ts); + * - 用同一个 ViewRenderer 渲染服务端按分享解析好的视图模型(不另写渲染器):preview 打开,所有操作禁用;入口、表单、实例、 + * 待办、报告正文服务端已经换成写明原因的说明块; + * - 印章常显:页面上没放印章组件的视图,也在「数据的来历」里逐个列出它的印章(截至、口径、出具档位、来源); + * - 出处给「下载报告」:点数字不开在线证据,说明出处在下载的报告里(报告文件自己离线点得开); + * - 页脚写「只读 · 由 X 于 … 分享 · … 到期」;撤销、到期、关了开关、链接不完整,照服务端的话写明,不给空白页; + * - 这条分享没允许嵌入、页面又在别的页面的 iframe 里:不显示内容,给「在新窗口打开」(服务端在 /s 上只能按全站的嵌入白名单 + * 发头,打开页面那一刻它还不知道是哪一条分享)。 + * + * 不认人、不取目录、不连心跳(App 在 /s 上整个跳过外壳的启动),也就不会因为没登录被送去登录页。 + */ +import { useEffect, useMemo, useState } from 'react' +import { Download, ExternalLink, Lock } from 'lucide-react' +import { ApiError } from '../../api/http' +import { shares } from '../../api/shares' +import { ViewRenderer } from '../../apps/ViewRenderer' +import { Stamp } from '../../apps/Stamp' +import { Logo } from '../../components/Logo' +import { Modal, Notice, Spinner, toast } from '../../components/ui' +import { formatDateTime } from '../../lib/format' +import { SHARE_PAGE_TEXT as T } from '../../lib/termsApp' +import type { SharePayload } from '../../typesApp' + +/** 链接里 # 后面的那一段就是分享 key */ +export const shareKeyOf = (hash: string): string => decodeURIComponent(hash.replace(/^#/, '').trim()) + +const framed = (): boolean => { + try { + return window.self !== window.top + } catch { + // 跨源的父页面读 top 会抛:那就是被别的站点嵌着 + return true + } +} + +export function SharePage() { + const key = useMemo(() => shareKeyOf(window.location.hash), []) + const [data, setData] = useState(null) + const [error, setError] = useState<{ code?: string; message: string } | null>(key ? null : { code: 'share_key_missing', message: T.noKey }) + const [note, setNote] = useState(false) + + useEffect(() => { + if (!key) return + shares.page(key).then(setData, (e) => setError({ + code: e instanceof ApiError ? e.code : undefined, + message: e instanceof Error ? e.message : T.failed, + })) + }, [key]) + useEffect(() => { + if (data) document.title = `${data.view.app.name} · ${data.view.page.title || data.view.page.id}` + }, [data]) + + const blocked = !!data && !data.share.embed_allowed && framed() + const state = error ? 'error' : !data ? 'loading' : blocked ? 'framed' : 'ok' + + return ( +
+
+
+ +
+

+ {data ? data.view.app.name : 'AgentLab'} + {data?.view.page.title && · {data.view.page.title}} +

+
+ {data && ( + + {T.readOnly} · {data.share.mode === 'fixed' ? T.fixed : T.latest} + + )} +
+ + {error ? ( +
+

{(error.code && T.errorTitle[error.code]) || T.failed}

+

{error.message}

+
+ ) : !data ? ( +
{T.loading}
+ ) : blocked ? ( +
+

{T.framed}

+ + {T.openDirect} + +
+ ) : ( + <> + setNote(true)} /> + +
+ +

+ {T.footer(data.share.created_by || T.someone, formatDateTime(data.share.created_at), + formatDateTime(data.share.expires_at))} +

+
+ + )} +
+ {data && ( + setNote(false)} title={T.evidenceTitle} width={460} + footer={}> +
+

{T.evidence}

+ +
+
+ )} +
+ ) +} + +/** 印章常显:每个视图来源的印章(取到了运行的),页面上有没有放印章组件都列 */ +function Stamps({ data }: { data: SharePayload }) { + const rows = Object.entries(data.view.sources).filter(([, s]) => s.stamp) + if (!rows.length) return null + return ( +
+
+

{T.stamps}

+

{T.stampsHint}

+
+
+ {rows.map(([vid, s]) => )} +
+
+ ) +} + +/** 下载报告:只给这条分享解析出来、带报告的那几次运行(服务端只认这几次) */ +function Downloads({ shareKey, data }: { shareKey: string; data: SharePayload }) { + const [busy, setBusy] = useState(null) + const items = data.downloads.flatMap((d) => d.reports.map((node) => ({ ...d, node }))) + if (!items.length) return

{T.noReports}

+ const download = async (runId: string, node: string) => { + const id = `${runId}:${node}` + setBusy(id) + try { + const { blob, name } = await shares.report(shareKey, runId, node) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = name ?? `报告-${runId.slice(0, 6)}.html` + document.body.appendChild(a) + a.click() + a.remove() + setTimeout(() => URL.revokeObjectURL(url), 1000) + toast.ok(T.downloaded) + } catch (e) { + toast.error(e) + } finally { + setBusy(null) + } + } + return ( +
+
+ {items.map((d) => { + const id = `${d.run_id}:${d.node}` + return ( + + ) + })} +
+ {T.downloadHint} +
+ ) +} diff --git a/frontend/src/pages/apps/edit/AccessSections.tsx b/frontend/src/pages/apps/edit/AccessSections.tsx index 1427c5f7..bd031221 100644 --- a/frontend/src/pages/apps/edit/AccessSections.tsx +++ b/frontend/src/pages/apps/edit/AccessSections.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useState } from 'react' -import { KeyRound, Plus } from 'lucide-react' +import { KeyRound, Link2, Plus } from 'lucide-react' import { api } from '../../../api/client' -import type { AppHookRow, HookReceipts } from '../../../typesApp' +import type { AppHookRow, HookReceipts, ViewShareCreated, ViewShareList, ViewShareRow } from '../../../typesApp' import { RecentRequests, type RecentRequestRow } from '../../../components/RecentRequests' -import { HOOK_RECEIPT_TEXT, RECENT_REQUESTS_TEXT } from '../../../lib/termsApp' +import { formatDateTime } from '../../../lib/format' +import { HOOK_RECEIPT_TEXT, RECENT_REQUESTS_TEXT, SHARE_TEXT as S } from '../../../lib/termsApp' import { CopyButton, DeleteButton, EmptyState, ErrorState, Field, SectionBar, Skeleton, Spinner, confirmDialog, toast, } from '../../../components/ui' @@ -20,6 +21,8 @@ import { useEditor } from './context' * 岗位:图里人工节点写的岗位名(assignee 的岗位)在这里绑定到人或组;绑定不进 spec,存在草稿的 bindings 里,发布时 * 进应用版本——spec 可以导出,绑定是这一个部署自己的。人员变动只改组成员,不发新版本。 * 授权:use / observe / manage,即时生效、写审计,不需要发新版本(A1)。关登录时所有人都是管理员,授权开登录后才生效 + * 分享链接(04 细案 C2):管理员开了「允许建分享链接」后,管理应用的人给发布版的一页建只读、不用登录的链接(必设到期、 + * 最长 90 天、可撤销);链接只在建好时显示一次。关着时不给新建,已有的照列、能撤销 */ const ROLES: GrantRole[] = ['use', 'observe', 'manage'] @@ -73,6 +76,169 @@ export function AccessSection() { + + + ) +} + +/** 有效期的几个选项(天),按服务端的上限截 */ +const SHARE_DAYS = [1, 7, 30, 90] + +function Shares() { + const { appId, detail } = useEditor() + const personal = detail.scope === 'personal' + const pages = detail.published?.pages ?? [] + const [list, setList] = useState(null) + const [error, setError] = useState(null) + const [form, setForm] = useState<{ page: string; mode: 'latest' | 'fixed'; days: number; embed: boolean } | null>(null) + const [busy, setBusy] = useState(false) + const [made, setMade] = useState(null) + const load = useCallback(() => { + if (personal) return + api.apps.shares(appId).then((v) => { setList(v); setError(null) }, setError) + }, [appId, personal]) + useEffect(load, [load]) + + if (personal) { + return ( +
+ +
+ ) + } + const create = async () => { + if (!form || busy) return + setBusy(true) + try { + const out = await api.apps.createShare(appId, { + page_id: form.page, mode: form.mode, embed_allowed: form.embed, + expires_at: new Date(Date.now() + form.days * 86_400_000).toISOString(), + }) + setMade(out) + setForm(null) + load() + } catch (e) { + toast.error(e) + } finally { + setBusy(false) + } + } + const revoke = async (row: ViewShareRow) => { + if (!(await confirmDialog({ title: S.revokeTitle(row.prefix), body: S.revokeBody, danger: true, confirmLabel: S.revoke }))) return + try { + await api.apps.revokeShare(appId, row.id) + toast.ok(S.revoked) + load() + } catch (e) { + toast.error(e) + } + } + const maxDays = list?.max_days ?? 90 + const canCreate = !!list?.enabled && pages.length > 0 && detail.published_version != null + const hint = !list ? S.hint : !list.enabled ? (list.items.length ? S.offWithRows : S.off) + : detail.published_version == null ? S.unpublished : S.hint + return ( +
+ + {canCreate && !form && ( + + )} + + {made && setMade(null)} />} + {form && ( +
+ + {(p) => ( + + )} + +
+ {S.mode} + {(['latest', 'fixed'] as const).map((m) => ( + + ))} +
+ + {(p) => ( + + )} + + +
+ + +
+
+ )} + {error ? : !list ? : !list.items.length ? ( +

{S.empty}

+ ) : ( +
+ {list.items.map((row) => ( +
+ {row.prefix}… + {row.page_title} + {S.modeShort[row.mode] ?? row.mode} + + {S.state[row.state] ?? row.state} + + {row.embed_allowed && {S.embedded}} + + {[row.expires_at ? S.until(formatDateTime(row.expires_at)) : null, S.opened(row.open_count), + row.created_by_name || row.created_by ? S.by(row.created_by_name || row.created_by || '') : null] + .filter(Boolean).join(' · ')} + + {row.state === 'active' && ( + + )} +
+ ))} +
+ )} +
+ ) +} + +/** 刚建好的分享链接:只显示这一次(库里只有哈希),附复制按钮;关掉就没有了 */ +function ShareOnce({ made, onDismiss }: { made: ViewShareCreated; onDismiss: () => void }) { + const link = made.url ?? `${window.location.origin}${made.path}` + return ( +
+
{S.created}
+

{S.once}

+
+ {link} + +
+
+ +
) } diff --git a/frontend/src/pages/settings/NotifyTab.tsx b/frontend/src/pages/settings/NotifyTab.tsx index 297c6955..06ea318a 100644 --- a/frontend/src/pages/settings/NotifyTab.tsx +++ b/frontend/src/pages/settings/NotifyTab.tsx @@ -1,37 +1,55 @@ import { useCallback, useEffect, useState } from 'react' -import { Bell, Plus, Send } from 'lucide-react' +import { Bell, Check, Mail, Plus, Send } from 'lucide-react' import { api } from '../../api/client' import { DeleteButton, EmptyState, ErrorState, Field, Modal, SectionBar, Skeleton, Spinner, confirmDialog, toast, } from '../../components/ui' import { formatDateTime } from '../../lib/format' +import { useLeaveGuard } from '../../lib/leave' import { NOTIFY_TEXT as T, SETTINGS_ROLE_TEXT } from '../../lib/terms' -import { NOTIFY_PERSONAL_EVENT_LABEL } from '../../lib/termsApp' +import { MAIL_TEXT as M, NOTIFY_PERSONAL_EVENT_LABEL } from '../../lib/termsApp' import { useAtLeast } from '../../store/auth' -import type { NotifyChannelRow, NotifyEventRow } from '../../typesApp' +import type { NotifyChannelRow, NotifyEventRow, NotifyMailState } from '../../typesApp' /* - * 设置 → 通知(A4 第 7 条):待办、动作的事件推送到哪些地址。格式只有 Webhook 的四种(Q6),发送核心和定时任务的 - * 通知共用;消息里只放标题、状态和链接,不放报告里的数字。地址和签名密钥加密保存,之后只显示主机名。 + * 设置 → 通知(A4 第 7 条):待办、动作的事件推送到哪些地址。格式是 Webhook 的四种加上邮件(04 细案 B2,改写了 Q6): + * Webhook 的发送核心和定时任务的通知共用;邮件走管理员在「邮件服务」一节配的 SMTP。消息里只放标题、状态和链接,不放报告 + * 里的数字。地址和签名密钥加密保存,之后只显示主机名。 * 全局渠道只给管理员建;个人渠道每个人都能建(§3.12)。开了登录的使用者只看、只建自己的个人渠道(scope=user), - * 投递记录只给搭建者以上 + * 投递记录只给搭建者以上。 + * + * 邮件:邮件服务没启用时不列「邮件」这一格式(建了也发不出去);个人邮件渠道不填地址,只发到本人的账号邮箱(列表里写 + * 「发到账号邮箱 …」,没有邮箱照实写发不出去);全局、应用的填固定收件地址。投递成功写「已交给邮件服务器」,不写已送达: + * 退信不追踪。「邮件服务」「每天的待办汇总」两节给搭建者以上看(设置接口搭建者只读),只有管理员能改。 */ -const FORMATS = ['generic', 'feishu', 'wecom', 'dingtalk'] as const +const WEBHOOK_FORMATS = ['generic', 'feishu', 'wecom', 'dingtalk'] as const const EVENTS = Object.keys(T.eventLabel) /** 事件在这个范围的渠道上怎么叫:个人渠道的实例结束只推自己发起的,叫「我发起的实例完成 / 失败」 */ const eventLabel = (event: string, scope: string): string => (scope === 'user' ? NOTIFY_PERSONAL_EVENT_LABEL[event] : undefined) ?? T.eventLabel[event] ?? event +/** 一个渠道发到哪:Webhook 只有主机名;邮件写收件人(个人渠道是账号邮箱) */ +function targetOf(row: NotifyChannelRow): string { + if (row.format !== 'email') return row.url_host + if (row.scope === 'user') return M.personalTo(row.mail_to?.[0] ?? null) + return M.to(row.mail_to ?? []) +} + export function NotifyTab() { const builder = useAtLeast('builder') const [rows, setRows] = useState(null) + const [mail, setMail] = useState({ enabled: false, account_email: null }) const [error, setError] = useState(null) const [editing, setEditing] = useState(null) const [testing, setTesting] = useState(null) const load = useCallback(() => { api.notify.channels() - .then(({ items }) => { setRows(builder ? items : items.filter((c) => c.scope === 'user')); setError(null) }) + .then(({ items, mail: state }) => { + setRows(builder ? items : items.filter((c) => c.scope === 'user')) + setMail(state ?? { enabled: false, account_email: null }) + setError(null) + }) .catch(setError) }, [builder]) useEffect(load, [load]) @@ -40,7 +58,8 @@ export function NotifyTab() { setTesting(row.id) try { const res = await api.notify.testChannel(row.id) - if (res.ok) toast.ok(T.testOk) + // 邮件渠道成功时服务端说的是「已交给邮件服务器……」:照原话写,不写「已发送」 + if (res.ok) toast.ok(row.format === 'email' ? res.message : T.testOk) else toast.warn(res.message) } catch (e) { toast.error(e) @@ -79,7 +98,8 @@ export function NotifyTab() { ) : (
{rows.map((row) => ( -
+
{row.name} @@ -89,7 +109,10 @@ export function NotifyTab() { {!row.enabled && 已停用}
- {row.url_host} + + {targetOf(row)} + {row.events.length > 0 && ` · ${row.events.map((e) => eventLabel(e, row.scope)).join('、')}`}
@@ -108,7 +131,9 @@ export function NotifyTab() { )} {builder && } - {editing && setEditing(null)} + {builder && } + {builder && } + {editing && setEditing(null)} onSaved={(name) => { setEditing(null); toast.ok(T.saved(name)); load() }} />}
) @@ -123,11 +148,13 @@ function Deliveries() { {!rows ? : !rows.length ?

{T.deliveriesEmpty}

: (
{rows.map((d) => ( -
+
{T.deliveryEventLabel[d.event] ?? T.eventLabel[d.event] ?? d.event} {d.error ?? (d.http_status ? `HTTP ${d.http_status}` : '')} - - {d.status === 'ok' ? '已送达' : d.status === 'failed' ? '失败' : d.status} + {/* 邮件只知道交给了邮件服务器,进没进收件箱不知道(退信不追踪):不写「已送达」 */} + + {d.status === 'ok' ? (d.format === 'email' ? M.handoff : '已送达') : d.status === 'failed' ? '失败' : d.status}
@@ -138,14 +165,16 @@ function Deliveries() { ) } -function ChannelEditor({ row, onClose, onSaved }: { - row: NotifyChannelRow | null; onClose: () => void; onSaved: (name: string) => void +function ChannelEditor({ row, mail, onClose, onSaved }: { + row: NotifyChannelRow | null; mail: NotifyMailState; onClose: () => void; onSaved: (name: string) => void }) { const admin = useAtLeast('admin') const builder = useAtLeast('builder') const [form, setForm] = useState({ name: row?.name ?? '', scope: row?.scope ?? (admin ? 'global' : 'user'), format: row?.format ?? 'generic', url: '', secret: '', events: row?.events ?? ['task.opened', 'action.unknown'], + // 全局、应用的邮件渠道:收件地址整份重填(服务端照原样给,编辑时带出来) + recipients: row?.format === 'email' && row.scope !== 'user' ? (row.mail_to ?? []).join('\n') : '', }) const [busy, setBusy] = useState(false) // 列哪些事件由服务端说(notify/service.EVENTS 的 scopes):这种范围的渠道收得到的才列,登记了还没推送的不列(勾了收不到)。 @@ -155,18 +184,28 @@ function ChannelEditor({ row, onClose, onSaved }: { useEffect(() => { api.notify.events().then(setRegistry).catch(() => setRegistryFailed(true)) }, []) const offered = EVENTS.filter((ev) => form.events.includes(ev) || row?.events.includes(ev) || registry?.some((r) => r.event === ev && r.scopes.includes(form.scope))) + const email = form.format === 'email' + // 邮件服务没启用:不列「邮件」(建了也发不出去);原来就是邮件渠道的照列,能改名、改订阅、换回 Webhook + const formats = [...WEBHOOK_FORMATS, ...(mail.enabled || row?.format === 'email' ? ['email' as const] : [])] + const personalMail = email && form.scope === 'user' + const recipientsChanged = form.recipients.trim() !== (row?.format === 'email' ? (row.mail_to ?? []).join('\n') : '') + const missingTarget = email ? !personalMail && !form.recipients.trim() + : !row || row.format === 'email' ? !form.url.trim() : false const save = async () => { setBusy(true) try { + const target = email + ? (personalMail ? {} : recipientsChanged || row?.format !== 'email' ? { url: form.recipients.trim() } : {}) + : form.url.trim() ? { url: form.url.trim() } : {} if (row) { await api.notify.updateChannel(row.id, { name: form.name.trim(), scope: form.scope, format: form.format, events: form.events, // 地址、密钥只回主机名:不填就是保持原值 - ...(form.url.trim() ? { url: form.url.trim() } : {}), ...(form.secret ? { secret: form.secret } : {}), + ...target, ...(!email && form.secret ? { secret: form.secret } : {}), }) } else { - await api.notify.createChannel({ name: form.name.trim(), scope: form.scope, format: form.format, url: form.url.trim(), - secret: form.secret || null, events: form.events }) + await api.notify.createChannel({ name: form.name.trim(), scope: form.scope, format: form.format, ...target, + secret: !email && form.secret ? form.secret : null, events: form.events }) } onSaved(form.name.trim()) } catch (e) { @@ -179,7 +218,7 @@ function ChannelEditor({ row, onClose, onSaved }: { - @@ -191,9 +230,9 @@ function ChannelEditor({ row, onClose, onSaved }: { {(p) => ( - )} @@ -207,14 +246,32 @@ function ChannelEditor({ row, onClose, onSaved }: { )}
- - {(p) => setForm({ ...form, url: e.target.value })} />} - - - {(p) => setForm({ ...form, secret: e.target.value })} />} - + {!mail.enabled && !email &&

{M.off}

} + {personalMail ? ( +
+
+ {M.personalTo(mail.account_email)} +
+

{M.personalToHint}

+
+ ) : email ? ( + + {(p) =>