From 89b43b8b28f1c11040d276346e80e02ba3bf0d93 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 11 Aug 2026 16:06:06 +0000
Subject: [PATCH] chore: auto-fix lint & formatting [skip ci]
---
README.zh.md | 2 +-
admin/components/ConsolePage.vue | 21 ++++++----
admin/components/GroupEditor.vue | 7 +++-
admin/components/RouteCard.vue | 13 +++++-
admin/composables/useGroups.ts | 14 ++++++-
docs/guide/configuration.md | 38 +++++++++---------
docs/zh/guide/configuration.md | 68 ++++++++++++++++----------------
src/__tests__/invites.test.ts | 57 ++++++++++++++++++++++----
src/lib/audit.ts | 3 +-
src/web/action-routes.ts | 9 +----
src/web/admin-routes.ts | 17 ++++----
src/web/auth.ts | 8 ++--
12 files changed, 160 insertions(+), 97 deletions(-)
diff --git a/README.zh.md b/README.zh.md
index 958a914..64a2dc5 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -65,7 +65,7 @@ npx wrangler dev # 启动本地开发服务器
| `TELEGRAM_RICH_HEADER_HOST` | 可选;覆盖内置 `GET /api/richheader` 的 Telegram 头像卡片地址 |
| `BASE_URL` | 公网地址(用于 OAuth 回调与 Telegram webhook 同步) |
| `ADMIN_USER_IDS` | 允许访问 `/admin` 的 GitHub 用户 ID(或登录名),逗号分隔 |
-| `ALLOW_SELF_SIGNUP` | 设为 `1` 时,无权限的 GitHub 用户首次登录自动获得个人分组(默认关闭) |
+| `ALLOW_SELF_SIGNUP` | 设为 `1` 时,无权限的 GitHub 用户首次登录自动获得个人分组(默认关闭) |
| `AUDIT_RETENTION_DAYS` | 定时清理时审计日志的保留天数(默认 90) |
| `DOCS_URL` | 可选;落地页使用的文档站点 URL |
| `GITHUB_REPO_URL` | 可选;落地页使用的 GitHub 仓库 URL |
diff --git a/admin/components/ConsolePage.vue b/admin/components/ConsolePage.vue
index f4a8abb..d5a4b56 100644
--- a/admin/components/ConsolePage.vue
+++ b/admin/components/ConsolePage.vue
@@ -73,11 +73,7 @@
{{ t("routes.emptyGroup") }}
-
@@ -134,7 +130,9 @@
{{ g.name || t("route.untitled") }}
{{ g.id }}
- {{ t("role.badge", { role: t("roles." + roleOf(g.id)) }) }}
+ {{
+ t("role.badge", { role: t("roles." + roleOf(g.id)) })
+ }}
{{ t("groups.members") }}{{ (g.members ?? []).length || (g.adminIds || []).length || "—" }}{{
+ (g.members ?? []).length || (g.adminIds || []).length || "—"
+ }}
{{ t("groups.owners") }}();
+const props = defineProps<{
+ open: boolean;
+ group: Group | null;
+ saving: boolean;
+ superAdmin?: boolean;
+}>();
const emit = defineEmits<{
(e: "close"): void;
(e: "save", group: Group): void;
diff --git a/admin/components/RouteCard.vue b/admin/components/RouteCard.vue
index 068b313..5b782f6 100644
--- a/admin/components/RouteCard.vue
+++ b/admin/components/RouteCard.vue
@@ -39,7 +39,11 @@
>
↓
-
+
✎
();
+const props = defineProps<{
+ route: Route;
+ atFirst?: boolean;
+ atLast?: boolean;
+ readonly?: boolean;
+}>();
const emit = defineEmits<{
(e: "toggle", route: Route): void;
(e: "edit", route: Route): void;
diff --git a/admin/composables/useGroups.ts b/admin/composables/useGroups.ts
index 23fde03..d3f01ad 100644
--- a/admin/composables/useGroups.ts
+++ b/admin/composables/useGroups.ts
@@ -70,5 +70,17 @@ export function useGroupsApi() {
groups.value = next;
}
- return { groups, isSuper, roles, roleOf, canEditGroup, canEditRoutes, loading, needLogin, error, load, save };
+ return {
+ groups,
+ isSuper,
+ roles,
+ roleOf,
+ canEditGroup,
+ canEditRoutes,
+ loading,
+ needLogin,
+ error,
+ load,
+ save,
+ };
}
diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md
index 9cf3e65..1d555d6 100644
--- a/docs/guide/configuration.md
+++ b/docs/guide/configuration.md
@@ -58,25 +58,25 @@ WebHooker ships with a built-in config console at `/admin` for managing routes i
The console is served as an SPA at `/admin`; its tabs are deep-linkable via the URL path (`/admin/groups`, `/admin/logs`, `/admin/audit`). URLs outside `/admin` that do not match an endpoint below return a plain `404` instead of the console.
-| Endpoint | Description |
-| ----------------------------------------- | -------------------------------------------- |
-| `GET /admin` | Config console UI |
-| `GET /admin/login` | Start GitHub OAuth sign-in |
-| `GET /admin/logout` | Destroy session |
-| `GET /admin/invite?token=…` | Accept a group invite (browser page) |
-| `GET /admin/api/me` | Current session, scope, groups, and roles |
-| `GET /admin/api/routes` | List routes (scoped to access) |
-| `PUT /admin/api/routes` | Replace routes (owner/admin per group) |
-| `GET /admin/api/groups` | List groups + the signed-in user's role each |
-| `PUT /admin/api/groups` | Replace groups (super: all; owner: own only) |
-| `GET /admin/api/groups/:id/routes` | List a group's routes |
-| `PUT /admin/api/groups/:id/routes` | Replace a group's routes (owner/admin) |
-| `GET /admin/api/logs` | Send logs (scoped to accessible routes) |
-| `GET /admin/api/logs/:id` | Single send-log entry (scoped) |
-| `POST /admin/api/groups/:id/invites` | Create an invite link (owner) |
-| `GET /admin/api/groups/:id/invites` | List pending invites (owner) |
-| `DELETE /admin/api/invites/:token` | Revoke an invite (owner) |
-| `GET /admin/api/audit` | Audit log (scoped to accessible groups) |
+| Endpoint | Description |
+| ------------------------------------ | -------------------------------------------- |
+| `GET /admin` | Config console UI |
+| `GET /admin/login` | Start GitHub OAuth sign-in |
+| `GET /admin/logout` | Destroy session |
+| `GET /admin/invite?token=…` | Accept a group invite (browser page) |
+| `GET /admin/api/me` | Current session, scope, groups, and roles |
+| `GET /admin/api/routes` | List routes (scoped to access) |
+| `PUT /admin/api/routes` | Replace routes (owner/admin per group) |
+| `GET /admin/api/groups` | List groups + the signed-in user's role each |
+| `PUT /admin/api/groups` | Replace groups (super: all; owner: own only) |
+| `GET /admin/api/groups/:id/routes` | List a group's routes |
+| `PUT /admin/api/groups/:id/routes` | Replace a group's routes (owner/admin) |
+| `GET /admin/api/logs` | Send logs (scoped to accessible routes) |
+| `GET /admin/api/logs/:id` | Single send-log entry (scoped) |
+| `POST /admin/api/groups/:id/invites` | Create an invite link (owner) |
+| `GET /admin/api/groups/:id/invites` | List pending invites (owner) |
+| `DELETE /admin/api/invites/:token` | Revoke an invite (owner) |
+| `GET /admin/api/audit` | Audit log (scoped to accessible groups) |
The console lets you add, edit, delete, and toggle routes. Saved routes are written to KV `config:routes` immediately and the config cache is invalidated so the webhook pipeline picks them up on the next run.
diff --git a/docs/zh/guide/configuration.md b/docs/zh/guide/configuration.md
index ef6d38f..4f7df2c 100644
--- a/docs/zh/guide/configuration.md
+++ b/docs/zh/guide/configuration.md
@@ -26,7 +26,7 @@ WebHooker 需要多个密钥才能运行。本地开发时存储在 `.dev.vars`
| --------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------- |
| `BASE_URL` | OAuth 回调的公开 URL | `http://localhost:8787` |
| `ADMIN_USER_IDS` | 允许访问 WebUI 的 GitHub 用户 ID(或登录名),逗号分隔 | 未设置时 WebUI 关闭 |
-| `ALLOW_SELF_SIGNUP` | 开启(`1`/`true`)后,没有任何分组权限的 GitHub 用户首次登录会自动获得个人分组而非 403 | 关闭 |
+| `ALLOW_SELF_SIGNUP` | 开启(`1`/`true`)后,没有任何分组权限的 GitHub 用户首次登录会自动获得个人分组而非 403 | 关闭 |
| `AUDIT_RETENTION_DAYS` | 定时清理时审计日志的保留天数 | `90` |
| `DISCORD_PUBLIC_KEY` | Discord 应用的公钥(开发者门户获取),交互功能必需 | 未设置时交互返回 401 |
| `DISCORD_APPLICATION_ID` | Discord 应用 ID;省略时自动获取 | 自动获取 |
@@ -58,25 +58,25 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
控制台以 SPA 形式挂在 `/admin`,各标签页可通过 URL 路径直达(`/admin/groups`、`/admin/logs`、`/admin/audit`)。`/admin` 之外且未匹配下方端点的 URL 直接返回 `404`,不会再被吞进控制台。
-| 端点 | 说明 |
-| ------------------------------------- | ------------------------------------ |
-| `GET /admin` | 配置控制台页面 |
-| `GET /admin/login` | 开始 GitHub OAuth 登录 |
-| `GET /admin/logout` | 销毁会话 |
-| `GET /admin/invite?token=…` | 接受分组邀请(浏览器页面) |
-| `GET /admin/api/me` | 当前会话、权限范围、分组和角色 |
-| `GET /admin/api/routes` | 列出路由(按权限过滤) |
-| `PUT /admin/api/routes` | 替换路由(按分组 owner/admin 权限) |
-| `GET /admin/api/groups` | 列出分组 + 当前用户在各组的角色 |
-| `PUT /admin/api/groups` | 替换分组(超管全量;owner 仅自己的组) |
-| `GET /admin/api/groups/:id/routes` | 列出某分组的路由 |
-| `PUT /admin/api/groups/:id/routes` | 替换某分组的路由(owner/admin) |
-| `GET /admin/api/logs` | 发送日志(按可访问路由过滤) |
-| `GET /admin/api/logs/:id` | 单条发送日志(按权限过滤) |
-| `POST /admin/api/groups/:id/invites` | 创建邀请链接(owner) |
-| `GET /admin/api/groups/:id/invites` | 列出待接受邀请(owner) |
-| `DELETE /admin/api/invites/:token` | 撤销邀请(owner) |
-| `GET /admin/api/audit` | 审计日志(按可访问分组过滤) |
+| 端点 | 说明 |
+| ------------------------------------ | -------------------------------------- |
+| `GET /admin` | 配置控制台页面 |
+| `GET /admin/login` | 开始 GitHub OAuth 登录 |
+| `GET /admin/logout` | 销毁会话 |
+| `GET /admin/invite?token=…` | 接受分组邀请(浏览器页面) |
+| `GET /admin/api/me` | 当前会话、权限范围、分组和角色 |
+| `GET /admin/api/routes` | 列出路由(按权限过滤) |
+| `PUT /admin/api/routes` | 替换路由(按分组 owner/admin 权限) |
+| `GET /admin/api/groups` | 列出分组 + 当前用户在各组的角色 |
+| `PUT /admin/api/groups` | 替换分组(超管全量;owner 仅自己的组) |
+| `GET /admin/api/groups/:id/routes` | 列出某分组的路由 |
+| `PUT /admin/api/groups/:id/routes` | 替换某分组的路由(owner/admin) |
+| `GET /admin/api/logs` | 发送日志(按可访问路由过滤) |
+| `GET /admin/api/logs/:id` | 单条发送日志(按权限过滤) |
+| `POST /admin/api/groups/:id/invites` | 创建邀请链接(owner) |
+| `GET /admin/api/groups/:id/invites` | 列出待接受邀请(owner) |
+| `DELETE /admin/api/invites/:token` | 撤销邀请(owner) |
+| `GET /admin/api/audit` | 审计日志(按可访问分组过滤) |
控制台支持新增、编辑、删除和开关路由。保存后立即写入 KV `config:routes` 并使配置缓存失效,下一次 webhook 处理即会生效。
@@ -186,25 +186,25 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
}
```
-| 字段 | 类型 | 必需 | 说明 |
-| ----------- | -------- | ---- | ------------------------------------------------------------------ |
-| `id` | string | 是 | 小写 id(`a-z0-9`、`-`),由每条路由的 `groupId` 引用 |
-| `name` | string | 是 | 可读的分组名称 |
-| `members` | object[] | 否 | `{ login, role }` 列表;角色为 `owner`、`admin` 或 `viewer` |
-| `adminIds` | string[] | 否 | 已废弃的旧字段;存在时按 role 为 `owner` 的成员处理 |
-| `owners` | string[] | 否 | 允许事件进入该分组的组织/用户登录名;为空表示不限制 |
-| `providers` | string[] | 否 | 允许进入该分组的来源平台(`github`、`gitea`);为空表示全部 |
-| `emoji` | boolean | 否 | 是否在该分组消息中显示 emoji(默认 `true`) |
+| 字段 | 类型 | 必需 | 说明 |
+| ----------- | -------- | ---- | ----------------------------------------------------------- |
+| `id` | string | 是 | 小写 id(`a-z0-9`、`-`),由每条路由的 `groupId` 引用 |
+| `name` | string | 是 | 可读的分组名称 |
+| `members` | object[] | 否 | `{ login, role }` 列表;角色为 `owner`、`admin` 或 `viewer` |
+| `adminIds` | string[] | 否 | 已废弃的旧字段;存在时按 role 为 `owner` 的成员处理 |
+| `owners` | string[] | 否 | 允许事件进入该分组的组织/用户登录名;为空表示不限制 |
+| `providers` | string[] | 否 | 允许进入该分组的来源平台(`github`、`gitea`);为空表示全部 |
+| `emoji` | boolean | 否 | 是否在该分组消息中显示 emoji(默认 `true`) |
### 角色
每个分组成员拥有三种角色之一。超级管理员(`ADMIN_USER_IDS`)始终绕过角色限制。
-| 角色 | 查看路由/日志 | 编辑路由 | 管理成员与邀请 | 编辑分组设置 |
-| -------- | ------------- | -------- | -------------- | ------------ |
+| 角色 | 查看路由/日志 | 编辑路由 | 管理成员与邀请 | 编辑分组设置 |
+| -------- | ------------- | -------- | -------------- | ------------------ |
| `owner` | ✓ | ✓ | ✓ | ✓(`owners` 除外) |
-| `admin` | ✓ | ✓ | ✗ | ✗ |
-| `viewer` | ✓(只读) | ✗ | ✗ | ✗ |
+| `admin` | ✓ | ✓ | ✗ | ✗ |
+| `viewer` | ✓(只读) | ✗ | ✗ | ✗ |
### 权限模型
@@ -264,7 +264,7 @@ owner(及超级管理员)可在分组的「成员」面板创建一次性邀
| `token-reverse:{sha256}` | 用于按 Token 反查的用户 id | 0.9 × Token 有效期 |
| `state:{hex}` | `{ redirectTo, expiresAt, discordUserId?, telegramUserId?, telegramChatId? }` | 600 秒 |
| `invite:{token}` | `{ groupId, role, expiresAt, createdBy, note? }` | 7 天 |
-| `invite:group:{id}` | 每组的 Token 索引(保证邀请列表一致性) | 永久 |
+| `invite:group:{id}` | 每组的 Token 索引(保证邀请列表一致性) | 永久 |
| `delivery:{id}` | Webhook 投递 id(去重标记) | 300 秒 |
| `msg:{routeId}:{key}:{target}` | 原地更新用消息 id 追踪(如 `workflow_run`) | 7 天 |
| `cmd:guild:{id}` | 已注册命令的服务器 id(去重标记) | 永久 |
diff --git a/src/__tests__/invites.test.ts b/src/__tests__/invites.test.ts
index 41943e7..ea17d27 100644
--- a/src/__tests__/invites.test.ts
+++ b/src/__tests__/invites.test.ts
@@ -57,22 +57,42 @@ describe("invites", () => {
});
it("lists pending invites of a group only", async () => {
- await createInvite(kv, { groupId: "team", role: "viewer", expiresAt: Date.now() + 86400_000, createdBy: "boss" });
- await createInvite(kv, { groupId: "other", role: "viewer", expiresAt: Date.now() + 86400_000, createdBy: "boss" });
+ await createInvite(kv, {
+ groupId: "team",
+ role: "viewer",
+ expiresAt: Date.now() + 86400_000,
+ createdBy: "boss",
+ });
+ await createInvite(kv, {
+ groupId: "other",
+ role: "viewer",
+ expiresAt: Date.now() + 86400_000,
+ createdBy: "boss",
+ });
const invites = await listInvites(kv, "team");
expect(invites).toHaveLength(1);
expect(invites[0]!.groupId).toBe("team");
});
it("revokes an invite", async () => {
- const token = await createInvite(kv, { groupId: "team", role: "viewer", expiresAt: Date.now() + 86400_000, createdBy: "boss" });
+ const token = await createInvite(kv, {
+ groupId: "team",
+ role: "viewer",
+ expiresAt: Date.now() + 86400_000,
+ createdBy: "boss",
+ });
await revokeInvite(kv, token);
expect(await getInvite(kv, token)).toBeNull();
});
it("accept adds the user as a member and consumes the token", async () => {
await saveGroups(kv, [group]);
- const token = await createInvite(kv, { groupId: "team", role: "admin", expiresAt: Date.now() + 86400_000, createdBy: "boss" });
+ const token = await createInvite(kv, {
+ groupId: "team",
+ role: "admin",
+ expiresAt: Date.now() + 86400_000,
+ createdBy: "boss",
+ });
const result = await acceptInvite(kv, token, "777", "newbie");
expect(result).toEqual({ ok: true, groupId: "team", role: "admin" });
expect(await getInvite(kv, token)).toBeNull();
@@ -84,9 +104,20 @@ describe("invites", () => {
it("upgrades an existing viewer to admin", async () => {
await saveGroups(kv, [
- { ...group, members: [{ login: "boss", role: "owner" }, { login: "newbie", role: "viewer" }] },
+ {
+ ...group,
+ members: [
+ { login: "boss", role: "owner" },
+ { login: "newbie", role: "viewer" },
+ ],
+ },
]);
- const token = await createInvite(kv, { groupId: "team", role: "admin", expiresAt: Date.now() + 86400_000, createdBy: "boss" });
+ const token = await createInvite(kv, {
+ groupId: "team",
+ role: "admin",
+ expiresAt: Date.now() + 86400_000,
+ createdBy: "boss",
+ });
await acceptInvite(kv, token, "777", "newbie");
const groups = await loadGroups(kv);
expect(groups[0]!.members).toContainEqual({ login: "newbie", role: "admin" });
@@ -94,14 +125,24 @@ describe("invites", () => {
it("rejects expired or unknown invites", async () => {
await saveGroups(kv, [group]);
- const token = await createInvite(kv, { groupId: "team", role: "viewer", expiresAt: Date.now() - 1000, createdBy: "boss" });
+ const token = await createInvite(kv, {
+ groupId: "team",
+ role: "viewer",
+ expiresAt: Date.now() - 1000,
+ createdBy: "boss",
+ });
expect(await acceptInvite(kv, token, "1", "x")).toEqual({ ok: false, reason: "invalid" });
expect(await acceptInvite(kv, "deadbeef", "1", "x")).toEqual({ ok: false, reason: "invalid" });
});
it("rejects invites for missing groups", async () => {
await saveGroups(kv, []);
- const token = await createInvite(kv, { groupId: "ghost", role: "viewer", expiresAt: Date.now() + 86400_000, createdBy: "boss" });
+ const token = await createInvite(kv, {
+ groupId: "ghost",
+ role: "viewer",
+ expiresAt: Date.now() + 86400_000,
+ createdBy: "boss",
+ });
expect(await acceptInvite(kv, token, "1", "x")).toEqual({ ok: false, reason: "group-missing" });
});
});
diff --git a/src/lib/audit.ts b/src/lib/audit.ts
index 9aac8aa..8268f80 100644
--- a/src/lib/audit.ts
+++ b/src/lib/audit.ts
@@ -15,7 +15,8 @@ export interface AuditEntry {
ip?: string;
}
-const COLUMNS = "id, ts, actor_id, actor_login, action, target_type, target_id, group_id, detail, ip";
+const COLUMNS =
+ "id, ts, actor_id, actor_login, action, target_type, target_id, group_id, detail, ip";
interface AuditRow {
id: number;
diff --git a/src/web/action-routes.ts b/src/web/action-routes.ts
index e8ec295..dbbe7fa 100644
--- a/src/web/action-routes.ts
+++ b/src/web/action-routes.ts
@@ -148,14 +148,7 @@ export function createActionRoutes(): Hono {
repo: body.repo,
issue_number: body.issueNumber,
content: body.reaction as
- | "+1"
- | "-1"
- | "laugh"
- | "confused"
- | "heart"
- | "hooray"
- | "rocket"
- | "eyes",
+ "+1" | "-1" | "laugh" | "confused" | "heart" | "hooray" | "rocket" | "eyes",
});
} catch (err) {
log.error({ err }, "Failed to create reaction");
diff --git a/src/web/admin-routes.ts b/src/web/admin-routes.ts
index 8dc0ec7..253abb3 100644
--- a/src/web/admin-routes.ts
+++ b/src/web/admin-routes.ts
@@ -292,10 +292,7 @@ function ownerCount(members: GroupMember[]): number {
}
/** Route params are always present for matched paths; keeps Hono's loose typing honest. */
-function param(
- c: { req: { param: (name: string) => string | undefined } },
- name: string,
-): string {
+function param(c: { req: { param: (name: string) => string | undefined } }, name: string): string {
return c.req.param(name) ?? "";
}
@@ -329,7 +326,9 @@ export function createAdminRoutes(): Hono {
if (!token) return c.redirect("/admin");
const auth = c.get("auth");
if (!auth) {
- return c.redirect(`/auth/github?redirect=${encodeURIComponent(`/admin/invite?token=${token}`)}`);
+ return c.redirect(
+ `/auth/github?redirect=${encodeURIComponent(`/admin/invite?token=${token}`)}`,
+ );
}
const result = await acceptInvite(c.env.KV, token, auth.session.userId, auth.session.login);
if (result.ok) {
@@ -411,10 +410,7 @@ export function createAdminRoutes(): Hono {
);
const otherOwner = ownerCount(members) > 1;
if (!stillMine && !otherOwner) {
- return c.json(
- { error: `group "${g.id}" would be left without an owner by you` },
- 403,
- );
+ return c.json({ error: `group "${g.id}" would be left without an owner by you` }, 403);
}
}
nextAll = [
@@ -453,7 +449,8 @@ export function createAdminRoutes(): Hono {
if (prev.emoji !== g.emoji) fields.push("emoji");
if (!deepEqual(prev.providers ?? [], g.providers ?? [])) fields.push("providers");
if (!deepEqual(prev.owners ?? [], g.owners ?? [])) fields.push("owners");
- if (!deepEqual(prev.members ?? normalizeGroupMembers(prev), g.members)) fields.push("members");
+ if (!deepEqual(prev.members ?? normalizeGroupMembers(prev), g.members))
+ fields.push("members");
if (fields.length > 0) {
await recordAudit(c.env.DB, {
ts: Date.now(),
diff --git a/src/web/auth.ts b/src/web/auth.ts
index 3fb3868..6d90837 100644
--- a/src/web/auth.ts
+++ b/src/web/auth.ts
@@ -62,9 +62,7 @@ export function currentAuth(c: Context): AuthContext {
return c.get(AUTH_KEY);
}
-export type GroupAccess =
- | { ok: true; group: Group }
- | { ok: false; status: 403 | 404 };
+export type GroupAccess = { ok: true; group: Group } | { ok: false; status: 403 | 404 };
/** Resolves the group and checks the user can at least view it. */
export function requireGroup(c: Context, groupId: string): GroupAccess {
@@ -95,7 +93,9 @@ export function requireGroupRole(
export { canEditGroup, canEditRoutes, roleAt };
/** Best-effort client IP for audit entries (Cloudflare header first). */
-export function clientIp(c: { req: { header: (n: string) => string | undefined } }): string | undefined {
+export function clientIp(c: {
+ req: { header: (n: string) => string | undefined };
+}): string | undefined {
return c.req.header("cf-connecting-ip") ?? c.req.header("x-forwarded-for")?.split(",")[0]?.trim();
}