feat: per-group webhook ingress, custom webhooks, GitHub App tenant isolation

Add POST /webhook/{groupId} with per-group secrets (KV tenant:{groupId}), a custom provider (X-WebHooker-Signature HMAC, arbitrary JSON -> custom events through the route pipeline), and GitHub App installation isolation (Group.installationId) with automatic provisioning on installation.created (inst-{id} groups or binding matching owners groups). Includes WebhookPanel admin UI, custom route template, docs and 157 passing tests.
This commit is contained in:
RhenCloud 2026-08-13 09:24:50 +08:00
parent 0b078d938b
commit b600f02027
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
34 changed files with 1711 additions and 183 deletions

View file

@ -50,6 +50,25 @@ const en: Dict = {
"members.errLastOwner": "Cannot remove the last owner.",
"members.inviteOk": "Invite accepted — welcome!",
"members.inviteBad": "This invite is invalid or expired.",
"webhook.title": "Webhook endpoint",
"webhook.note": "(per-group URL + secret)",
"webhook.empty": "Not configured. Generate a secret to enable the group's own webhook endpoint.",
"webhook.url": "URL",
"webhook.secret": "Secret",
"webhook.noSecret": "— no secret configured —",
"webhook.secretHidden":
"The secret is only shown once after generating/regenerating. Regenerate to reveal a new one.",
"webhook.copy": "Copy",
"webhook.copied": "Copied!",
"webhook.generate": "Generate secret",
"webhook.regenerate": "Regenerate",
"webhook.disable": "Disable",
"webhook.usageTitle": "How to use",
"webhook.usageGitHub":
"GitHub: repo/org Settings → Webhooks → Add webhook → payload URL = the URL above, Content type = application/json, Secret = the secret above.",
"webhook.usageGitea": "Gitea: repo Settings → Webhooks → Add Webhook → same URL + secret.",
"webhook.usageCustom":
"Custom: POST any JSON signed with X-WebHooker-Signature (sha256 HMAC). Create a route with event = custom to receive it:",
"groups.empty": "No groups yet. Groups scope routes by org/user and delegate access.",
"groups.createFirst": "Create your first group",
"groups.admins": "ADMINS",
@ -95,6 +114,7 @@ const en: Dict = {
"templates.createDelete": "Create / Delete",
"templates.member": "Member",
"templates.commitComment": "Commit Comment",
"templates.customWebhook": "Custom Webhook",
"routeEditor.name": "Name",
"routeEditor.namePlaceholder": "My Route",
"routeEditor.id": "ID",
@ -167,6 +187,12 @@ const en: Dict = {
"groupEditor.providersNote": "(leave both unchecked = all)",
"groupEditor.providersHint":
"Only webhook events from the checked forges (GitHub / Gitea) enter this group's routes.",
"groupEditor.installationId": "GitHub App installation ID",
"groupEditor.installationIdNote": "(optional)",
"groupEditor.installationIdPlaceholder": "e.g. 12345678",
"groupEditor.installationIdHint":
"Bind this group to one GitHub App installation (the org/user that installed the app). Only events from that installation enter this group's routes; leave empty to allow any installation (still filtered by Owner scope).",
"groupEditor.errInstallationId": "Installation ID must be a positive integer",
"groupEditor.emoji": "Show emojis in messages",
"groupEditor.logTarget": "Webhook log channel",
"groupEditor.logTargetNote": "(optional)",
@ -276,6 +302,24 @@ const zh: Dict = {
"members.errLastOwner": "不能移除最后一位 owner。",
"members.inviteOk": "邀请已接受 —— 欢迎!",
"members.inviteBad": "该邀请无效或已过期。",
"webhook.title": "Webhook 入口",
"webhook.note": "(分组独立的 URL + secret",
"webhook.empty": "未配置。生成 secret 即可启用该分组的独立 webhook 入口。",
"webhook.url": "URL",
"webhook.secret": "Secret",
"webhook.noSecret": "—— 未配置 secret ——",
"webhook.secretHidden": "secret 仅在生成/重新生成时显示一次。如需查看请重新生成。",
"webhook.copy": "复制",
"webhook.copied": "已复制!",
"webhook.generate": "生成 secret",
"webhook.regenerate": "重新生成",
"webhook.disable": "停用",
"webhook.usageTitle": "使用方法",
"webhook.usageGitHub":
"GitHub仓库/组织 Settings → Webhooks → Add webhook → Payload URL 填上面的 URLContent type 选 application/jsonSecret 填上面的 secret。",
"webhook.usageGitea": "Gitea仓库 Settings → Webhooks → Add Webhook填写同样的 URL 和 secret。",
"webhook.usageCustom":
"自定义:用 X-WebHooker-Signaturesha256 HMAC签名任意 JSON 后 POST。创建一条 event = custom 的路由即可接收:",
"groups.admins": "管理员",
"groups.owners": "来源",
"groups.members": "成员",
@ -319,6 +363,7 @@ const zh: Dict = {
"templates.createDelete": "创建 / 删除",
"templates.member": "协作者",
"templates.commitComment": "提交评论",
"templates.customWebhook": "自定义 Webhook",
"routeEditor.name": "名称",
"routeEditor.namePlaceholder": "我的路由",
"routeEditor.id": "ID",
@ -390,6 +435,12 @@ const zh: Dict = {
"groupEditor.providersNote": "(都不勾选 = 全部来源)",
"groupEditor.providersHint":
"只有来自所勾选 forgeGitHub / Gitea的 webhook 事件才会进入本分组的路由。",
"groupEditor.installationId": "GitHub App 安装 ID",
"groupEditor.installationIdNote": "(可选)",
"groupEditor.installationIdPlaceholder": "如 12345678",
"groupEditor.installationIdHint":
"将本分组绑定到一个 GitHub App 安装(安装了 App 的组织/用户)。只有来自该安装的事件才会进入本分组的路由;留空表示接受任意安装(仍受来源限定过滤)。",
"groupEditor.errInstallationId": "安装 ID 必须为正整数",
"groupEditor.emoji": "消息中显示表情符号",
"groupEditor.logTarget": "Webhook 日志频道",
"groupEditor.logTargetNote": "(可选)",

View file

@ -0,0 +1,50 @@
export interface GroupWebhookInfo {
url: string;
hasSecret: boolean;
secret?: string;
}
export function useWebhookApi() {
const loading = ref(false);
const error = ref("");
async function request<T>(path: string, init?: RequestInit): Promise<T> {
loading.value = true;
error.value = "";
try {
const res = await fetch(path, {
headers: { accept: "application/json" },
credentials: "same-origin",
...init,
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error ?? `HTTP ${res.status}`);
}
return (await res.json()) as T;
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
throw err;
} finally {
loading.value = false;
}
}
function info(groupId: string): Promise<GroupWebhookInfo> {
return request(`/admin/api/groups/${encodeURIComponent(groupId)}/webhook`);
}
function regenerate(groupId: string): Promise<GroupWebhookInfo> {
return request(`/admin/api/groups/${encodeURIComponent(groupId)}/webhook/regenerate`, {
method: "POST",
});
}
function disable(groupId: string): Promise<{ ok: boolean }> {
return request(`/admin/api/groups/${encodeURIComponent(groupId)}/webhook`, {
method: "DELETE",
});
}
return { loading, error, info, regenerate, disable };
}