mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
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:
parent
0b078d938b
commit
b600f02027
34 changed files with 1711 additions and 183 deletions
|
|
@ -1282,6 +1282,62 @@ main {
|
|||
color: var(--faint);
|
||||
}
|
||||
|
||||
.webhook-panel .wh-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.webhook-panel .wh-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
min-width: 52px;
|
||||
}
|
||||
|
||||
.webhook-panel .wh-value {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
padding: 6px 10px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12.5px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.webhook-panel .wh-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.webhook-panel .wh-usage {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.webhook-panel .wh-usage summary {
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.webhook-panel .wh-code {
|
||||
margin: 10px 0 0;
|
||||
padding: 12px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.filter-row {
|
||||
grid-template-columns: 100px 1fr auto;
|
||||
|
|
|
|||
|
|
@ -84,6 +84,12 @@
|
|||
:saving="savingGroup"
|
||||
@save="onSaveGroupFromPanel"
|
||||
/>
|
||||
|
||||
<WebhookPanel
|
||||
v-if="canEditGroup(selectedGroup.id)"
|
||||
:group-id="selectedGroup.id"
|
||||
:can-edit="canEditGroup(selectedGroup.id)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Top-level views -->
|
||||
|
|
@ -227,6 +233,7 @@
|
|||
<script setup lang="ts">
|
||||
import type { Group, Route } from "~/types";
|
||||
import { useAuditApi } from "~/composables/useAudit";
|
||||
import WebhookPanel from "~/components/WebhookPanel.vue";
|
||||
|
||||
const { t, toggle } = useI18n();
|
||||
const { push } = useToasts();
|
||||
|
|
|
|||
|
|
@ -106,6 +106,19 @@
|
|||
</div>
|
||||
<div class="hint">{{ t("groupEditor.providersHint") }}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
>{{ t("groupEditor.installationId") }}
|
||||
<span class="lbl-note">{{ t("groupEditor.installationIdNote") }}</span></label
|
||||
>
|
||||
<input
|
||||
v-model="form.installationId"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
:placeholder="t('groupEditor.installationIdPlaceholder')"
|
||||
/>
|
||||
<div class="hint">{{ t("groupEditor.installationIdHint") }}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label
|
||||
>{{ t("groupEditor.logTarget") }}
|
||||
|
|
@ -183,6 +196,7 @@ const form = reactive({
|
|||
name: "",
|
||||
owners: "",
|
||||
providers: [] as ("github" | "gitea")[],
|
||||
installationId: "",
|
||||
emoji: true,
|
||||
lang: "",
|
||||
logPlatform: "" as "" | "discord" | "telegram",
|
||||
|
|
@ -218,6 +232,7 @@ watch(
|
|||
form.providers = (g?.providers ?? []).filter(
|
||||
(p): p is "github" | "gitea" => p === "github" || p === "gitea",
|
||||
);
|
||||
form.installationId = g?.installationId != null ? String(g.installationId) : "";
|
||||
form.emoji = g?.emoji ?? true;
|
||||
form.lang = g?.lang ?? "";
|
||||
form.logPlatform = lt?.platform ?? "";
|
||||
|
|
@ -268,6 +283,15 @@ function save(): void {
|
|||
}
|
||||
logTarget = { platform: "telegram", chatId, topicId: form.logTopicId.trim() || undefined };
|
||||
}
|
||||
const installationText = form.installationId.trim();
|
||||
let installationId: number | undefined;
|
||||
if (installationText) {
|
||||
installationId = Number(installationText);
|
||||
if (!Number.isInteger(installationId) || installationId <= 0) {
|
||||
formError.value = t("groupEditor.errInstallationId");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const owners = splitList(form.owners);
|
||||
const members = props.group?.members
|
||||
? props.group.members.map((m) => ({ ...m }))
|
||||
|
|
@ -281,6 +305,7 @@ function save(): void {
|
|||
adminIds: members.filter((m) => m.role === "owner").map((m) => m.login),
|
||||
owners: props.superAdmin ? (owners.length ? owners : undefined) : props.group?.owners,
|
||||
providers: form.providers.length ? form.providers : undefined,
|
||||
installationId,
|
||||
emoji: form.emoji,
|
||||
lang: form.lang.trim() || undefined,
|
||||
logTarget,
|
||||
|
|
|
|||
154
admin/components/WebhookPanel.vue
Normal file
154
admin/components/WebhookPanel.vue
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
<template>
|
||||
<section class="members-panel webhook-panel">
|
||||
<div class="panel-head">
|
||||
<h3>{{ t("webhook.title") }}</h3>
|
||||
<span class="lbl-note">{{ t("webhook.note") }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="!info" class="empty-log">{{ t("webhook.empty") }}</p>
|
||||
|
||||
<template v-else>
|
||||
<div class="wh-row">
|
||||
<span class="wh-label">{{ t("webhook.url") }}</span>
|
||||
<code class="wh-value">{{ info.url }}</code>
|
||||
<button class="btn btn-ghost btn-sm" @click="copy(info.url, 'url')">
|
||||
{{ copiedUrl ? t("webhook.copied") : t("webhook.copy") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="wh-row">
|
||||
<span class="wh-label">{{ t("webhook.secret") }}</span>
|
||||
<code class="wh-value">{{
|
||||
info.secret ? info.secret : info.hasSecret ? maskedSecret : t("webhook.noSecret")
|
||||
}}</code>
|
||||
<button
|
||||
v-if="info.secret"
|
||||
class="btn btn-ghost btn-sm"
|
||||
@click="copy(info.secret!, 'secret')"
|
||||
>
|
||||
{{ copiedSecret ? t("webhook.copied") : t("webhook.copy") }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="info.hasSecret && !info.secret" class="hint">{{ t("webhook.secretHidden") }}</p>
|
||||
|
||||
<div class="wh-actions">
|
||||
<button class="btn btn-accent btn-sm" :disabled="busy" @click="onRegenerate">
|
||||
{{ info.hasSecret ? t("webhook.regenerate") : t("webhook.generate") }}
|
||||
</button>
|
||||
<button
|
||||
v-if="info.hasSecret"
|
||||
class="btn btn-ghost btn-sm"
|
||||
:disabled="busy"
|
||||
@click="onDisable"
|
||||
>
|
||||
{{ t("webhook.disable") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<details class="wh-usage">
|
||||
<summary>{{ t("webhook.usageTitle") }}</summary>
|
||||
<p class="hint">{{ t("webhook.usageGitHub") }}</p>
|
||||
<p class="hint">{{ t("webhook.usageGitea") }}</p>
|
||||
<p class="hint">{{ t("webhook.usageCustom") }}</p>
|
||||
<pre class="wh-code">{{ customExample }}</pre>
|
||||
</details>
|
||||
</template>
|
||||
|
||||
<div class="err">{{ error }}</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { useWebhookApi, type GroupWebhookInfo } from "~/composables/useWebhook";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{ groupId: string; canEdit: boolean }>();
|
||||
|
||||
const api = useWebhookApi();
|
||||
const info = ref<GroupWebhookInfo | null>(null);
|
||||
const copiedUrl = ref(false);
|
||||
const copiedSecret = ref(false);
|
||||
const busy = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
const maskedSecret = "••••••••••••••••";
|
||||
const customExample = [
|
||||
'curl -X POST "$URL" \\',
|
||||
' -H "Content-Type: application/json" \\',
|
||||
' -H "X-WebHooker-Signature: sha256=$(hmac-sha256 "$BODY" "$SECRET")" \\',
|
||||
" -d '{",
|
||||
' "title": "Deploy failed",',
|
||||
' "description": "Prod rollout failed at 12:03 UTC",',
|
||||
' "color": "red",',
|
||||
' "repo": "acme/widget",',
|
||||
' "url": "https://ci.example.com/runs/42",',
|
||||
' "fields": [{ "name": "Env", "value": "prod", "inline": true }]',
|
||||
" }'",
|
||||
].join("\n");
|
||||
|
||||
watch(
|
||||
() => props.groupId,
|
||||
() => {
|
||||
if (!props.canEdit) return;
|
||||
load();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
error.value = "";
|
||||
try {
|
||||
info.value = await api.info(props.groupId);
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
info.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onRegenerate(): Promise<void> {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
info.value = await api.regenerate(props.groupId);
|
||||
copiedSecret.value = false;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onDisable(): Promise<void> {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await api.disable(props.groupId);
|
||||
info.value = { url: info.value?.url ?? "", hasSecret: false };
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copy(text: string, which: "url" | "secret"): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (which === "url") {
|
||||
copiedUrl.value = true;
|
||||
window.setTimeout(() => {
|
||||
copiedUrl.value = false;
|
||||
}, 1500);
|
||||
} else {
|
||||
copiedSecret.value = true;
|
||||
window.setTimeout(() => {
|
||||
copiedSecret.value = false;
|
||||
}, 1500);
|
||||
}
|
||||
} catch {
|
||||
// ignore clipboard failures
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -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 填上面的 URL,Content type 选 application/json,Secret 填上面的 secret。",
|
||||
"webhook.usageGitea": "Gitea:仓库 Settings → Webhooks → Add Webhook,填写同样的 URL 和 secret。",
|
||||
"webhook.usageCustom":
|
||||
"自定义:用 X-WebHooker-Signature(sha256 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":
|
||||
"只有来自所勾选 forge(GitHub / 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": "(可选)",
|
||||
|
|
|
|||
50
admin/composables/useWebhook.ts
Normal file
50
admin/composables/useWebhook.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ export interface Group {
|
|||
members?: GroupMember[];
|
||||
owners?: string[];
|
||||
providers?: ("github" | "gitea" | "gitlab")[];
|
||||
installationId?: number;
|
||||
emoji?: boolean;
|
||||
lang?: string;
|
||||
logTarget?: RouteTarget;
|
||||
|
|
@ -146,6 +147,11 @@ export const ROUTE_TEMPLATES: RouteTemplate[] = [
|
|||
nameKey: "templates.commitComment",
|
||||
filters: [{ type: "event", match: "commit_comment" }],
|
||||
},
|
||||
{
|
||||
id: "custom-webhook",
|
||||
nameKey: "templates.customWebhook",
|
||||
filters: [{ type: "event", match: "custom" }],
|
||||
},
|
||||
];
|
||||
|
||||
export const FILTER_TYPES = ["event", "repo", "actor", "action", "branch", "keyword"] as const;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue