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
|
|
@ -151,8 +151,8 @@ export async function dispatchEvent(
|
|||
const tr = trMap.get(group?.lang ?? "en")!;
|
||||
const showEmoji = group?.emoji !== false;
|
||||
const message = formatEvent(route, event, tr, showEmoji);
|
||||
if (group?.forgeLabel) {
|
||||
message.forge = forgeInfo(event);
|
||||
if (group?.forgeSources?.length) {
|
||||
message.forge = forgeInfo(event, group.forgeSources);
|
||||
}
|
||||
if (route.discordRoleIds?.length) {
|
||||
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 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`):
|
||||
* GitHub gets its brand + favicon, Gitea the instance hostname + favicon
|
||||
* derived from the repository URL, custom webhooks a plain "Custom" label.
|
||||
* Forge branding for an event, driven by the group's own forgeSources list.
|
||||
* The event's repository host (github.com for GitHub, the instance hostname
|
||||
* 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;
|
||||
switch (event.provider) {
|
||||
case "github":
|
||||
return {
|
||||
name: "GitHub",
|
||||
url: "https://github.com",
|
||||
iconUrl: "https://github.com/favicon.ico",
|
||||
};
|
||||
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;
|
||||
}
|
||||
let host: string | undefined;
|
||||
if (repoUrl) {
|
||||
try {
|
||||
host = new URL(repoUrl).hostname.toLowerCase();
|
||||
} catch {
|
||||
// unparseable repo URL — fall through
|
||||
}
|
||||
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;
|
||||
/**
|
||||
* Whether to show the forge source (GitHub / Gitea instance / custom) in the
|
||||
* footer of this group's messages. Defaults to false when omitted.
|
||||
* Named forge sources shown in the footer of this group's messages. Each
|
||||
* 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
|
||||
* via KV i18n:<lang>). Defaults to "en" when omitted.
|
||||
|
|
@ -170,13 +174,31 @@ export interface NeutralAuthor {
|
|||
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
|
||||
* footer when the group enables `Group.forgeLabel` so recipients can tell
|
||||
* GitHub, a Gitea instance or a custom webhook apart at a glance.
|
||||
* footer when the group defines a matching `Group.forgeSources` entry so
|
||||
* recipients can tell GitHub and Gitea instances apart at a glance.
|
||||
*/
|
||||
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;
|
||||
/** Site URL for the hyperlink (Telegram) / brand context (Discord). */
|
||||
url?: string;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,15 @@ function isValidId(value: unknown): value is number {
|
|||
|
||||
async function readJsonBody(event: H3Event): Promise<Record<string, unknown> | null> {
|
||||
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 {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
setResponseHeader,
|
||||
setResponseStatus,
|
||||
} 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 { getAdminSession, destroyAdminSession, clearAdminCookie } from "./session";
|
||||
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 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[] {
|
||||
if (typeof match === "string") return match.trim().length > 0;
|
||||
|
|
@ -301,8 +302,55 @@ export function validateGroups(
|
|||
if (g.emoji !== undefined && typeof g.emoji !== "boolean") {
|
||||
return { ok: false, error: `group "${g.id}".emoji must be a boolean` };
|
||||
}
|
||||
if (g.forgeLabel !== undefined && typeof g.forgeLabel !== "boolean") {
|
||||
return { ok: false, error: `group "${g.id}".forgeLabel must be a boolean` };
|
||||
if (g.forgeSources !== undefined && g.forgeSources !== null) {
|
||||
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") {
|
||||
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> {
|
||||
try {
|
||||
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 {
|
||||
return null;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue