mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
merge: resolve docs conflict with origin/main (auto-fix)
Merged the remote lint/formatting fix; kept the group-level lang row in the zh group schema table.
This commit is contained in:
commit
13ba1e019e
12 changed files with 151 additions and 88 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -73,11 +73,7 @@
|
|||
|
||||
<section v-if="!groupRoutesLoading && !groupRoutes.length" class="empty">
|
||||
<p>{{ t("routes.emptyGroup") }}</p>
|
||||
<button
|
||||
v-if="canEditRoutes(selectedGroup.id)"
|
||||
class="btn btn-accent"
|
||||
@click="openNew"
|
||||
>
|
||||
<button v-if="canEditRoutes(selectedGroup.id)" class="btn btn-accent" @click="openNew">
|
||||
{{ t("routes.createFirst") }}
|
||||
</button>
|
||||
</section>
|
||||
|
|
@ -134,7 +130,9 @@
|
|||
<div class="card-title">
|
||||
<span class="route-name">{{ g.name || t("route.untitled") }}</span>
|
||||
<span class="route-id">{{ g.id }}</span>
|
||||
<span v-if="roleOf(g.id)" class="badge lang">{{ t("role.badge", { role: t("roles." + roleOf(g.id)) }) }}</span>
|
||||
<span v-if="roleOf(g.id)" class="badge lang">{{
|
||||
t("role.badge", { role: t("roles." + roleOf(g.id)) })
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="canEditGroup(g.id)" class="card-actions" @click.stop>
|
||||
<button
|
||||
|
|
@ -150,7 +148,9 @@
|
|||
<div class="target">
|
||||
<span
|
||||
><b>{{ t("groups.members") }}</b
|
||||
><code>{{ (g.members ?? []).length || (g.adminIds || []).length || "—" }}</code></span
|
||||
><code>{{
|
||||
(g.members ?? []).length || (g.adminIds || []).length || "—"
|
||||
}}</code></span
|
||||
>
|
||||
<span
|
||||
><b>{{ t("groups.owners") }}</b
|
||||
|
|
@ -249,7 +249,12 @@ if (view.value === null) {
|
|||
}
|
||||
|
||||
const { logs, loading: logsLoading, load: loadLogs } = useSendLogs();
|
||||
const { entries: auditEntries, loading: auditLoading, error: auditError, load: loadAudit } = useAuditApi();
|
||||
const {
|
||||
entries: auditEntries,
|
||||
loading: auditLoading,
|
||||
error: auditError,
|
||||
load: loadAudit,
|
||||
} = useAuditApi();
|
||||
const {
|
||||
groups,
|
||||
isSuper,
|
||||
|
|
|
|||
|
|
@ -127,7 +127,12 @@ import type { Group } from "~/types";
|
|||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{ open: boolean; group: Group | null; saving: boolean; superAdmin?: boolean }>();
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
group: Group | null;
|
||||
saving: boolean;
|
||||
superAdmin?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
(e: "save", group: Group): void;
|
||||
|
|
|
|||
|
|
@ -38,7 +38,11 @@
|
|||
>
|
||||
↓
|
||||
</button>
|
||||
<button class="icon-btn" :title="t('routeEditor.editTitle')" @click="$emit('edit', route)">
|
||||
<button
|
||||
class="icon-btn"
|
||||
:title="t('routeEditor.editTitle')"
|
||||
@click="$emit('edit', route)"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -87,7 +91,12 @@ import { fmtMatch } from "~/types";
|
|||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{ route: Route; atFirst?: boolean; atLast?: boolean; readonly?: boolean }>();
|
||||
const props = defineProps<{
|
||||
route: Route;
|
||||
atFirst?: boolean;
|
||||
atLast?: boolean;
|
||||
readonly?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: "toggle", route: Route): void;
|
||||
(e: "edit", route: Route): void;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 处理即会生效。
|
||||
|
||||
|
|
@ -200,11 +200,11 @@ WebHooker 内置了位于 `/admin` 的配置控制台,可在浏览器中管理
|
|||
|
||||
每个分组成员拥有三种角色之一。超级管理员(`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(去重标记) | 永久 |
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -148,14 +148,7 @@ export function createActionRoutes(): Hono<AuthEnv> {
|
|||
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");
|
||||
|
|
|
|||
|
|
@ -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<AuthEnv> {
|
|||
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<AuthEnv> {
|
|||
);
|
||||
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<AuthEnv> {
|
|||
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(),
|
||||
|
|
|
|||
|
|
@ -62,9 +62,7 @@ export function currentAuth(c: Context<AuthEnv>): 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<AuthEnv>, 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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue