mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat(groups): host-based forge sources with optional display name
- forgeSources entries are now { host, type, name? }: the repository URL's
hostname is matched case-insensitively against host (github.com for GitHub,
distinct hosts for multiple Gitea instances); the footer label is the
optional name, falling back to the host
- GroupEditor renders one row per source: host input + type select + optional
display name (grid layout); hostname validation mirrors the server
- fix: apiFetch sends Content-Type: application/json — h3's readBody only
parses JSON bodies with that header, so every PUT/POST from the refactored
console arrived as a raw string and failed with 'groups must be an array'
- hardening: readJsonBody (admin + actions) JSON-parses string bodies so curl
and older clients without the content-type header still work
- regression test: groups PUT without content-type + forgeSources round-trip
- docs: groups.md/message-format.md (en/zh), AGENTS.md, config.example.yaml
This commit is contained in:
parent
17d10db845
commit
3f6f7f17b5
19 changed files with 469 additions and 108 deletions
|
|
@ -23,7 +23,7 @@ Core pipeline: GitHub Webhook → Worker (verify + filter + format) → Discord
|
||||||
- Invites: single-use 7-day links (`invite:{token}`) for joining a group as admin/viewer; `ALLOW_SELF_SIGNUP=1` gives access-less users a personal group on first login (self-service SaaS entry)
|
- Invites: single-use 7-day links (`invite:{token}`) for joining a group as admin/viewer; `ALLOW_SELF_SIGNUP=1` gives access-less users a personal group on first login (self-service SaaS entry)
|
||||||
- Audit log: every admin operation (logins, group/route/member/invite changes) recorded in D1 `audit_logs`; pruned by the scheduled trigger after `AUDIT_RETENTION_DAYS` (default 90)
|
- Audit log: every admin operation (logins, group/route/member/invite changes) recorded in D1 `audit_logs`; pruned by the scheduled trigger after `AUDIT_RETENTION_DAYS` (default 90)
|
||||||
- Group webhook log channel: optional `Group.logTarget` (Discord channel/thread or Telegram chat/topic) receives one summary message per webhook the group's routes dispatched (event, repo, delivery id, per route×target ✅/❌ outcome, green/red color); best-effort, not recorded in `send_logs`
|
- Group webhook log channel: optional `Group.logTarget` (Discord channel/thread or Telegram chat/topic) receives one summary message per webhook the group's routes dispatched (event, repo, delivery id, per route×target ✅/❌ outcome, green/red color); best-effort, not recorded in `send_logs`
|
||||||
- Per-group forge branding: optional `Group.forgeLabel` (default off) renders the source forge in the message footer — GitHub brand + favicon, Gitea instance hostname + favicon (from the repo URL origin), or plain `Custom` for custom webhooks; Discord shows the footer icon + name, Telegram a linked name
|
- Per-group forge branding: optional `Group.forgeSources` (a list of `{ host, type: "github" | "gitea", name? }` the group defines itself) labels each message's footer with the first entry whose type matches the event's provider and whose host matches the repository URL's hostname (GitHub matches `github.com`, so two Gitea instances can be `git1.example.com`/`git2.example.com`); the label is the entry's optional `name` (fallback: host); links/favicons are derived from the repo URL; Discord shows the footer icon + name, Telegram a linked name
|
||||||
- Local dev: wrangler + Miniflare
|
- Local dev: wrangler + Miniflare
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
@ -125,7 +125,7 @@ tests/ # bun test unit tests (webhook, formatter, discord, tel
|
||||||
- Record every admin operation (login/logout, group/route/member/invite changes) to D1 `audit_logs`; the scheduled trigger prunes entries past `AUDIT_RETENTION_DAYS`
|
- Record every admin operation (login/logout, group/route/member/invite changes) to D1 `audit_logs`; the scheduled trigger prunes entries past `AUDIT_RETENTION_DAYS`
|
||||||
- Mention Discord roles on route trigger: route-level `discordRoleIds` are rendered as `<@&id>` into the Discord message `content` (Telegram targets ignore the field)
|
- Mention Discord roles on route trigger: route-level `discordRoleIds` are rendered as `<@&id>` into the Discord message `content` (Telegram targets ignore the field)
|
||||||
- Format 28 GitHub/Gitea event types plus `custom` webhooks as platform-neutral messages (Discord embeds + Telegram HTML)
|
- Format 28 GitHub/Gitea event types plus `custom` webhooks as platform-neutral messages (Discord embeds + Telegram HTML)
|
||||||
- Show the forge source (GitHub / Gitea instance / custom) in the message footer when the group enables `Group.forgeLabel`
|
- Show the forge source (named per `Group.forgeSources` host entries) in the message footer when the group defines a host matching the event's repository
|
||||||
- Route messages to Discord channels/threads and Telegram chats/topics via REST
|
- Route messages to Discord channels/threads and Telegram chats/topics via REST
|
||||||
- Edit already-sent messages in place for `workflow_run` / `check_run` progress (stable `updateKey`, KV `msg:*` tracking)
|
- Edit already-sent messages in place for `workflow_run` / `check_run` progress (stable `updateKey`, KV `msg:*` tracking)
|
||||||
- Record every dispatch attempt to D1 `send_logs` (route id, event, target, ok/error, duration, error code)
|
- Record every dispatch attempt to D1 `send_logs` (route id, event, target, ok/error, duration, error code)
|
||||||
|
|
|
||||||
|
|
@ -478,6 +478,32 @@
|
||||||
@apply mt-1 w-full justify-center;
|
@apply mt-1 w-full justify-center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.forge-source-row {
|
||||||
|
@apply mb-2 grid items-center gap-2 rounded-sm border border-border bg-surface-2 p-2.5;
|
||||||
|
grid-template-areas:
|
||||||
|
"host type del"
|
||||||
|
"name name name";
|
||||||
|
grid-template-columns: 1fr auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forge-source-row .f-host {
|
||||||
|
grid-area: host;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forge-source-row .select.forge-type {
|
||||||
|
grid-area: type;
|
||||||
|
@apply w-[110px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.forge-source-row .f-name {
|
||||||
|
grid-area: name;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forge-source-row > .icon-btn {
|
||||||
|
grid-area: del;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Toasts ---- */
|
/* ---- Toasts ---- */
|
||||||
.toasts {
|
.toasts {
|
||||||
@apply pointer-events-none fixed bottom-7 left-1/2 z-[60] flex -translate-x-1/2 flex-col items-center gap-2;
|
@apply pointer-events-none fixed bottom-7 left-1/2 z-[60] flex -translate-x-1/2 flex-col items-center gap-2;
|
||||||
|
|
|
||||||
|
|
@ -46,27 +46,47 @@
|
||||||
/>
|
/>
|
||||||
<div class="hint">{{ t("groupEditor.langHint") }}</div>
|
<div class="hint">{{ t("groupEditor.langHint") }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label
|
<label
|
||||||
>{{ t("groupEditor.emoji") }}
|
>{{ t("groupEditor.emoji") }}
|
||||||
<span class="lbl-note">{{ t("groupEditor.emojiNote") }}</span></label
|
<span class="lbl-note">{{ t("groupEditor.emojiNote") }}</span></label
|
||||||
>
|
>
|
||||||
<label class="inline">
|
<label class="inline">
|
||||||
<input v-model="form.emoji" type="checkbox" />
|
<input v-model="form.emoji" type="checkbox" />
|
||||||
<span>{{ t("groupEditor.emojiLabel") }}</span>
|
<span>{{ t("groupEditor.emojiLabel") }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label
|
<label
|
||||||
>{{ t("groupEditor.forgeLabel") }}
|
>{{ t("groupEditor.forgeSources") }}
|
||||||
<span class="lbl-note">{{ t("groupEditor.forgeLabelNote") }}</span></label
|
<span class="lbl-note">{{ t("groupEditor.forgeSourcesNote") }}</span></label
|
||||||
>
|
>
|
||||||
<label class="inline">
|
<div v-for="(s, i) in form.forgeSources" :key="i" class="forge-source-row">
|
||||||
<input v-model="form.forgeLabel" type="checkbox" />
|
<input
|
||||||
<span>{{ t("groupEditor.forgeLabelLabel") }}</span>
|
v-model="s.host"
|
||||||
</label>
|
type="text"
|
||||||
<div class="hint">{{ t("groupEditor.forgeLabelHint") }}</div>
|
class="input f-host"
|
||||||
|
:placeholder="t('groupEditor.forgeSourcesHost')"
|
||||||
|
/>
|
||||||
|
<select v-model="s.type" class="select forge-type">
|
||||||
|
<option value="github">GitHub</option>
|
||||||
|
<option value="gitea">Gitea</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="icon-btn danger" @click="form.forgeSources.splice(i, 1)">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
v-model="s.name"
|
||||||
|
type="text"
|
||||||
|
class="input f-name"
|
||||||
|
:placeholder="t('groupEditor.forgeSourcesName')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="btn btn-ghost add-filter" @click="addForgeSource">
|
||||||
|
{{ t("groupEditor.forgeSourcesAdd") }}
|
||||||
|
</button>
|
||||||
|
<div class="hint">{{ t("groupEditor.forgeSourcesHint") }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label
|
<label
|
||||||
|
|
@ -193,7 +213,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, watch } from "vue";
|
import { reactive, watch } from "vue";
|
||||||
import type { Group } from "~/types";
|
import type { ForgeSource, Group } from "~/types";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
|
@ -219,7 +239,7 @@ const form = reactive({
|
||||||
providers: [] as ("github" | "gitea")[],
|
providers: [] as ("github" | "gitea")[],
|
||||||
installationId: "",
|
installationId: "",
|
||||||
emoji: true,
|
emoji: true,
|
||||||
forgeLabel: false,
|
forgeSources: [] as ForgeSource[],
|
||||||
lang: "",
|
lang: "",
|
||||||
logPlatform: "" as "" | "discord" | "telegram",
|
logPlatform: "" as "" | "discord" | "telegram",
|
||||||
logChannelId: "",
|
logChannelId: "",
|
||||||
|
|
@ -228,6 +248,9 @@ const form = reactive({
|
||||||
logTopicId: "",
|
logTopicId: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function addForgeSource(): void {
|
||||||
|
form.forgeSources.push({ host: "", type: "github" });
|
||||||
|
}
|
||||||
function toggleProvider(p: "github" | "gitea", e: Event): void {
|
function toggleProvider(p: "github" | "gitea", e: Event): void {
|
||||||
const checked = (e.target as HTMLInputElement).checked;
|
const checked = (e.target as HTMLInputElement).checked;
|
||||||
form.providers = checked
|
form.providers = checked
|
||||||
|
|
@ -249,7 +272,7 @@ watch(
|
||||||
);
|
);
|
||||||
form.installationId = g?.installationId != null ? String(g.installationId) : "";
|
form.installationId = g?.installationId != null ? String(g.installationId) : "";
|
||||||
form.emoji = g?.emoji ?? true;
|
form.emoji = g?.emoji ?? true;
|
||||||
form.forgeLabel = g?.forgeLabel ?? false;
|
form.forgeSources = (g?.forgeSources ?? []).map((s) => ({ ...s }));
|
||||||
form.lang = g?.lang ?? "";
|
form.lang = g?.lang ?? "";
|
||||||
form.logPlatform = lt?.platform ?? "";
|
form.logPlatform = lt?.platform ?? "";
|
||||||
form.logChannelId = lt?.channelId ?? "";
|
form.logChannelId = lt?.channelId ?? "";
|
||||||
|
|
@ -308,6 +331,12 @@ function save(): void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const s of form.forgeSources) {
|
||||||
|
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i.test(s.host.trim())) {
|
||||||
|
formError.value = t("groupEditor.errForgeSourceHost");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
const owners = splitList(form.owners);
|
const owners = splitList(form.owners);
|
||||||
const members = props.group?.members
|
const members = props.group?.members
|
||||||
? props.group.members.map((m) => ({ ...m }))
|
? props.group.members.map((m) => ({ ...m }))
|
||||||
|
|
@ -323,7 +352,13 @@ function save(): void {
|
||||||
providers: form.providers.length ? form.providers : undefined,
|
providers: form.providers.length ? form.providers : undefined,
|
||||||
installationId,
|
installationId,
|
||||||
emoji: form.emoji,
|
emoji: form.emoji,
|
||||||
forgeLabel: form.forgeLabel,
|
forgeSources: form.forgeSources.length
|
||||||
|
? form.forgeSources.map((s) => ({
|
||||||
|
host: s.host.trim(),
|
||||||
|
type: s.type,
|
||||||
|
...(s.name?.trim() ? { name: s.name.trim() } : {}),
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
lang: form.lang.trim() || undefined,
|
lang: form.lang.trim() || undefined,
|
||||||
logTarget,
|
logTarget,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,15 @@ export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T>
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
...init,
|
...init,
|
||||||
headers: { accept: "application/json", ...(init?.headers ?? {}) },
|
// JSON in/out: h3's readBody only parses JSON bodies when the request
|
||||||
|
// declares application/json, and the browser defaults string bodies to
|
||||||
|
// text/plain — without this header every PUT/POST would arrive as a
|
||||||
|
// raw string and fail validation.
|
||||||
|
headers: {
|
||||||
|
accept: "application/json",
|
||||||
|
"content-type": "application/json",
|
||||||
|
...(init?.headers ?? {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
useAuthState().needLogin.value = true;
|
useAuthState().needLogin.value = true;
|
||||||
|
|
|
||||||
|
|
@ -198,11 +198,14 @@ const en: Dict = {
|
||||||
"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).",
|
"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.errInstallationId": "Installation ID must be a positive integer",
|
||||||
"groupEditor.emoji": "Show emojis in messages",
|
"groupEditor.emoji": "Show emojis in messages",
|
||||||
"groupEditor.forgeLabel": "Show forge source",
|
"groupEditor.forgeSources": "Forge hosts",
|
||||||
"groupEditor.forgeLabelNote": "(optional)",
|
"groupEditor.forgeSourcesNote": "(optional — label events by their host)",
|
||||||
"groupEditor.forgeLabelLabel": "Show which forge (GitHub / Gitea / custom) produced each event",
|
"groupEditor.forgeSourcesHost": "git.example.com",
|
||||||
"groupEditor.forgeLabelHint":
|
"groupEditor.forgeSourcesName": "Optional display name",
|
||||||
"Adds the forge icon and site name to the message footer so events from different forges can be told apart.",
|
"groupEditor.forgeSourcesAdd": "+ Add host",
|
||||||
|
"groupEditor.forgeSourcesHint":
|
||||||
|
"Events whose repository host matches an entry are labeled in the message footer with its display name (or the host when none is set) — e.g. git1.example.com and git2.example.com for two Gitea instances. GitHub events match github.com.",
|
||||||
|
"groupEditor.errForgeSourceHost": "Every forge host must be a valid hostname",
|
||||||
"groupEditor.logTarget": "Webhook log channel",
|
"groupEditor.logTarget": "Webhook log channel",
|
||||||
"groupEditor.logTargetNote": "(optional)",
|
"groupEditor.logTargetNote": "(optional)",
|
||||||
"groupEditor.logDisabled": "— Disabled —",
|
"groupEditor.logDisabled": "— Disabled —",
|
||||||
|
|
@ -453,11 +456,14 @@ const zh: Dict = {
|
||||||
"将本分组绑定到一个 GitHub App 安装(安装了 App 的组织/用户)。只有来自该安装的事件才会进入本分组的路由;留空表示接受任意安装(仍受来源限定过滤)。",
|
"将本分组绑定到一个 GitHub App 安装(安装了 App 的组织/用户)。只有来自该安装的事件才会进入本分组的路由;留空表示接受任意安装(仍受来源限定过滤)。",
|
||||||
"groupEditor.errInstallationId": "安装 ID 必须为正整数",
|
"groupEditor.errInstallationId": "安装 ID 必须为正整数",
|
||||||
"groupEditor.emoji": "消息中显示表情符号",
|
"groupEditor.emoji": "消息中显示表情符号",
|
||||||
"groupEditor.forgeLabel": "显示来源平台",
|
"groupEditor.forgeSources": "来源主机",
|
||||||
"groupEditor.forgeLabelNote": "(可选)",
|
"groupEditor.forgeSourcesNote": "(可选 —— 按主机标识事件)",
|
||||||
"groupEditor.forgeLabelLabel": "在消息中标识事件来源(GitHub / Gitea / 自定义)",
|
"groupEditor.forgeSourcesHost": "git.example.com",
|
||||||
"groupEditor.forgeLabelHint":
|
"groupEditor.forgeSourcesName": "可选显示名称",
|
||||||
"在消息底部显示来源平台的图标与站点名称,便于区分不同 forge 的事件。",
|
"groupEditor.forgeSourcesAdd": "+ 添加主机",
|
||||||
|
"groupEditor.forgeSourcesHint":
|
||||||
|
"仓库主机与条目匹配的事件,会在消息底部用该条目的显示名称标注(未填名称则用主机名)——例如两个 Gitea 实例可分别填 git1.example.com 和 git2.example.com。GitHub 事件匹配 github.com。",
|
||||||
|
"groupEditor.errForgeSourceHost": "每个来源主机必须是有效的主机名",
|
||||||
"groupEditor.logTarget": "Webhook 日志频道",
|
"groupEditor.logTarget": "Webhook 日志频道",
|
||||||
"groupEditor.logTargetNote": "(可选)",
|
"groupEditor.logTargetNote": "(可选)",
|
||||||
"groupEditor.logDisabled": "— 未启用 —",
|
"groupEditor.logDisabled": "— 未启用 —",
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,12 @@ export interface Route {
|
||||||
|
|
||||||
export type GroupRole = "owner" | "admin" | "viewer";
|
export type GroupRole = "owner" | "admin" | "viewer";
|
||||||
|
|
||||||
|
export interface ForgeSource {
|
||||||
|
host: string;
|
||||||
|
type: "github" | "gitea";
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GroupMember {
|
export interface GroupMember {
|
||||||
login: string;
|
login: string;
|
||||||
role: GroupRole;
|
role: GroupRole;
|
||||||
|
|
@ -40,7 +46,7 @@ export interface Group {
|
||||||
providers?: ("github" | "gitea" | "gitlab")[];
|
providers?: ("github" | "gitea" | "gitlab")[];
|
||||||
installationId?: number;
|
installationId?: number;
|
||||||
emoji?: boolean;
|
emoji?: boolean;
|
||||||
forgeLabel?: boolean;
|
forgeSources?: ForgeSource[];
|
||||||
lang?: string;
|
lang?: string;
|
||||||
logTarget?: RouteTarget;
|
logTarget?: RouteTarget;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,16 @@ groups:
|
||||||
# providers: ["github", "gitea"] # restrict to source platforms (optional; empty = all)
|
# providers: ["github", "gitea"] # restrict to source platforms (optional; empty = all)
|
||||||
# installationId: 12345678 # GitHub App installation binding (auto-created on installation.created)
|
# installationId: 12345678 # GitHub App installation binding (auto-created on installation.created)
|
||||||
# emoji: true # include emoji in messages (default true)
|
# emoji: true # include emoji in messages (default true)
|
||||||
# forgeLabel: false # show the forge source (GitHub / Gitea / custom) in message footers (default false)
|
# Forge hosts: label message footers with the host an event came from
|
||||||
|
# (optional `name` overrides the host as the display label).
|
||||||
|
# forgeSources:
|
||||||
|
# - host: "github.com" # GitHub events match github.com
|
||||||
|
# type: github
|
||||||
|
# name: "GitHub 主站" # optional display label
|
||||||
|
# - host: "git1.example.com" # a self-hosted Gitea instance
|
||||||
|
# type: gitea
|
||||||
|
# - host: "git2.example.com" # another one
|
||||||
|
# type: gitea
|
||||||
# lang: "en" # message language for this group's routes (en/zh, default en)
|
# lang: "en" # message language for this group's routes (en/zh, default en)
|
||||||
# Webhook log channel: a Discord channel/thread or Telegram chat/topic that
|
# Webhook log channel: a Discord channel/thread or Telegram chat/topic that
|
||||||
# receives a summary of every webhook this group's routes dispatch.
|
# receives a summary of every webhook this group's routes dispatch.
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ Routes belong to groups. Groups scope admin access and can restrict which events
|
||||||
| `providers` | string[] | No | Source platforms allowed into this group (`github`, `gitea`); empty = all |
|
| `providers` | string[] | No | Source platforms allowed into this group (`github`, `gitea`); empty = all |
|
||||||
| `installationId` | number | No | GitHub App installation id bound to this group; only that installation's events are accepted (empty = all) |
|
| `installationId` | number | No | GitHub App installation id bound to this group; only that installation's events are accepted (empty = all) |
|
||||||
| `emoji` | boolean | No | Whether to include emoji in this group's messages (default `true`) |
|
| `emoji` | boolean | No | Whether to include emoji in this group's messages (default `true`) |
|
||||||
| `forgeLabel` | boolean | No | Whether to show the forge source (GitHub / Gitea instance / custom) in the footer of this group's messages (default `false`) |
|
| `forgeSources` | object[] | No | Forge hosts: `{ host, type, name? }` entries (`type` is `github` or `gitea`, `name` is an optional display label) that label this group's message footers; empty = no label |
|
||||||
| `lang` | string | No | Message language for every route in this group (e.g. `en`, `zh`; custom via KV `i18n:<lang>`) — see [Message Language](./i18n) — defaults to `en` |
|
| `lang` | string | No | Message language for every route in this group (e.g. `en`, `zh`; custom via KV `i18n:<lang>`) — see [Message Language](./i18n) — defaults to `en` |
|
||||||
| `logTarget` | object | No | Webhook log channel: a Discord `{ platform, channelId, threadId? }` or Telegram `{ platform, chatId, topicId? }` target that receives a summary of every webhook the group's routes dispatch |
|
| `logTarget` | object | No | Webhook log channel: a Discord `{ platform, channelId, threadId? }` or Telegram `{ platform, chatId, topicId? }` target that receives a summary of every webhook the group's routes dispatch |
|
||||||
|
|
||||||
|
|
@ -59,11 +59,19 @@ A group may set `logTarget` to a Discord channel/thread or Telegram chat/topic.
|
||||||
|
|
||||||
## Forge Source Label
|
## Forge Source Label
|
||||||
|
|
||||||
With `forgeLabel: true`, every message this group's routes send carries the source forge in its footer, so events from GitHub, a self-hosted Gitea instance and custom webhooks are easy to tell apart when they share a channel:
|
A group defines the forges it receives events from via `forgeSources`, a list of `{ host, type, name? }` entries (`type` is `github` or `gitea`). Every message this group's routes send carries the footer label of the **first entry whose type matches the event's provider and whose host matches the repository URL's hostname** (GitHub events match `github.com`). The label is the entry's optional `name` — falling back to the host — so two self-hosted Gitea instances can be shown as "内网 Gitea" / "Git2 仓库" while matched by their distinct hosts:
|
||||||
|
|
||||||
- **Discord** — the embed footer shows the forge name next to the repo (`GitHub · acme/widget`) with the site's favicon as the footer icon (for Gitea the instance's own favicon is fetched from its origin).
|
```json
|
||||||
- **Telegram** — the footer line starts with the hyperlinked site name (`[GitHub](https://github.com)` or the Gitea instance hostname).
|
{ "forgeSources": [
|
||||||
- **Custom** webhooks are labeled `Custom` without a link.
|
{ "host": "github.com", "type": "github", "name": "GitHub 主站" },
|
||||||
|
{ "host": "git1.example.com", "type": "gitea", "name": "内网 Gitea" },
|
||||||
|
{ "host": "git2.example.com", "type": "gitea" }
|
||||||
|
] }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Discord** — the embed footer shows the label next to the repo (`内网 Gitea · acme/widget`) with the site's favicon as the footer icon (derived from the repository URL).
|
||||||
|
- **Telegram** — the footer line starts with the hyperlinked label (`[内网 Gitea](https://git1.example.com)`).
|
||||||
|
- Events whose repository host has no matching entry (e.g. custom webhooks, or a repo hosted elsewhere) get no label.
|
||||||
|
|
||||||
The label is independent of `Group.emoji` and follows every message that group dispatches (including in-place edits of workflow/check messages).
|
The label is independent of `Group.emoji` and follows every message that group dispatches (including in-place edits of workflow/check messages).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ Event-specific emoji are added by the formatters; per-group `Group.emoji` (defau
|
||||||
|
|
||||||
## Forge Source Label
|
## Forge Source Label
|
||||||
|
|
||||||
With `Group.forgeLabel` (default false) the message footer additionally names the source forge — GitHub, the Gitea instance hostname (hyperlinked), or a plain `Custom` — so events from different forges can be told apart. See [Groups → Forge Source Label](./groups#forge-source-label).
|
With `Group.forgeSources` (a list of `{ host, type, name? }` entries) the message footer names the matching source — the first entry whose type (`github` / `gitea`) matches the event's provider and whose host matches the repository URL's hostname, labeled with its optional `name` (falling back to the host) — so events from different forges can be told apart. See [Groups → Forge Source Label](./groups#forge-source-label).
|
||||||
|
|
||||||
## In-Place Updates
|
## In-Place Updates
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
| `providers` | string[] | 否 | 允许进入该分组的来源平台(`github`、`gitea`);空 = 全部 |
|
| `providers` | string[] | 否 | 允许进入该分组的来源平台(`github`、`gitea`);空 = 全部 |
|
||||||
| `installationId` | number | 否 | 绑定到该分组的 GitHub App 安装 id;仅接受该安装的事件(空 = 全部) |
|
| `installationId` | number | 否 | 绑定到该分组的 GitHub App 安装 id;仅接受该安装的事件(空 = 全部) |
|
||||||
| `emoji` | boolean | 否 | 该分组消息是否包含表情(默认 `true`) |
|
| `emoji` | boolean | 否 | 该分组消息是否包含表情(默认 `true`) |
|
||||||
| `forgeLabel` | boolean | 否 | 是否在该分组消息的底部显示来源平台标识(GitHub / Gitea 实例 / 自定义)(默认 `false`) |
|
| `forgeSources` | object[] | 否 | 来源主机:`{ host, type, name? }` 条目(`type` 为 `github` 或 `gitea`,`name` 为可选显示名称),用于标注该分组消息底部;空 = 不标注 |
|
||||||
| `lang` | string | 否 | 该分组所有路由的消息语言(如 `en`、`zh`;可通过 KV `i18n:<lang>` 自定义)——见[消息语言](./i18n)——默认 `en` |
|
| `lang` | string | 否 | 该分组所有路由的消息语言(如 `en`、`zh`;可通过 KV `i18n:<lang>` 自定义)——见[消息语言](./i18n)——默认 `en` |
|
||||||
| `logTarget` | object | 否 | Webhook 日志频道:Discord 目标 `{ platform, channelId, threadId? }` 或 Telegram 目标 `{ platform, chatId, topicId? }`,接收该分组路由每次分发 webhook 的摘要 |
|
| `logTarget` | object | 否 | Webhook 日志频道:Discord 目标 `{ platform, channelId, threadId? }` 或 Telegram 目标 `{ platform, chatId, topicId? }`,接收该分组路由每次分发 webhook 的摘要 |
|
||||||
|
|
||||||
|
|
@ -59,11 +59,19 @@
|
||||||
|
|
||||||
## 来源平台标识
|
## 来源平台标识
|
||||||
|
|
||||||
设置 `forgeLabel: true` 后,该分组路由发出的每条消息都会在底部带上来源平台,便于在共享频道中区分来自 GitHub、自建 Gitea 实例与自定义 webhook 的事件:
|
分组通过 `forgeSources` 定义自己接收事件的 forge,即一组 `{ host, type, name? }` 条目(`type` 为 `github` 或 `gitea`)。该分组路由发出的每条消息都会使用**与事件来源类型匹配、且 host 与仓库 URL 主机名一致的第一条**配置作为底部标识(GitHub 事件匹配 `github.com`)。标识文本为条目的可选 `name`——未填则回退为 host——因此两个自建 Gitea 实例可以显示为「内网 Gitea」/「Git2 仓库」,同时通过各自主机名完成匹配:
|
||||||
|
|
||||||
- **Discord** — embed 底部在仓库名旁显示平台名(`GitHub · acme/widget`),并以站点 favicon 作为底部图标(Gitea 会从其实例源站获取自身 favicon)。
|
```json
|
||||||
- **Telegram** — 底部行以带超链接的站点名称开头(`[GitHub](https://github.com)` 或 Gitea 实例主机名)。
|
{ "forgeSources": [
|
||||||
- **自定义** webhook 显示为无链接的 `Custom`。
|
{ "host": "github.com", "type": "github", "name": "GitHub 主站" },
|
||||||
|
{ "host": "git1.example.com", "type": "gitea", "name": "内网 Gitea" },
|
||||||
|
{ "host": "git2.example.com", "type": "gitea" }
|
||||||
|
] }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Discord** — embed 底部在仓库名旁显示标识(`内网 Gitea · acme/widget`),并以按仓库 URL 推导的站点 favicon 作为底部图标。
|
||||||
|
- **Telegram** — 底部行以带超链接的标识开头(`[内网 Gitea](https://git1.example.com)`)。
|
||||||
|
- 仓库主机没有匹配条目的事件(如自定义 webhook,或仓库托管在别处)不显示标识。
|
||||||
|
|
||||||
该标识与 `Group.emoji` 相互独立,并跟随该分组分发的所有消息(包括工作流/检查消息的就地更新)。
|
该标识与 `Group.emoji` 相互独立,并跟随该分组分发的所有消息(包括工作流/检查消息的就地更新)。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
|
|
||||||
## 来源平台标识
|
## 来源平台标识
|
||||||
|
|
||||||
设置 `Group.forgeLabel`(默认关闭)后,消息底部还会标注来源平台——GitHub、Gitea 实例主机名(带超链接)或无链接的 `Custom`——便于区分来自不同 forge 的事件。见[分组 → 来源平台标识](./groups#来源平台标识)。
|
设置 `Group.forgeSources`(一组 `{ host, type, name? }` 条目)后,消息底部会标注匹配的来源——即 `type`(`github` / `gitea`)与事件来源一致、且 `host` 与仓库 URL 主机名一致的第一条配置,标识文本为其可选 `name`(未填回退为 host)——便于区分来自不同 forge 的事件。见[分组 → 来源平台标识](./groups#来源平台标识)。
|
||||||
|
|
||||||
## 原地更新
|
## 原地更新
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -151,8 +151,8 @@ export async function dispatchEvent(
|
||||||
const tr = trMap.get(group?.lang ?? "en")!;
|
const tr = trMap.get(group?.lang ?? "en")!;
|
||||||
const showEmoji = group?.emoji !== false;
|
const showEmoji = group?.emoji !== false;
|
||||||
const message = formatEvent(route, event, tr, showEmoji);
|
const message = formatEvent(route, event, tr, showEmoji);
|
||||||
if (group?.forgeLabel) {
|
if (group?.forgeSources?.length) {
|
||||||
message.forge = forgeInfo(event);
|
message.forge = forgeInfo(event, group.forgeSources);
|
||||||
}
|
}
|
||||||
if (route.discordRoleIds?.length) {
|
if (route.discordRoleIds?.length) {
|
||||||
message.mentionRoleIds = route.discordRoleIds;
|
message.mentionRoleIds = route.discordRoleIds;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { NeutralForge, NeutralMessage, WebhookEvent } from "../types";
|
import type { ForgeSource, NeutralForge, NeutralMessage, WebhookEvent } from "../types";
|
||||||
import { t as translate } from "../lib/i18n";
|
import { t as translate } from "../lib/i18n";
|
||||||
import type { Translations } from "../lib/i18n";
|
import type { Translations } from "../lib/i18n";
|
||||||
|
|
||||||
|
|
@ -162,31 +162,50 @@ export function senderProfileUrl(repoUrl: string | undefined, login: string): st
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forge branding for an event (used when the group enables `forgeLabel`):
|
* Forge branding for an event, driven by the group's own forgeSources list.
|
||||||
* GitHub gets its brand + favicon, Gitea the instance hostname + favicon
|
* The event's repository host (github.com for GitHub, the instance hostname
|
||||||
* derived from the repository URL, custom webhooks a plain "Custom" label.
|
* for Gitea) is matched case-insensitively against the configured hosts; the
|
||||||
|
* first entry whose type and host both match wins. The footer label is the
|
||||||
|
* entry's display `name` (or its host when no name is set). Link/favicon are
|
||||||
|
* derived from the repository URL.
|
||||||
*/
|
*/
|
||||||
export function forgeInfo(event: WebhookEvent): NeutralForge | undefined {
|
export function forgeInfo(
|
||||||
|
event: WebhookEvent,
|
||||||
|
sources?: ForgeSource[],
|
||||||
|
): NeutralForge | undefined {
|
||||||
const repoUrl = (event.payload.repository as { html_url?: string } | undefined)?.html_url;
|
const repoUrl = (event.payload.repository as { html_url?: string } | undefined)?.html_url;
|
||||||
switch (event.provider) {
|
let host: string | undefined;
|
||||||
case "github":
|
if (repoUrl) {
|
||||||
return {
|
try {
|
||||||
name: "GitHub",
|
host = new URL(repoUrl).hostname.toLowerCase();
|
||||||
url: "https://github.com",
|
} catch {
|
||||||
iconUrl: "https://github.com/favicon.ico",
|
// unparseable repo URL — fall through
|
||||||
};
|
|
||||||
case "gitea": {
|
|
||||||
if (!repoUrl) return undefined;
|
|
||||||
try {
|
|
||||||
const origin = new URL(repoUrl).origin;
|
|
||||||
return { name: new URL(origin).hostname, url: origin, iconUrl: `${origin}/favicon.ico` };
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
case "custom":
|
|
||||||
return { name: "Custom" };
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
// GitHub events without a repository (e.g. ping) still match github.com.
|
||||||
|
if (!host && event.provider === "github") host = "github.com";
|
||||||
|
if (!host) return undefined;
|
||||||
|
|
||||||
|
const source = sources?.find(
|
||||||
|
(s) => s.type === event.provider && s.host.toLowerCase() === host,
|
||||||
|
);
|
||||||
|
if (!source) return undefined;
|
||||||
|
|
||||||
|
const label = source.name?.trim() || source.host;
|
||||||
|
if (repoUrl) {
|
||||||
|
try {
|
||||||
|
const origin = new URL(repoUrl).origin;
|
||||||
|
return { name: label, url: origin, iconUrl: `${origin}/favicon.ico` };
|
||||||
|
} catch {
|
||||||
|
// unparseable repo URL — name only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.provider === "github") {
|
||||||
|
return {
|
||||||
|
name: label,
|
||||||
|
url: "https://github.com",
|
||||||
|
iconUrl: "https://github.com/favicon.ico",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { name: label };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -127,10 +127,14 @@ export interface Group {
|
||||||
*/
|
*/
|
||||||
emoji?: boolean;
|
emoji?: boolean;
|
||||||
/**
|
/**
|
||||||
* Whether to show the forge source (GitHub / Gitea instance / custom) in the
|
* Named forge sources shown in the footer of this group's messages. Each
|
||||||
* footer of this group's messages. Defaults to false when omitted.
|
* entry pairs a host (e.g. `git1.example.com`) with a source type
|
||||||
|
* (`github` / `gitea`) and an optional display `name`; an event is labeled
|
||||||
|
* with the first entry whose type matches its provider and whose host
|
||||||
|
* matches the repository URL's hostname (GitHub events match `github.com`).
|
||||||
|
* Empty/omitted = no forge label.
|
||||||
*/
|
*/
|
||||||
forgeLabel?: boolean;
|
forgeSources?: ForgeSource[];
|
||||||
/**
|
/**
|
||||||
* Message language for every route in this group (e.g. "en", "zh"; custom
|
* Message language for every route in this group (e.g. "en", "zh"; custom
|
||||||
* via KV i18n:<lang>). Defaults to "en" when omitted.
|
* via KV i18n:<lang>). Defaults to "en" when omitted.
|
||||||
|
|
@ -170,13 +174,31 @@ export interface NeutralAuthor {
|
||||||
url?: string;
|
url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ForgeType = "github" | "gitea";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A forge host a group configures itself. When an event's repository host
|
||||||
|
* matches `host`, the message footer is labeled with `name` (when set) or the
|
||||||
|
* host itself — e.g. two self-hosted Gitea instances can be shown as
|
||||||
|
* "内网 Gitea" / "Git2 仓库" while matched by git1.example.com /
|
||||||
|
* git2.example.com. GitHub events match `github.com` (or a GitHub Enterprise
|
||||||
|
* host). Link/icon are derived from the repository URL (https, port preserved).
|
||||||
|
*/
|
||||||
|
export interface ForgeSource {
|
||||||
|
/** Hostname matched against the repository URL (case-insensitive). */
|
||||||
|
host: string;
|
||||||
|
type: ForgeType;
|
||||||
|
/** Optional display label shown in the message footer; falls back to `host`. */
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The forge (source platform) that produced an event: shown in the message
|
* The forge (source platform) that produced an event: shown in the message
|
||||||
* footer when the group enables `Group.forgeLabel` so recipients can tell
|
* footer when the group defines a matching `Group.forgeSources` entry so
|
||||||
* GitHub, a Gitea instance or a custom webhook apart at a glance.
|
* recipients can tell GitHub and Gitea instances apart at a glance.
|
||||||
*/
|
*/
|
||||||
export interface NeutralForge {
|
export interface NeutralForge {
|
||||||
/** Display name, e.g. "GitHub" or the Gitea instance hostname. */
|
/** Display name: the source's `name` or its host (e.g. "内网 Gitea"). */
|
||||||
name: string;
|
name: string;
|
||||||
/** Site URL for the hyperlink (Telegram) / brand context (Discord). */
|
/** Site URL for the hyperlink (Telegram) / brand context (Discord). */
|
||||||
url?: string;
|
url?: string;
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,15 @@ function isValidId(value: unknown): value is number {
|
||||||
|
|
||||||
async function readJsonBody(event: H3Event): Promise<Record<string, unknown> | null> {
|
async function readJsonBody(event: H3Event): Promise<Record<string, unknown> | null> {
|
||||||
try {
|
try {
|
||||||
return (await readBody(event)) as Record<string, unknown>;
|
const body = await readBody(event);
|
||||||
|
if (typeof body === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(body) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (body ?? {}) as Record<string, unknown>;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
setResponseHeader,
|
setResponseHeader,
|
||||||
setResponseStatus,
|
setResponseStatus,
|
||||||
} from "h3";
|
} from "h3";
|
||||||
import type { Route, Group, GroupMember, GroupRole } from "../types";
|
import type { Route, Group, GroupMember, GroupRole, ForgeSource } from "../types";
|
||||||
import { loadRoutes, saveRoutes } from "../config";
|
import { loadRoutes, saveRoutes } from "../config";
|
||||||
import { getAdminSession, destroyAdminSession, clearAdminCookie } from "./session";
|
import { getAdminSession, destroyAdminSession, clearAdminCookie } from "./session";
|
||||||
import { saveGroups, loadGroups, identityMatches, normalizeGroupMembers } from "./groups";
|
import { saveGroups, loadGroups, identityMatches, normalizeGroupMembers } from "./groups";
|
||||||
|
|
@ -36,6 +36,7 @@ import { log } from "../lib/log";
|
||||||
|
|
||||||
const VALID_FILTER_TYPES = new Set(["event", "repo", "actor", "action", "branch", "keyword"]);
|
const VALID_FILTER_TYPES = new Set(["event", "repo", "actor", "action", "branch", "keyword"]);
|
||||||
const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
|
const ID_RE = /^[a-z0-9][a-z0-9-]*$/;
|
||||||
|
const HOST_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
|
||||||
|
|
||||||
function isValidMatch(match: unknown): match is string | string[] {
|
function isValidMatch(match: unknown): match is string | string[] {
|
||||||
if (typeof match === "string") return match.trim().length > 0;
|
if (typeof match === "string") return match.trim().length > 0;
|
||||||
|
|
@ -301,8 +302,55 @@ export function validateGroups(
|
||||||
if (g.emoji !== undefined && typeof g.emoji !== "boolean") {
|
if (g.emoji !== undefined && typeof g.emoji !== "boolean") {
|
||||||
return { ok: false, error: `group "${g.id}".emoji must be a boolean` };
|
return { ok: false, error: `group "${g.id}".emoji must be a boolean` };
|
||||||
}
|
}
|
||||||
if (g.forgeLabel !== undefined && typeof g.forgeLabel !== "boolean") {
|
if (g.forgeSources !== undefined && g.forgeSources !== null) {
|
||||||
return { ok: false, error: `group "${g.id}".forgeLabel must be a boolean` };
|
if (!Array.isArray(g.forgeSources)) {
|
||||||
|
return { ok: false, error: `group "${g.id}".forgeSources must be an array` };
|
||||||
|
}
|
||||||
|
if (g.forgeSources.length > 20) {
|
||||||
|
return { ok: false, error: `group "${g.id}".forgeSources: too many sources` };
|
||||||
|
}
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const normalized: ForgeSource[] = [];
|
||||||
|
for (let i = 0; i < g.forgeSources.length; i++) {
|
||||||
|
const s = g.forgeSources[i] as Record<string, unknown>;
|
||||||
|
if (!s || typeof s !== "object") {
|
||||||
|
return { ok: false, error: `group "${g.id}".forgeSources[${i}] is not an object` };
|
||||||
|
}
|
||||||
|
if (typeof s.host !== "string" || !HOST_RE.test(s.host)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `group "${g.id}".forgeSources[${i}].host must be a valid hostname`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (s.type !== "github" && s.type !== "gitea") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `group "${g.id}".forgeSources[${i}].type must be "github" | "gitea"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
s.name !== undefined &&
|
||||||
|
(typeof s.name !== "string" || s.name.length > 50)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `group "${g.id}".forgeSources[${i}].name must be a string`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const key = `${s.type}:${s.host.toLowerCase()}`;
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return { ok: false, error: `group "${g.id}".forgeSources has a duplicate source` };
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
normalized.push({
|
||||||
|
host: s.host.trim(),
|
||||||
|
type: s.type,
|
||||||
|
...(s.name !== undefined && s.name.trim() ? { name: s.name.trim() } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
g.forgeSources = normalized;
|
||||||
|
} else {
|
||||||
|
delete g.forgeSources;
|
||||||
}
|
}
|
||||||
if (g.lang !== undefined && typeof g.lang !== "string") {
|
if (g.lang !== undefined && typeof g.lang !== "string") {
|
||||||
return { ok: false, error: `group "${g.id}".lang must be a string` };
|
return { ok: false, error: `group "${g.id}".lang must be a string` };
|
||||||
|
|
@ -344,10 +392,21 @@ function accessError(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read a JSON body, returning null when it is not valid JSON. */
|
/**
|
||||||
|
* Read a JSON body, returning null when it is not valid JSON. h3's readBody
|
||||||
|
* only parses `application/json` bodies; tolerate clients that omit the
|
||||||
|
* Content-Type header (curl, older UI) by JSON-parsing the raw string.
|
||||||
|
*/
|
||||||
async function readJsonBody(event: H3Event): Promise<Record<string, unknown> | null> {
|
async function readJsonBody(event: H3Event): Promise<Record<string, unknown> | null> {
|
||||||
try {
|
try {
|
||||||
const body = await readBody(event);
|
const body = await readBody(event);
|
||||||
|
if (typeof body === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(body) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
return (body ?? {}) as Record<string, unknown>;
|
return (body ?? {}) as Record<string, unknown>;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import { describe, it, expect } from "bun:test";
|
import { describe, it, expect } from "bun:test";
|
||||||
import { adminGroupRename, adminGroupRoutesGet, adminApiMe } from "../server/lib/web/admin";
|
import {
|
||||||
|
adminGroupRename,
|
||||||
|
adminGroupRoutesGet,
|
||||||
|
adminApiGroupsPut,
|
||||||
|
adminApiMe,
|
||||||
|
} from "../server/lib/web/admin";
|
||||||
import { createAdminSession, adminCookie } from "../server/lib/web/session";
|
import { createAdminSession, adminCookie } from "../server/lib/web/session";
|
||||||
import { loadGroups } from "../server/lib/web/groups";
|
import { loadGroups } from "../server/lib/web/groups";
|
||||||
import { loadRoutes } from "../server/lib/config";
|
import { loadRoutes } from "../server/lib/config";
|
||||||
|
|
@ -116,6 +121,46 @@ describe("admin handlers (h3)", () => {
|
||||||
expect(await listInvites(kv, "old-team")).toHaveLength(0);
|
expect(await listInvites(kv, "old-team")).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts a groups PUT without the application/json content-type", async () => {
|
||||||
|
const kv = createMockKV();
|
||||||
|
await kv.put(
|
||||||
|
"config:groups",
|
||||||
|
JSON.stringify([
|
||||||
|
{ id: "team", name: "Team", adminIds: [], members: [{ login: "alice", role: "owner" }] },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const env = createEnv({ KV: kv });
|
||||||
|
const sessionId = await createAdminSession(kv, "1001", "alice");
|
||||||
|
|
||||||
|
// The console's fetch helper always declares application/json, but curl
|
||||||
|
// and older clients may omit it — h3's readBody then returns the raw
|
||||||
|
// string, which used to fail validation with "groups must be an array".
|
||||||
|
const event = makeEvent("/api/groups", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { cookie: adminCookie(sessionId) },
|
||||||
|
body: JSON.stringify({
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: "team",
|
||||||
|
name: "Team",
|
||||||
|
adminIds: [],
|
||||||
|
members: [{ login: "alice", role: "owner" }],
|
||||||
|
forgeSources: [{ host: "git.example.com", type: "gitea", name: "内网 Gitea" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
const result = (await adminApiGroupsPut(event)) as { ok?: boolean };
|
||||||
|
expect(responseStatus(event)).toBe(200);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
const groups = await loadGroups(kv);
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
expect(groups[0]!.forgeSources).toEqual([
|
||||||
|
{ host: "git.example.com", type: "gitea", name: "内网 Gitea" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("forbids non-owner members from renaming", async () => {
|
it("forbids non-owner members from renaming", async () => {
|
||||||
const kv = createMockKV();
|
const kv = createMockKV();
|
||||||
await kv.put(
|
await kv.put(
|
||||||
|
|
|
||||||
|
|
@ -385,7 +385,7 @@ describe("dispatchEvent fallback routing", () => {
|
||||||
expect(logBody.embeds?.[0]?.fields?.[0]?.value).toContain("❌ Push Route → 111");
|
expect(logBody.embeds?.[0]?.fields?.[0]?.value).toContain("❌ Push Route → 111");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("attaches the forge label when the group enables it", async () => {
|
it("attaches the forge label when the group defines a matching host", async () => {
|
||||||
const bodies: string[] = [];
|
const bodies: string[] = [];
|
||||||
mockFetch((url, init) => {
|
mockFetch((url, init) => {
|
||||||
bodies.push(String(init?.body ?? ""));
|
bodies.push(String(init?.body ?? ""));
|
||||||
|
|
@ -395,7 +395,12 @@ describe("dispatchEvent fallback routing", () => {
|
||||||
await kv.put(
|
await kv.put(
|
||||||
"config:groups",
|
"config:groups",
|
||||||
JSON.stringify([
|
JSON.stringify([
|
||||||
{ id: "gh", name: "GH", adminIds: [], forgeLabel: true },
|
{
|
||||||
|
id: "gh",
|
||||||
|
name: "GH",
|
||||||
|
adminIds: [],
|
||||||
|
forgeSources: [{ host: "git.example.com", type: "gitea", name: "内网 Gitea" }],
|
||||||
|
},
|
||||||
{ id: "plain", name: "Plain", adminIds: [] },
|
{ id: "plain", name: "Plain", adminIds: [] },
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
@ -445,7 +450,7 @@ describe("dispatchEvent fallback routing", () => {
|
||||||
return parsed.embeds?.[0]?.footer;
|
return parsed.embeds?.[0]?.footer;
|
||||||
});
|
});
|
||||||
expect(footers).toContainEqual({
|
expect(footers).toContainEqual({
|
||||||
text: "git.example.com · owner/repo",
|
text: "内网 Gitea · owner/repo",
|
||||||
icon_url: "https://git.example.com/favicon.ico",
|
icon_url: "https://git.example.com/favicon.ico",
|
||||||
});
|
});
|
||||||
expect(footers).toContainEqual({ text: "owner/repo" });
|
expect(footers).toContainEqual({ text: "owner/repo" });
|
||||||
|
|
|
||||||
|
|
@ -520,32 +520,129 @@ describe("limits and localization", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("forge source branding", () => {
|
describe("forge source branding", () => {
|
||||||
it("github events brand as GitHub with the favicon", () => {
|
const ghHost = { host: "github.com", type: "github" as const };
|
||||||
expect(forgeInfo({ event: "push", provider: "github", payload: {} })).toEqual({
|
|
||||||
name: "GitHub",
|
it("labels github events with the configured host and the github favicon", () => {
|
||||||
|
expect(
|
||||||
|
forgeInfo(
|
||||||
|
{ event: "push", provider: "github", payload: { repository: { html_url: "https://github.com/org/repo" } } },
|
||||||
|
[ghHost],
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
name: "github.com",
|
||||||
url: "https://github.com",
|
url: "https://github.com",
|
||||||
iconUrl: "https://github.com/favicon.ico",
|
iconUrl: "https://github.com/favicon.ico",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gitea events brand as the instance hostname derived from the repo url", () => {
|
it("labels github events without a repository (ping) via the github.com fallback", () => {
|
||||||
expect(
|
expect(
|
||||||
forgeInfo({
|
forgeInfo({ event: "ping", provider: "github", payload: {} }, [ghHost]),
|
||||||
event: "push",
|
|
||||||
provider: "gitea",
|
|
||||||
payload: { repository: { html_url: "https://git.example.com/org/repo" } },
|
|
||||||
}),
|
|
||||||
).toEqual({
|
).toEqual({
|
||||||
name: "git.example.com",
|
name: "github.com",
|
||||||
url: "https://git.example.com",
|
url: "https://github.com",
|
||||||
iconUrl: "https://git.example.com/favicon.ico",
|
iconUrl: "https://github.com/favicon.ico",
|
||||||
});
|
});
|
||||||
expect(forgeInfo({ event: "push", provider: "gitea", payload: {} })).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("custom events brand as a plain label without links", () => {
|
it("matches distinct gitea instances by their own host", () => {
|
||||||
expect(forgeInfo({ event: "custom", provider: "custom", payload: {} })).toEqual({
|
const sources = [
|
||||||
name: "Custom",
|
{ host: "git1.example.com", type: "gitea" as const },
|
||||||
|
{ host: "git2.example.com", type: "gitea" as const },
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
forgeInfo(
|
||||||
|
{ event: "push", provider: "gitea", payload: { repository: { html_url: "https://git1.example.com/org/a" } } },
|
||||||
|
sources,
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
name: "git1.example.com",
|
||||||
|
url: "https://git1.example.com",
|
||||||
|
iconUrl: "https://git1.example.com/favicon.ico",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
forgeInfo(
|
||||||
|
{ event: "push", provider: "gitea", payload: { repository: { html_url: "https://git2.example.com/org/b" } } },
|
||||||
|
sources,
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
name: "git2.example.com",
|
||||||
|
url: "https://git2.example.com",
|
||||||
|
iconUrl: "https://git2.example.com/favicon.ico",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("labels gitea events with the configured name when set, else the host", () => {
|
||||||
|
const sources = [
|
||||||
|
{ host: "git1.example.com", type: "gitea" as const, name: "内网 Gitea" },
|
||||||
|
{ host: "git2.example.com", type: "gitea" as const },
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
forgeInfo(
|
||||||
|
{ event: "push", provider: "gitea", payload: { repository: { html_url: "https://git1.example.com/org/a" } } },
|
||||||
|
sources,
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
name: "内网 Gitea",
|
||||||
|
url: "https://git1.example.com",
|
||||||
|
iconUrl: "https://git1.example.com/favicon.ico",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
forgeInfo(
|
||||||
|
{ event: "push", provider: "gitea", payload: { repository: { html_url: "https://git2.example.com/org/b" } } },
|
||||||
|
sources,
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
name: "git2.example.com",
|
||||||
|
url: "https://git2.example.com",
|
||||||
|
iconUrl: "https://git2.example.com/favicon.ico",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
forgeInfo(
|
||||||
|
{ event: "push", provider: "gitea", payload: { repository: { html_url: "https://git1.example.com/org/a" } } },
|
||||||
|
[{ host: "git1.example.com", type: "gitea", name: " " }],
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
name: "git1.example.com",
|
||||||
|
url: "https://git1.example.com",
|
||||||
|
iconUrl: "https://git1.example.com/favicon.ico",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches hosts case-insensitively", () => {
|
||||||
|
expect(
|
||||||
|
forgeInfo(
|
||||||
|
{ event: "push", provider: "gitea", payload: { repository: { html_url: "https://GIT1.Example.COM/org/a" } } },
|
||||||
|
[{ host: "Git1.Example.com", type: "gitea" }],
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
name: "Git1.Example.com",
|
||||||
|
url: "https://git1.example.com",
|
||||||
|
iconUrl: "https://git1.example.com/favicon.ico",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no label when the gitea repo url is missing or unparseable", () => {
|
||||||
|
expect(
|
||||||
|
forgeInfo({ event: "push", provider: "gitea", payload: {} }, [{ host: "git1.example.com", type: "gitea" }]),
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
forgeInfo(
|
||||||
|
{ event: "push", provider: "gitea", payload: { repository: { html_url: "not-a-url" } } },
|
||||||
|
[{ host: "git1.example.com", type: "gitea" }],
|
||||||
|
),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when no source matches the provider or host", () => {
|
||||||
|
expect(forgeInfo({ event: "custom", provider: "custom", payload: {} }, [ghHost])).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
forgeInfo(
|
||||||
|
{ event: "push", provider: "gitea", payload: { repository: { html_url: "https://other.example.com/org/a" } } },
|
||||||
|
[ghHost],
|
||||||
|
),
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(forgeInfo({ event: "push", provider: "github", payload: {} }, [])).toBeUndefined();
|
||||||
|
expect(forgeInfo({ event: "push", provider: "github", payload: {} })).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue