mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat: migrate to Nuxt 4 (Nitro) and Tailwind CSS v3
This commit is contained in:
parent
f4959eebf8
commit
b139712a91
166 changed files with 19790 additions and 5539 deletions
168
server/lib/web/actions.ts
Normal file
168
server/lib/web/actions.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import type { H3Event } from "h3";
|
||||
import { readBody, setResponseStatus } from "h3";
|
||||
import { getUserOctokit } from "../github/oauth";
|
||||
import { bearerUserId } from "./auth";
|
||||
import { cfEnv } from "../cf";
|
||||
import { log } from "../lib/log";
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
function isValidId(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
async function readJsonBody(event: H3Event): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
return (await readBody(event)) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function userOctokit(event: H3Event, userId: string): Promise<Awaited<ReturnType<typeof getUserOctokit>>> {
|
||||
return getUserOctokit(userId, cfEnv(event).KV);
|
||||
}
|
||||
|
||||
function fail(event: H3Event, status: number, error: string): Record<string, unknown> {
|
||||
setResponseStatus(event, status);
|
||||
return { error };
|
||||
}
|
||||
|
||||
/** POST /api/comment */
|
||||
export async function apiComment(event: H3Event): Promise<Record<string, unknown>> {
|
||||
const userId = await bearerUserId(event);
|
||||
const body = await readJsonBody(event);
|
||||
if (
|
||||
!body ||
|
||||
!isNonEmptyString(body.owner) ||
|
||||
!isNonEmptyString(body.repo) ||
|
||||
!isValidId(body.issueNumber) ||
|
||||
!isNonEmptyString(body.body)
|
||||
) {
|
||||
return fail(event, 400, "Invalid request body");
|
||||
}
|
||||
const octokit = await userOctokit(event, userId);
|
||||
if (!octokit) return fail(event, 401, "Not authorized");
|
||||
try {
|
||||
await octokit.rest.issues.createComment({
|
||||
owner: body.owner,
|
||||
repo: body.repo,
|
||||
issue_number: body.issueNumber,
|
||||
body: body.body,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error({ err }, "Failed to create comment");
|
||||
return fail(event, 500, "GitHub API error");
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** POST /api/merge */
|
||||
export async function apiMerge(event: H3Event): Promise<Record<string, unknown>> {
|
||||
const userId = await bearerUserId(event);
|
||||
const body = await readJsonBody(event);
|
||||
if (
|
||||
!body ||
|
||||
!isNonEmptyString(body.owner) ||
|
||||
!isNonEmptyString(body.repo) ||
|
||||
!isValidId(body.pullNumber)
|
||||
) {
|
||||
return fail(event, 400, "Invalid request body");
|
||||
}
|
||||
const method = body.method === undefined ? "squash" : body.method;
|
||||
if (method !== "merge" && method !== "squash" && method !== "rebase") {
|
||||
return fail(event, 400, "Invalid request body");
|
||||
}
|
||||
const octokit = await userOctokit(event, userId);
|
||||
if (!octokit) return fail(event, 401, "Not authorized");
|
||||
try {
|
||||
await octokit.rest.pulls.merge({
|
||||
owner: body.owner,
|
||||
repo: body.repo,
|
||||
pull_number: body.pullNumber,
|
||||
merge_method: method,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error({ err }, "Failed to merge pull request");
|
||||
return fail(event, 500, "GitHub API error");
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** POST /api/close */
|
||||
export async function apiClose(event: H3Event): Promise<Record<string, unknown>> {
|
||||
const userId = await bearerUserId(event);
|
||||
const body = await readJsonBody(event);
|
||||
if (
|
||||
!body ||
|
||||
!isNonEmptyString(body.owner) ||
|
||||
!isNonEmptyString(body.repo) ||
|
||||
!isValidId(body.pullNumber)
|
||||
) {
|
||||
return fail(event, 400, "Invalid request body");
|
||||
}
|
||||
const octokit = await userOctokit(event, userId);
|
||||
if (!octokit) return fail(event, 401, "Not authorized");
|
||||
try {
|
||||
await octokit.rest.pulls.update({
|
||||
owner: body.owner,
|
||||
repo: body.repo,
|
||||
pull_number: body.pullNumber,
|
||||
state: "closed",
|
||||
});
|
||||
} catch (err) {
|
||||
log.error({ err }, "Failed to close pull request");
|
||||
return fail(event, 500, "GitHub API error");
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** POST /api/react */
|
||||
export async function apiReact(event: H3Event): Promise<Record<string, unknown>> {
|
||||
const userId = await bearerUserId(event);
|
||||
const body = await readJsonBody(event);
|
||||
const reactions = [
|
||||
"+1",
|
||||
"-1",
|
||||
"laugh",
|
||||
"confused",
|
||||
"heart",
|
||||
"hooray",
|
||||
"rocket",
|
||||
"eyes",
|
||||
] as const;
|
||||
if (
|
||||
!body ||
|
||||
!isNonEmptyString(body.owner) ||
|
||||
!isNonEmptyString(body.repo) ||
|
||||
!isValidId(body.issueNumber) ||
|
||||
!isNonEmptyString(body.reaction) ||
|
||||
!(reactions as readonly string[]).includes(body.reaction)
|
||||
) {
|
||||
return fail(event, 400, "Invalid request body");
|
||||
}
|
||||
const octokit = await userOctokit(event, userId);
|
||||
if (!octokit) return fail(event, 401, "Not authorized");
|
||||
try {
|
||||
await octokit.rest.reactions.createForIssue({
|
||||
owner: body.owner,
|
||||
repo: body.repo,
|
||||
issue_number: body.issueNumber,
|
||||
content: body.reaction as
|
||||
| "+1"
|
||||
| "-1"
|
||||
| "laugh"
|
||||
| "confused"
|
||||
| "heart"
|
||||
| "hooray"
|
||||
| "rocket"
|
||||
| "eyes",
|
||||
});
|
||||
} catch (err) {
|
||||
log.error({ err }, "Failed to create reaction");
|
||||
return fail(event, 500, "GitHub API error");
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
948
server/lib/web/admin.ts
Normal file
948
server/lib/web/admin.ts
Normal file
|
|
@ -0,0 +1,948 @@
|
|||
import type { H3Event } from "h3";
|
||||
import {
|
||||
getHeader,
|
||||
getQuery,
|
||||
readBody,
|
||||
sendRedirect,
|
||||
setResponseHeader,
|
||||
setResponseStatus,
|
||||
} from "h3";
|
||||
import type { Route, Group, GroupMember, GroupRole } from "../types";
|
||||
import { loadRoutes, saveRoutes } from "../config";
|
||||
import { getAdminSession, destroyAdminSession, clearAdminCookie } from "./session";
|
||||
import { saveGroups, loadGroups, identityMatches, normalizeGroupMembers } from "./groups";
|
||||
import {
|
||||
requireAnyAccess,
|
||||
currentAuth,
|
||||
requireGroup,
|
||||
requireGroupRole,
|
||||
roleAt,
|
||||
clientIp,
|
||||
type GroupAccess,
|
||||
} from "./auth";
|
||||
import { getSendLog, getSendLogById } from "../lib/send-log";
|
||||
import { getAuditLog, recordAudit } from "../lib/audit";
|
||||
import {
|
||||
createInvite,
|
||||
listInvites,
|
||||
revokeInvite,
|
||||
getInvite,
|
||||
acceptInvite,
|
||||
migrateInvites,
|
||||
} from "./invites";
|
||||
import { getTenantSecret, setTenantSecret, deleteTenantSecret } from "./tenants";
|
||||
import { cfEnv } from "../cf";
|
||||
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-]*$/;
|
||||
|
||||
function isValidMatch(match: unknown): match is string | string[] {
|
||||
if (typeof match === "string") return match.trim().length > 0;
|
||||
if (Array.isArray(match))
|
||||
return match.length > 0 && match.every((m) => typeof m === "string" && m.trim().length > 0);
|
||||
return false;
|
||||
}
|
||||
|
||||
function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (typeof a !== typeof b || a === null || b === null) return false;
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
||||
return a.every((x, i) => deepEqual(x, b[i]));
|
||||
}
|
||||
if (typeof a === "object" && typeof b === "object") {
|
||||
const ao = a as Record<string, unknown>;
|
||||
const bo = b as Record<string, unknown>;
|
||||
const ak = Object.keys(ao);
|
||||
const bk = Object.keys(bo);
|
||||
if (ak.length !== bk.length) return false;
|
||||
return ak.every((k) => Object.prototype.hasOwnProperty.call(bo, k) && deepEqual(ao[k], bo[k]));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the submitted routes. Routes that are byte-for-byte identical to an
|
||||
* entry in `unchanged` (keyed by id) skip the full content check, so a pre-existing
|
||||
* incomplete route can never block edits to a different route. Only new or modified
|
||||
* routes are fully validated. Structural checks (id shape, uniqueness) still run for all.
|
||||
*/
|
||||
function validateRoutes(
|
||||
routes: unknown,
|
||||
unchanged?: Map<string, Route>,
|
||||
): { ok: true; routes: Route[] } | { ok: false; error: string } {
|
||||
if (!Array.isArray(routes)) return { ok: false, error: "routes must be an array" };
|
||||
if (routes.length > 200) return { ok: false, error: "too many routes" };
|
||||
|
||||
const seenByGroup = new Map<string, Set<string>>();
|
||||
for (let i = 0; i < routes.length; i++) {
|
||||
const r = routes[i] as Record<string, unknown>;
|
||||
if (!r || typeof r !== "object") return { ok: false, error: `route[${i}] is not an object` };
|
||||
if (typeof r.id !== "string" || !ID_RE.test(r.id)) {
|
||||
return { ok: false, error: `route[${i}].id is invalid` };
|
||||
}
|
||||
const gid = (r.groupId as string) ?? "__nogroup__";
|
||||
let groupSeen = seenByGroup.get(gid);
|
||||
if (!groupSeen) {
|
||||
groupSeen = new Set();
|
||||
seenByGroup.set(gid, groupSeen);
|
||||
}
|
||||
if (groupSeen.has(r.id)) {
|
||||
return { ok: false, error: `duplicate route id "${r.id}" in group "${gid}"` };
|
||||
}
|
||||
groupSeen.add(r.id);
|
||||
// Skip full validation for routes that are unchanged from what is stored.
|
||||
const prev = unchanged?.get(r.id);
|
||||
if (prev && deepEqual(r, prev)) continue;
|
||||
if (typeof r.name !== "string" || r.name.trim().length === 0) {
|
||||
return { ok: false, error: `route "${r.id}" needs a name` };
|
||||
}
|
||||
if (typeof r.groupId !== "string" || r.groupId.trim().length === 0) {
|
||||
return { ok: false, error: `route "${r.id}" needs a group` };
|
||||
}
|
||||
if (typeof r.enabled !== "boolean")
|
||||
return { ok: false, error: `route "${r.id}".enabled must be boolean` };
|
||||
if (r.fallback !== undefined && typeof r.fallback !== "boolean") {
|
||||
return { ok: false, error: `route "${r.id}".fallback must be a boolean` };
|
||||
}
|
||||
if (r.stop !== undefined && typeof r.stop !== "boolean") {
|
||||
return { ok: false, error: `route "${r.id}".stop must be a boolean` };
|
||||
}
|
||||
if (
|
||||
r.discordRoleIds !== undefined &&
|
||||
(!Array.isArray(r.discordRoleIds) ||
|
||||
!r.discordRoleIds.every((d) => typeof d === "string" && d.trim().length > 0))
|
||||
) {
|
||||
return { ok: false, error: `route "${r.id}".discordRoleIds must be a list of strings` };
|
||||
}
|
||||
if (!Array.isArray(r.filters)) {
|
||||
return { ok: false, error: `route "${r.id}".filters must be an array` };
|
||||
}
|
||||
if (r.fallback !== true && r.filters.length === 0) {
|
||||
return { ok: false, error: `route "${r.id}" needs at least one filter` };
|
||||
}
|
||||
for (let j = 0; j < r.filters.length; j++) {
|
||||
const f = r.filters[j] as Record<string, unknown>;
|
||||
if (!f || typeof f !== "object")
|
||||
return { ok: false, error: `route "${r.id}" filter[${j}] invalid` };
|
||||
if (!VALID_FILTER_TYPES.has(f.type as string)) {
|
||||
return { ok: false, error: `route "${r.id}" filter[${j}] has unknown type` };
|
||||
}
|
||||
if (!isValidMatch(f.match)) {
|
||||
return { ok: false, error: `route "${r.id}" filter[${j}] needs a match value` };
|
||||
}
|
||||
if (f.exclude !== undefined && typeof f.exclude !== "boolean") {
|
||||
return { ok: false, error: `route "${r.id}" filter[${j}].exclude must be boolean` };
|
||||
}
|
||||
}
|
||||
const rawTarget = r.target as Record<string, unknown> | undefined;
|
||||
const rawTargets = r.targets as unknown;
|
||||
if (rawTargets === undefined && rawTarget && typeof rawTarget === "object") {
|
||||
const legacy = validateTarget(`route "${r.id}"`, rawTarget);
|
||||
if (!legacy.ok) return legacy;
|
||||
(r as Record<string, unknown>).targets = [legacy.target];
|
||||
delete (r as Record<string, unknown>).target;
|
||||
} else if (Array.isArray(rawTargets)) {
|
||||
if (rawTargets.length === 0) {
|
||||
return { ok: false, error: `route "${r.id}" needs at least one target` };
|
||||
}
|
||||
const normalized: Route["targets"] = [];
|
||||
for (let j = 0; j < rawTargets.length; j++) {
|
||||
const t = rawTargets[j] as Record<string, unknown>;
|
||||
if (!t || typeof t !== "object") {
|
||||
return { ok: false, error: `route "${r.id}".targets[${j}] is not an object` };
|
||||
}
|
||||
const result = validateTarget(`route "${r.id}".targets[${j}]`, t);
|
||||
if (!result.ok) return result;
|
||||
normalized.push(result.target);
|
||||
}
|
||||
(r as Record<string, unknown>).targets = normalized;
|
||||
} else {
|
||||
return { ok: false, error: `route "${r.id}" needs a targets array` };
|
||||
}
|
||||
}
|
||||
return { ok: true, routes: routes as Route[] };
|
||||
}
|
||||
|
||||
function validateTarget(
|
||||
label: string,
|
||||
target: Record<string, unknown>,
|
||||
): { ok: true; target: Route["targets"][number] } | { ok: false; error: string } {
|
||||
const platform = target.platform === undefined ? "discord" : target.platform;
|
||||
if (platform !== "discord" && platform !== "telegram") {
|
||||
return { ok: false, error: `${label}.platform must be "discord" or "telegram"` };
|
||||
}
|
||||
if (platform === "telegram") {
|
||||
if (typeof target.chatId !== "string" || target.chatId.trim().length === 0)
|
||||
return { ok: false, error: `${label}.chatId is required` };
|
||||
if (target.topicId !== undefined && typeof target.topicId !== "string") {
|
||||
return { ok: false, error: `${label}.topicId must be a string` };
|
||||
}
|
||||
} else {
|
||||
if (typeof target.channelId !== "string" || target.channelId.trim().length === 0)
|
||||
return { ok: false, error: `${label}.channelId is required` };
|
||||
if (target.threadId !== undefined && typeof target.threadId !== "string") {
|
||||
return { ok: false, error: `${label}.threadId must be a string` };
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
target: {
|
||||
platform,
|
||||
channelId: platform === "telegram" ? undefined : (target.channelId as string),
|
||||
threadId: platform === "telegram" ? undefined : ((target.threadId as string) ?? undefined),
|
||||
chatId: platform === "telegram" ? (target.chatId as string) : undefined,
|
||||
topicId: platform === "telegram" ? ((target.topicId as string) ?? undefined) : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateMembers(
|
||||
g: Record<string, unknown>,
|
||||
gid: string,
|
||||
): { ok: true; members: GroupMember[] } | { ok: false; error: string } {
|
||||
const raw = g.members;
|
||||
if (raw === undefined || raw === null) {
|
||||
// Legacy payload without members: derive owners from adminIds.
|
||||
const adminIds = g.adminIds;
|
||||
if (!Array.isArray(adminIds)) {
|
||||
return { ok: false, error: `group "${gid}" needs a members list` };
|
||||
}
|
||||
if (!adminIds.every((a) => typeof a === "string" && a.trim().length > 0)) {
|
||||
return { ok: false, error: `group "${gid}".adminIds must be a list of strings` };
|
||||
}
|
||||
const members: GroupMember[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const a of adminIds as string[]) {
|
||||
const login = a.trim();
|
||||
if (!login || seen.has(login.toLowerCase())) continue;
|
||||
seen.add(login.toLowerCase());
|
||||
members.push({ login, role: "owner" });
|
||||
}
|
||||
return { ok: true, members };
|
||||
}
|
||||
if (!Array.isArray(raw)) return { ok: false, error: `group "${gid}".members must be an array` };
|
||||
const seen = new Set<string>();
|
||||
const members: GroupMember[] = [];
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const m = raw[i] as Record<string, unknown>;
|
||||
if (!m || typeof m !== "object")
|
||||
return { ok: false, error: `group "${gid}".members[${i}] is not an object` };
|
||||
const login = typeof m.login === "string" ? m.login.trim() : "";
|
||||
if (!login) return { ok: false, error: `group "${gid}".members[${i}].login is required` };
|
||||
const role = m.role;
|
||||
if (role !== "owner" && role !== "admin" && role !== "viewer") {
|
||||
return {
|
||||
ok: false,
|
||||
error: `group "${gid}".members[${i}].role must be "owner" | "admin" | "viewer"`,
|
||||
};
|
||||
}
|
||||
if (seen.has(login.toLowerCase())) {
|
||||
return { ok: false, error: `group "${gid}" has duplicate member "${login}"` };
|
||||
}
|
||||
seen.add(login.toLowerCase());
|
||||
members.push({ login, role });
|
||||
}
|
||||
if (members.length > 0 && !members.some((m) => m.role === "owner")) {
|
||||
return { ok: false, error: `group "${gid}" needs at least one owner` };
|
||||
}
|
||||
return { ok: true, members };
|
||||
}
|
||||
|
||||
export function validateGroups(
|
||||
groups: unknown,
|
||||
): { ok: true; groups: Group[] } | { ok: false; error: string } {
|
||||
if (!Array.isArray(groups)) return { ok: false, error: "groups must be an array" };
|
||||
if (groups.length > 100) return { ok: false, error: "too many groups" };
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
const g = groups[i] as Record<string, unknown>;
|
||||
if (!g || typeof g !== "object") return { ok: false, error: `group[${i}] is not an object` };
|
||||
if (typeof g.id !== "string" || !ID_RE.test(g.id)) {
|
||||
return { ok: false, error: `group[${i}].id is invalid` };
|
||||
}
|
||||
if (seen.has(g.id)) return { ok: false, error: `duplicate group id "${g.id}"` };
|
||||
seen.add(g.id);
|
||||
if (typeof g.name !== "string" || g.name.trim().length === 0) {
|
||||
return { ok: false, error: `group "${g.id}" needs a name` };
|
||||
}
|
||||
const mres = validateMembers(g, g.id);
|
||||
if (!mres.ok) return mres;
|
||||
// `members` is the single source of truth; adminIds stays in sync so
|
||||
// legacy consumers (isGroupAdmin, older UI) keep working.
|
||||
g.members = mres.members;
|
||||
g.adminIds = mres.members.filter((m) => m.role === "owner").map((m) => m.login);
|
||||
if (
|
||||
g.owners !== undefined &&
|
||||
(!Array.isArray(g.owners) ||
|
||||
!g.owners.every((o) => typeof o === "string" && o.trim().length > 0))
|
||||
) {
|
||||
return { ok: false, error: `group "${g.id}".owners must be a list of strings` };
|
||||
}
|
||||
if (
|
||||
g.providers !== undefined &&
|
||||
(!Array.isArray(g.providers) ||
|
||||
!g.providers.every(
|
||||
(p) => typeof p === "string" && ["github", "gitea", "gitlab"].includes(p),
|
||||
))
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `group "${g.id}".providers must be a list of "github" | "gitea" | "gitlab"`,
|
||||
};
|
||||
}
|
||||
if (g.installationId !== undefined && g.installationId !== null) {
|
||||
if (typeof g.installationId !== "number" || !Number.isInteger(g.installationId)) {
|
||||
return { ok: false, error: `group "${g.id}".installationId must be an integer` };
|
||||
}
|
||||
} else {
|
||||
delete g.installationId;
|
||||
}
|
||||
if (g.emoji !== undefined && typeof g.emoji !== "boolean") {
|
||||
return { ok: false, error: `group "${g.id}".emoji must be a boolean` };
|
||||
}
|
||||
if (g.lang !== undefined && typeof g.lang !== "string") {
|
||||
return { ok: false, error: `group "${g.id}".lang must be a string` };
|
||||
}
|
||||
if (g.logTarget !== undefined && g.logTarget !== null) {
|
||||
if (typeof g.logTarget !== "object" || Array.isArray(g.logTarget)) {
|
||||
return { ok: false, error: `group "${g.id}".logTarget must be an object` };
|
||||
}
|
||||
const tgt = validateTarget(
|
||||
`group "${g.id}".logTarget`,
|
||||
g.logTarget as Record<string, unknown>,
|
||||
);
|
||||
if (!tgt.ok) return tgt;
|
||||
g.logTarget = tgt.target;
|
||||
} else {
|
||||
delete g.logTarget;
|
||||
}
|
||||
}
|
||||
return { ok: true, groups: groups as Group[] };
|
||||
}
|
||||
|
||||
function ownerCount(members: GroupMember[]): number {
|
||||
return members.filter((m) => m.role === "owner").length;
|
||||
}
|
||||
|
||||
function respondError(event: H3Event, status: number, error: string): Record<string, unknown> {
|
||||
setResponseStatus(event, status);
|
||||
return { error };
|
||||
}
|
||||
|
||||
function accessError(
|
||||
event: H3Event,
|
||||
access: Extract<GroupAccess, { ok: false }>,
|
||||
): Record<string, unknown> {
|
||||
return respondError(
|
||||
event,
|
||||
access.status,
|
||||
access.status === 404 ? "Group not found" : "Forbidden",
|
||||
);
|
||||
}
|
||||
|
||||
/** Read a JSON body, returning null when it is not valid JSON. */
|
||||
async function readJsonBody(event: H3Event): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
const body = await readBody(event);
|
||||
return (body ?? {}) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function webhookUrl(event: H3Event, groupId: string): string {
|
||||
const env = cfEnv(event);
|
||||
const origin = env.BASE_URL ?? getRequestOrigin(event);
|
||||
return `${origin.replace(/\/$/, "")}/webhook/${groupId}`;
|
||||
}
|
||||
|
||||
function getRequestOrigin(event: H3Event): string {
|
||||
const proto = getHeader(event, "x-forwarded-proto") ?? "https";
|
||||
const host = getHeader(event, "host") ?? "localhost";
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** GET /admin/login */
|
||||
export async function adminLogin(event: H3Event): Promise<void> {
|
||||
await sendRedirect(event, "/auth/github?redirect=/admin");
|
||||
}
|
||||
|
||||
/** GET /admin/logout */
|
||||
export async function adminLogout(event: H3Event): Promise<void> {
|
||||
const env = cfEnv(event);
|
||||
const session = await getAdminSession(env.KV, getHeader(event, "cookie"));
|
||||
if (session) {
|
||||
await recordAudit(env.DB, {
|
||||
ts: Date.now(),
|
||||
actorId: session.userId,
|
||||
actorLogin: session.login,
|
||||
action: "session.logout",
|
||||
ip: clientIp(event),
|
||||
});
|
||||
}
|
||||
await destroyAdminSession(env.KV, getHeader(event, "cookie"));
|
||||
setResponseHeader(event, "Set-Cookie", clearAdminCookie());
|
||||
await sendRedirect(event, "/admin");
|
||||
}
|
||||
|
||||
/** GET /admin/invite?token=… */
|
||||
export async function adminInvite(event: H3Event): Promise<void> {
|
||||
const env = cfEnv(event);
|
||||
const token = String(getQuery(event)["token"] ?? "");
|
||||
if (!token) {
|
||||
await sendRedirect(event, "/admin");
|
||||
return;
|
||||
}
|
||||
const session = await getAdminSession(env.KV, getHeader(event, "cookie"));
|
||||
if (!session) {
|
||||
await sendRedirect(event, `/auth/github?redirect=${encodeURIComponent(`/admin/invite?token=${token}`)}`);
|
||||
return;
|
||||
}
|
||||
const result = await acceptInvite(env.KV, token, session.userId, session.login);
|
||||
if (result.ok) {
|
||||
await recordAudit(env.DB, {
|
||||
ts: Date.now(),
|
||||
actorId: session.userId,
|
||||
actorLogin: session.login,
|
||||
action: "invite.accept",
|
||||
targetType: "group",
|
||||
targetId: result.groupId,
|
||||
groupId: result.groupId,
|
||||
detail: { role: result.role },
|
||||
ip: clientIp(event),
|
||||
});
|
||||
}
|
||||
await sendRedirect(event, `/admin?invite=${result.ok ? "ok" : result.reason}`);
|
||||
}
|
||||
|
||||
/** GET /admin/api/me */
|
||||
export async function adminApiMe(event: H3Event): Promise<Record<string, unknown>> {
|
||||
const auth = await requireAnyAccess(event);
|
||||
const roles: Record<string, GroupRole> = {};
|
||||
for (const g of auth.scope.groups) {
|
||||
const role = roleAt(auth.scope, g.id);
|
||||
if (role) roles[g.id] = role;
|
||||
}
|
||||
return {
|
||||
login: auth.session.login,
|
||||
userId: auth.session.userId,
|
||||
isSuper: auth.scope.isSuper,
|
||||
groups: auth.scope.groups,
|
||||
roles,
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /admin/api/groups */
|
||||
export async function adminApiGroupsGet(event: H3Event): Promise<Record<string, unknown>> {
|
||||
const auth = await requireAnyAccess(event);
|
||||
const roles: Record<string, GroupRole> = {};
|
||||
for (const g of auth.scope.groups) {
|
||||
const role = roleAt(auth.scope, g.id);
|
||||
if (role) roles[g.id] = role;
|
||||
}
|
||||
return { groups: auth.scope.groups, isSuper: auth.scope.isSuper, roles };
|
||||
}
|
||||
|
||||
/** PUT /admin/api/groups */
|
||||
export async function adminApiGroupsPut(event: H3Event): Promise<Record<string, unknown>> {
|
||||
const auth = await requireAnyAccess(event);
|
||||
const env = cfEnv(event);
|
||||
const body = await readJsonBody(event);
|
||||
if (!body) return respondError(event, 400, "Invalid JSON body");
|
||||
const result = validateGroups(body["groups"]);
|
||||
if (!result.ok) return respondError(event, 400, result.error);
|
||||
|
||||
const existing = await loadGroups(env.KV);
|
||||
const prevById = new Map(existing.map((g) => [g.id, g]));
|
||||
let nextAll: Group[];
|
||||
|
||||
if (auth.scope.isSuper) {
|
||||
// Super admins see and submit every group: full replace.
|
||||
nextAll = result.groups;
|
||||
} else {
|
||||
// Owners may only write groups they own. Preserve every other group and
|
||||
// never let a submission drop the last owner of a group.
|
||||
const mine = new Set<string>();
|
||||
for (const [gid, role] of auth.scope.roles) {
|
||||
if (role === "owner") mine.add(gid);
|
||||
}
|
||||
for (const g of result.groups) {
|
||||
if (!mine.has(g.id)) {
|
||||
return respondError(event, 403, `group "${g.id}" is outside your ownership`);
|
||||
}
|
||||
const members = g.members ?? normalizeGroupMembers(g);
|
||||
const stillMine = members.some(
|
||||
(m) =>
|
||||
m.role === "owner" &&
|
||||
identityMatches([m.login], auth.session.userId, auth.session.login),
|
||||
);
|
||||
const otherOwner = ownerCount(members) > 1;
|
||||
if (!stillMine && !otherOwner) {
|
||||
return respondError(event, 403, `group "${g.id}" would be left without an owner by you`);
|
||||
}
|
||||
}
|
||||
nextAll = [
|
||||
...existing.filter((g) => !mine.has(g.id)),
|
||||
...result.groups.map((g) => {
|
||||
const prev = prevById.get(g.id);
|
||||
if (prev && prev.owners !== undefined && g.owners === undefined) {
|
||||
// Owners cannot edit the `owners` scope; keep the stored value.
|
||||
return { ...g, owners: prev.owners };
|
||||
}
|
||||
return g;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
// Audit every create / update / delete.
|
||||
const prevGroups = existing;
|
||||
const nextById = new Map(nextAll.map((g) => [g.id, g]));
|
||||
const actor = { actorId: auth.session.userId, actorLogin: auth.session.login };
|
||||
for (const g of nextAll) {
|
||||
const prev = prevById.get(g.id);
|
||||
if (!prev) {
|
||||
await recordAudit(env.DB, {
|
||||
ts: Date.now(),
|
||||
...actor,
|
||||
action: "group.create",
|
||||
targetType: "group",
|
||||
targetId: g.id,
|
||||
groupId: g.id,
|
||||
ip: clientIp(event),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const fields: string[] = [];
|
||||
if (prev.name !== g.name) fields.push("name");
|
||||
if (prev.emoji !== g.emoji) fields.push("emoji");
|
||||
if (prev.lang !== g.lang) fields.push("lang");
|
||||
if (!deepEqual(prev.logTarget, g.logTarget)) fields.push("logTarget");
|
||||
if (!deepEqual(prev.providers ?? [], g.providers ?? [])) fields.push("providers");
|
||||
if (prev.installationId !== g.installationId) fields.push("installationId");
|
||||
if (!deepEqual(prev.owners ?? [], g.owners ?? [])) fields.push("owners");
|
||||
if (!deepEqual(prev.members ?? normalizeGroupMembers(prev), g.members)) fields.push("members");
|
||||
if (fields.length > 0) {
|
||||
await recordAudit(env.DB, {
|
||||
ts: Date.now(),
|
||||
...actor,
|
||||
action: "group.update",
|
||||
targetType: "group",
|
||||
targetId: g.id,
|
||||
groupId: g.id,
|
||||
detail: { fields },
|
||||
ip: clientIp(event),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const g of prevGroups) {
|
||||
if (!nextById.has(g.id)) {
|
||||
await recordAudit(env.DB, {
|
||||
ts: Date.now(),
|
||||
...actor,
|
||||
action: "group.delete",
|
||||
targetType: "group",
|
||||
targetId: g.id,
|
||||
groupId: g.id,
|
||||
ip: clientIp(event),
|
||||
});
|
||||
await deleteTenantSecret(env.KV, g.id).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await saveGroups(env.KV, nextAll);
|
||||
} catch (err) {
|
||||
log.error({ err }, "Failed to save groups");
|
||||
return respondError(event, 500, "Failed to save groups");
|
||||
}
|
||||
log.info({ count: nextAll.length }, "Groups updated via admin UI");
|
||||
return { ok: true, count: nextAll.length };
|
||||
}
|
||||
|
||||
/** GET /admin/api/routes */
|
||||
export async function adminApiRoutesGet(event: H3Event): Promise<Record<string, unknown>> {
|
||||
const auth = await requireAnyAccess(event);
|
||||
const env = cfEnv(event);
|
||||
const all = await loadRoutes(env.KV);
|
||||
const routes = auth.scope.isSuper
|
||||
? all
|
||||
: all.filter((r) => r.groupId != null && auth.scope.groupIds.has(r.groupId));
|
||||
return { routes };
|
||||
}
|
||||
|
||||
/** PUT /admin/api/routes */
|
||||
export async function adminApiRoutesPut(event: H3Event): Promise<Record<string, unknown>> {
|
||||
const auth = await requireAnyAccess(event);
|
||||
const env = cfEnv(event);
|
||||
const body = await readJsonBody(event);
|
||||
if (!body) return respondError(event, 400, "Invalid JSON body");
|
||||
const existing = await loadRoutes(env.KV);
|
||||
const unchanged = new Map(existing.map((r) => [r.id, r]));
|
||||
const result = validateRoutes(body["routes"], unchanged);
|
||||
if (!result.ok) return respondError(event, 400, result.error);
|
||||
|
||||
let nextAll: Route[];
|
||||
|
||||
if (auth.scope.isSuper) {
|
||||
// Super admins see and submit every route: full replace.
|
||||
nextAll = result.routes;
|
||||
} else {
|
||||
// Owners and admins may only write routes inside groups they manage.
|
||||
// Reject any submitted route that targets a group they cannot edit,
|
||||
// then splice their groups' routes in place while preserving all others.
|
||||
const writable = new Set<string>();
|
||||
for (const [gid, role] of auth.scope.roles) {
|
||||
if (role === "owner" || role === "admin") writable.add(gid);
|
||||
}
|
||||
for (const r of result.routes) {
|
||||
if (!r.groupId || !writable.has(r.groupId)) {
|
||||
return respondError(event, 403, `route "${r.id}" is outside your groups`);
|
||||
}
|
||||
}
|
||||
nextAll = [
|
||||
...existing.filter((r) => !(r.groupId != null && writable.has(r.groupId))),
|
||||
...result.routes,
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
await saveRoutes(env.KV, nextAll);
|
||||
} catch (err) {
|
||||
log.error({ err }, "Failed to save routes");
|
||||
return respondError(event, 500, "Failed to save routes");
|
||||
}
|
||||
await recordAudit(env.DB, {
|
||||
ts: Date.now(),
|
||||
actorId: auth.session.userId,
|
||||
actorLogin: auth.session.login,
|
||||
action: "routes.update",
|
||||
targetType: "routes",
|
||||
targetId: "all",
|
||||
detail: { count: nextAll.length },
|
||||
ip: clientIp(event),
|
||||
});
|
||||
log.info({ count: nextAll.length }, "Routes updated via admin UI");
|
||||
return { ok: true, count: nextAll.length };
|
||||
}
|
||||
|
||||
/** GET /admin/api/logs */
|
||||
export async function adminApiLogs(event: H3Event): Promise<Record<string, unknown>> {
|
||||
const auth = await requireAnyAccess(event);
|
||||
const env = cfEnv(event);
|
||||
const query = getQuery(event);
|
||||
const limit = Math.min(Math.max(Number(String(query["limit"] ?? "50")), 1), 100);
|
||||
const filterGroupId = String(query["groupId"] ?? "") || undefined;
|
||||
const allLogs = await getSendLog(env.DB, 200);
|
||||
const allowed = auth.scope.isSuper
|
||||
? allLogs
|
||||
: allLogs.filter((l) => l.groupId != null && auth.scope.groupIds.has(l.groupId));
|
||||
const logs = filterGroupId ? allowed.filter((l) => l.groupId === filterGroupId) : allowed;
|
||||
return { logs: logs.slice(0, limit) };
|
||||
}
|
||||
|
||||
/** GET /admin/api/logs/:id */
|
||||
export async function adminApiLogsById(event: H3Event, id: number): Promise<Record<string, unknown>> {
|
||||
const auth = await requireAnyAccess(event);
|
||||
const env = cfEnv(event);
|
||||
if (!Number.isInteger(id) || id <= 0) return respondError(event, 400, "Invalid log id");
|
||||
const entry = await getSendLogById(env.DB, id);
|
||||
if (!entry) return respondError(event, 404, "Log entry not found");
|
||||
if (!auth.scope.isSuper) {
|
||||
if (!entry.groupId || !auth.scope.groupIds.has(entry.groupId)) {
|
||||
return respondError(event, 403, "Forbidden");
|
||||
}
|
||||
}
|
||||
return { log: entry };
|
||||
}
|
||||
|
||||
/** GET /admin/api/groups/:groupId/routes */
|
||||
export async function adminGroupRoutesGet(
|
||||
event: H3Event,
|
||||
groupId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
await requireAnyAccess(event);
|
||||
const env = cfEnv(event);
|
||||
const access = requireGroup(event, groupId);
|
||||
if (!access.ok) return accessError(event, access);
|
||||
const all = await loadRoutes(env.KV);
|
||||
return { group: access.group, routes: all.filter((r) => r.groupId === groupId) };
|
||||
}
|
||||
|
||||
/** PUT /admin/api/groups/:groupId/routes */
|
||||
export async function adminGroupRoutesPut(
|
||||
event: H3Event,
|
||||
groupId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
await requireAnyAccess(event);
|
||||
const env = cfEnv(event);
|
||||
const access = requireGroupRole(event, groupId, "admin");
|
||||
if (!access.ok) return accessError(event, access);
|
||||
|
||||
const body = await readJsonBody(event);
|
||||
if (!body) return respondError(event, 400, "Invalid JSON body");
|
||||
|
||||
// Force every submitted route into this group so the client never has to
|
||||
// carry a groupId; the path parameter is the single source of truth.
|
||||
const submitted = body["routes"];
|
||||
const scoped = Array.isArray(submitted)
|
||||
? submitted.map((r) => ({ ...(r as Record<string, unknown>), groupId }))
|
||||
: submitted;
|
||||
const existing = await loadRoutes(env.KV);
|
||||
const unchanged = new Map(existing.map((r) => [r.id, r]));
|
||||
const result = validateRoutes(scoped, unchanged);
|
||||
if (!result.ok) return respondError(event, 400, result.error);
|
||||
|
||||
// Replace only this group's routes; every other group is preserved untouched.
|
||||
const others = existing.filter((r) => r.groupId !== groupId);
|
||||
const nextAll = [...others, ...result.routes];
|
||||
|
||||
try {
|
||||
await saveRoutes(env.KV, nextAll);
|
||||
} catch (err) {
|
||||
log.error({ err }, "Failed to save routes");
|
||||
return respondError(event, 500, "Failed to save routes");
|
||||
}
|
||||
const auth = currentAuth(event);
|
||||
await recordAudit(env.DB, {
|
||||
ts: Date.now(),
|
||||
actorId: auth.session.userId,
|
||||
actorLogin: auth.session.login,
|
||||
action: "group.routes.update",
|
||||
targetType: "group",
|
||||
targetId: groupId,
|
||||
groupId,
|
||||
detail: { count: result.routes.length },
|
||||
ip: clientIp(event),
|
||||
});
|
||||
log.info({ groupId, count: result.routes.length }, "Group routes updated via admin UI");
|
||||
return { ok: true, count: result.routes.length };
|
||||
}
|
||||
|
||||
/** POST /admin/api/groups/:groupId/invites */
|
||||
export async function adminGroupInvitesPost(
|
||||
event: H3Event,
|
||||
groupId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
await requireAnyAccess(event);
|
||||
const env = cfEnv(event);
|
||||
const access = requireGroupRole(event, groupId, "owner");
|
||||
if (!access.ok) return accessError(event, access);
|
||||
|
||||
const body = await readJsonBody(event);
|
||||
if (!body) return respondError(event, 400, "Invalid JSON body");
|
||||
const role = body["role"];
|
||||
if (role !== "admin" && role !== "viewer") {
|
||||
return respondError(event, 400, 'role must be "admin" or "viewer"');
|
||||
}
|
||||
const note = body["note"];
|
||||
const auth = currentAuth(event);
|
||||
const token = await createInvite(env.KV, {
|
||||
groupId,
|
||||
role,
|
||||
expiresAt: Date.now() + 7 * 86400_000,
|
||||
createdBy: auth.session.login,
|
||||
note: typeof note === "string" && note.trim() ? note.trim() : undefined,
|
||||
});
|
||||
await recordAudit(env.DB, {
|
||||
ts: Date.now(),
|
||||
actorId: auth.session.userId,
|
||||
actorLogin: auth.session.login,
|
||||
action: "invite.create",
|
||||
targetType: "group",
|
||||
targetId: groupId,
|
||||
groupId,
|
||||
detail: { role },
|
||||
ip: clientIp(event),
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
token,
|
||||
url: `/admin/invite?token=${token}`,
|
||||
expiresAt: Date.now() + 7 * 86400_000,
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /admin/api/groups/:groupId/invites */
|
||||
export async function adminGroupInvitesGet(
|
||||
event: H3Event,
|
||||
groupId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
await requireAnyAccess(event);
|
||||
const env = cfEnv(event);
|
||||
const access = requireGroupRole(event, groupId, "owner");
|
||||
if (!access.ok) return accessError(event, access);
|
||||
const invites = await listInvites(env.KV, groupId);
|
||||
return { invites };
|
||||
}
|
||||
|
||||
/** DELETE /admin/api/invites/:token */
|
||||
export async function adminInviteDelete(event: H3Event, token: string): Promise<Record<string, unknown>> {
|
||||
await requireAnyAccess(event);
|
||||
const env = cfEnv(event);
|
||||
const invite = await getInvite(env.KV, token);
|
||||
if (!invite) return respondError(event, 404, "Invite not found");
|
||||
const access = requireGroupRole(event, invite.groupId, "owner");
|
||||
if (!access.ok) return respondError(event, 403, "Forbidden");
|
||||
await revokeInvite(env.KV, token);
|
||||
const auth = currentAuth(event);
|
||||
await recordAudit(env.DB, {
|
||||
ts: Date.now(),
|
||||
actorId: auth.session.userId,
|
||||
actorLogin: auth.session.login,
|
||||
action: "invite.revoke",
|
||||
targetType: "group",
|
||||
targetId: invite.groupId,
|
||||
groupId: invite.groupId,
|
||||
ip: clientIp(event),
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** PUT /admin/api/groups/:groupId/rename */
|
||||
export async function adminGroupRename(
|
||||
event: H3Event,
|
||||
groupId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
await requireAnyAccess(event);
|
||||
const env = cfEnv(event);
|
||||
const access = requireGroupRole(event, groupId, "owner");
|
||||
if (!access.ok) return accessError(event, access);
|
||||
|
||||
const body = await readJsonBody(event);
|
||||
if (!body) return respondError(event, 400, "Invalid JSON body");
|
||||
const newId = String(body["newId"] ?? "").trim();
|
||||
if (!ID_RE.test(newId)) return respondError(event, 400, "newId is invalid");
|
||||
if (newId === groupId) {
|
||||
return respondError(event, 400, "newId must differ from the current id");
|
||||
}
|
||||
const auth = currentAuth(event);
|
||||
const existing = await loadGroups(env.KV);
|
||||
if (existing.some((g) => g.id === newId)) {
|
||||
return respondError(event, 400, `group id "${newId}" already exists`);
|
||||
}
|
||||
|
||||
const next = existing.map((g) => (g.id === groupId ? { ...g, id: newId } : g));
|
||||
try {
|
||||
await saveGroups(env.KV, next);
|
||||
} catch (err) {
|
||||
log.error({ err }, "Failed to save groups on rename");
|
||||
return respondError(event, 500, "Failed to save groups");
|
||||
}
|
||||
|
||||
// Re-point routes, the tenant webhook secret and pending invites.
|
||||
const routes = await loadRoutes(env.KV);
|
||||
const touched = routes.filter((r) => r.groupId === groupId);
|
||||
if (touched.length > 0) {
|
||||
await saveRoutes(
|
||||
env.KV,
|
||||
routes.map((r) => (r.groupId === groupId ? { ...r, groupId: newId } : r)),
|
||||
);
|
||||
}
|
||||
const secret = await getTenantSecret(env.KV, groupId);
|
||||
if (secret) {
|
||||
await env.KV.put(`tenant:${newId}`, secret);
|
||||
await env.KV.delete(`tenant:${groupId}`);
|
||||
}
|
||||
await migrateInvites(env.KV, groupId, newId);
|
||||
|
||||
await recordAudit(env.DB, {
|
||||
ts: Date.now(),
|
||||
actorId: auth.session.userId,
|
||||
actorLogin: auth.session.login,
|
||||
action: "group.rename",
|
||||
targetType: "group",
|
||||
targetId: newId,
|
||||
groupId: newId,
|
||||
detail: { from: groupId, to: newId, routes: touched.length },
|
||||
ip: clientIp(event),
|
||||
});
|
||||
log.info({ from: groupId, to: newId }, "Group renamed via admin UI");
|
||||
return { ok: true, id: newId };
|
||||
}
|
||||
|
||||
/** GET /admin/api/groups/:groupId/webhook */
|
||||
export async function adminGroupWebhookGet(
|
||||
event: H3Event,
|
||||
groupId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
await requireAnyAccess(event);
|
||||
const env = cfEnv(event);
|
||||
const access = requireGroupRole(event, groupId, "owner");
|
||||
if (!access.ok) return accessError(event, access);
|
||||
return {
|
||||
url: webhookUrl(event, groupId),
|
||||
hasSecret: (await getTenantSecret(env.KV, groupId)) != null,
|
||||
};
|
||||
}
|
||||
|
||||
/** POST /admin/api/groups/:groupId/webhook/regenerate */
|
||||
export async function adminGroupWebhookRegenerate(
|
||||
event: H3Event,
|
||||
groupId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
await requireAnyAccess(event);
|
||||
const env = cfEnv(event);
|
||||
const access = requireGroupRole(event, groupId, "owner");
|
||||
if (!access.ok) return accessError(event, access);
|
||||
const secret = await setTenantSecret(env.KV, groupId);
|
||||
const auth = currentAuth(event);
|
||||
await recordAudit(env.DB, {
|
||||
ts: Date.now(),
|
||||
actorId: auth.session.userId,
|
||||
actorLogin: auth.session.login,
|
||||
action: "webhook.secret.regenerate",
|
||||
targetType: "group",
|
||||
targetId: groupId,
|
||||
groupId,
|
||||
ip: clientIp(event),
|
||||
});
|
||||
return { ok: true, url: webhookUrl(event, groupId), secret };
|
||||
}
|
||||
|
||||
/** DELETE /admin/api/groups/:groupId/webhook */
|
||||
export async function adminGroupWebhookDelete(
|
||||
event: H3Event,
|
||||
groupId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
await requireAnyAccess(event);
|
||||
const env = cfEnv(event);
|
||||
const access = requireGroupRole(event, groupId, "owner");
|
||||
if (!access.ok) return accessError(event, access);
|
||||
await deleteTenantSecret(env.KV, groupId);
|
||||
const auth = currentAuth(event);
|
||||
await recordAudit(env.DB, {
|
||||
ts: Date.now(),
|
||||
actorId: auth.session.userId,
|
||||
actorLogin: auth.session.login,
|
||||
action: "webhook.secret.delete",
|
||||
targetType: "group",
|
||||
targetId: groupId,
|
||||
groupId,
|
||||
ip: clientIp(event),
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** GET /admin/api/audit */
|
||||
export async function adminAudit(event: H3Event): Promise<Record<string, unknown>> {
|
||||
const auth = await requireAnyAccess(event);
|
||||
const env = cfEnv(event);
|
||||
const query = getQuery(event);
|
||||
const limit = Math.min(Math.max(Number(String(query["limit"] ?? "50")), 1), 200);
|
||||
const groupId = String(query["groupId"] ?? "") || undefined;
|
||||
if (groupId && !auth.scope.isSuper && !auth.scope.groupIds.has(groupId)) {
|
||||
return respondError(event, 403, "Forbidden");
|
||||
}
|
||||
const entries = await getAuditLog(env.DB, { groupId, limit });
|
||||
const visible = auth.scope.isSuper
|
||||
? entries
|
||||
: entries.filter((e) => e.groupId != null && auth.scope.groupIds.has(e.groupId));
|
||||
return { audit: visible };
|
||||
}
|
||||
104
server/lib/web/auth.ts
Normal file
104
server/lib/web/auth.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import type { H3Event } from "h3";
|
||||
import { createError, getHeader } from "h3";
|
||||
import type { Group, GroupRole } from "../types";
|
||||
import { getAdminSession, type AdminSession } from "./session";
|
||||
import {
|
||||
loadGroups,
|
||||
resolveScope,
|
||||
hasAnyAccess,
|
||||
canEditGroup,
|
||||
canEditRoutes,
|
||||
roleAtLeast,
|
||||
roleAt,
|
||||
type AccessScope,
|
||||
} from "./groups";
|
||||
import { findUserIdByToken } from "../github/store";
|
||||
import { cfEnv } from "../cf";
|
||||
|
||||
export interface AuthContext {
|
||||
session: AdminSession;
|
||||
scope: AccessScope;
|
||||
groups: Group[];
|
||||
}
|
||||
|
||||
const AUTH_KEY = "auth";
|
||||
|
||||
/** Read the admin session + access scope for a request (null when logged out). */
|
||||
export async function loadAuth(event: H3Event): Promise<AuthContext | null> {
|
||||
const env = cfEnv(event);
|
||||
const session = await getAdminSession(env.KV, getHeader(event, "cookie"));
|
||||
if (!session) return null;
|
||||
const groups = await loadGroups(env.KV);
|
||||
const scope = resolveScope(env, groups, session.userId, session.login);
|
||||
return { session, scope, groups };
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate + gate: throws 401 when logged out, 403 when the account has
|
||||
* no group access at all. Returns the auth context and caches it on the event.
|
||||
*/
|
||||
export async function requireAnyAccess(event: H3Event): Promise<AuthContext> {
|
||||
const auth = await loadAuth(event);
|
||||
if (!auth) throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
||||
if (!hasAnyAccess(auth.scope)) throw createError({ statusCode: 403, statusMessage: "Forbidden" });
|
||||
event.context[AUTH_KEY] = auth;
|
||||
return auth;
|
||||
}
|
||||
|
||||
/** The authenticated context; only valid after `requireAnyAccess`. */
|
||||
export function currentAuth(event: H3Event): AuthContext {
|
||||
return event.context[AUTH_KEY] as AuthContext;
|
||||
}
|
||||
|
||||
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(event: H3Event, groupId: string): GroupAccess {
|
||||
const auth = currentAuth(event);
|
||||
const group = auth.groups.find((g) => g.id === groupId);
|
||||
if (!group) return { ok: false, status: 404 };
|
||||
if (!auth.scope.isSuper && !auth.scope.groupIds.has(groupId)) {
|
||||
return { ok: false, status: 403 };
|
||||
}
|
||||
return { ok: true, group };
|
||||
}
|
||||
|
||||
/** Requires at least `min` role in the group (owner|admin|viewer). */
|
||||
export function requireGroupRole(
|
||||
event: H3Event,
|
||||
groupId: string,
|
||||
min: GroupRole,
|
||||
): GroupAccess {
|
||||
const access = requireGroup(event, groupId);
|
||||
if (!access.ok) return access;
|
||||
const auth = currentAuth(event);
|
||||
if (!auth.scope.isSuper && !roleAtLeast(roleAt(auth.scope, groupId), min)) {
|
||||
return { ok: false, status: 403 };
|
||||
}
|
||||
return access;
|
||||
}
|
||||
|
||||
export { canEditGroup, canEditRoutes, roleAt };
|
||||
|
||||
/** Best-effort client IP for audit entries (Cloudflare header first). */
|
||||
export function clientIp(event: H3Event): string | undefined {
|
||||
return (
|
||||
getHeader(event, "cf-connecting-ip") ??
|
||||
getHeader(event, "x-forwarded-for")?.split(",")[0]?.trim()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearer-token auth for machine-to-machine action endpoints: resolves the
|
||||
* GitHub userId owning the token, or throws 401.
|
||||
*/
|
||||
export async function bearerUserId(event: H3Event): Promise<string> {
|
||||
const env = cfEnv(event);
|
||||
const auth = getHeader(event, "authorization");
|
||||
if (!auth?.startsWith("Bearer "))
|
||||
throw createError({ statusCode: 401, statusMessage: "Missing authorization" });
|
||||
const userId = await findUserIdByToken(env.KV, auth.slice(7));
|
||||
if (!userId)
|
||||
throw createError({ statusCode: 401, statusMessage: "Invalid or expired token" });
|
||||
return userId;
|
||||
}
|
||||
213
server/lib/web/groups.ts
Normal file
213
server/lib/web/groups.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import type { Env, Group, GroupMember, GroupRole } from "../types";
|
||||
import { isAdminUser } from "./session";
|
||||
import { log } from "../lib/log";
|
||||
|
||||
const GROUPS_KEY = "config:groups";
|
||||
|
||||
/**
|
||||
* Normalizes a group's member list. Groups stored with the legacy `adminIds`
|
||||
* field (or with neither field) get their admins as role "owner" members, so
|
||||
* every existing group keeps full control after the member-model migration.
|
||||
* Never mutates the input; returns a fresh array.
|
||||
*/
|
||||
export function normalizeGroupMembers(group: Group): GroupMember[] {
|
||||
const members = group.members;
|
||||
if (Array.isArray(members) && members.length > 0) {
|
||||
const seen = new Set<string>();
|
||||
const out: GroupMember[] = [];
|
||||
for (const m of members) {
|
||||
const login = String(m?.login ?? "").trim();
|
||||
if (!login || seen.has(login.toLowerCase())) continue;
|
||||
const role: GroupRole = m?.role === "admin" || m?.role === "viewer" ? m.role : "owner";
|
||||
seen.add(login.toLowerCase());
|
||||
out.push({ login, role });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Legacy path: adminIds → owners.
|
||||
const seen = new Set<string>();
|
||||
const out: GroupMember[] = [];
|
||||
for (const a of group.adminIds ?? []) {
|
||||
const login = a.trim();
|
||||
if (!login || seen.has(login.toLowerCase())) continue;
|
||||
seen.add(login.toLowerCase());
|
||||
out.push({ login, role: "owner" });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function loadGroups(kv: KVNamespace): Promise<Group[]> {
|
||||
try {
|
||||
const stored = await kv.get<Group[]>(GROUPS_KEY, "json");
|
||||
if (Array.isArray(stored)) return stored;
|
||||
} catch (err) {
|
||||
log.warn({ err }, "Failed to load groups from KV");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function saveGroups(kv: KVNamespace, groups: Group[]): Promise<void> {
|
||||
await kv.put(GROUPS_KEY, JSON.stringify(groups));
|
||||
}
|
||||
|
||||
/** Case-insensitive match of a GitHub userId or login against a list of ids/logins. */
|
||||
export function identityMatches(ids: string[], userId: string, login: string): boolean {
|
||||
const wanted = ids.map((s) => s.trim()).filter(Boolean);
|
||||
if (wanted.length === 0) return false;
|
||||
return wanted.some((id) => id === userId || id.toLowerCase() === login.toLowerCase());
|
||||
}
|
||||
|
||||
export function isGroupAdmin(group: Group, userId: string, login: string): boolean {
|
||||
return identityMatches(group.adminIds ?? [], userId, login);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's role in a group, derived from normalized members. Legacy
|
||||
* `adminIds` groups resolve to "owner" for every listed admin.
|
||||
*/
|
||||
export function memberRole(group: Group, userId: string, login: string): GroupRole | undefined {
|
||||
const members = normalizeGroupMembers(group);
|
||||
const wanted = members.filter((m) => {
|
||||
const candidate = m.login.trim().toLowerCase();
|
||||
return candidate === login.toLowerCase() || candidate === userId;
|
||||
});
|
||||
return wanted.length > 0 ? wanted[0]!.role : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an event originating from `owners` (org/user logins) is allowed into
|
||||
* this group. A group with no owner restriction accepts everything.
|
||||
*/
|
||||
export function groupAcceptsOwners(group: Group, owners: string[]): boolean {
|
||||
const restrict = (group.owners ?? []).map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
if (restrict.length === 0) return true;
|
||||
const seen = owners.map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
return seen.some((o) => restrict.includes(o));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an event from a webhook `provider` (source platform: github, gitea,
|
||||
* ...) is allowed into this group. A group with no provider restriction
|
||||
* accepts every provider. Events without a provider are treated as github.
|
||||
*/
|
||||
export function groupAcceptsProvider(group: Group, provider?: string): boolean {
|
||||
const allowed = (group.providers ?? []).map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
if (allowed.length === 0) return true;
|
||||
return allowed.includes(provider ?? "github");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an event from a GitHub App `installationId` is allowed into this
|
||||
* group. A group bound to an installation only accepts events from that
|
||||
* installation (hard tenant isolation). Unbound groups accept everything here
|
||||
* (their access is governed by `owners`/`providers` instead).
|
||||
*/
|
||||
export function groupAcceptsInstallation(group: Group, installationId?: number): boolean {
|
||||
if (group.installationId == null) return true;
|
||||
return installationId != null && group.installationId === installationId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-provision a GitHub App installation on `installation.created`:
|
||||
* 1. No-op when a group is already bound to this installation id.
|
||||
* 2. Otherwise bind every unbound group whose `owners` match the installing
|
||||
* account login (so existing org groups light up automatically).
|
||||
* 3. Otherwise create a dedicated `inst-{installationId}` group bound to the
|
||||
* installation. Returns the group that now owns the installation.
|
||||
*/
|
||||
export async function ensureInstallationGroup(
|
||||
kv: KVNamespace,
|
||||
installationId: number,
|
||||
accountLogin: string,
|
||||
): Promise<Group | null> {
|
||||
const groups = await loadGroups(kv);
|
||||
const existing = groups.find((g) => g.installationId === installationId);
|
||||
if (existing) return existing;
|
||||
|
||||
const login = accountLogin.trim().toLowerCase();
|
||||
const candidates = groups.filter(
|
||||
(g) =>
|
||||
g.installationId == null &&
|
||||
login.length > 0 &&
|
||||
(g.owners ?? []).some((o) => o.trim().toLowerCase() === login),
|
||||
);
|
||||
if (candidates.length > 0) {
|
||||
const next = groups.map((g) => (candidates.includes(g) ? { ...g, installationId } : g));
|
||||
await saveGroups(kv, next);
|
||||
return next.find((g) => g.id === candidates[0]!.id) ?? null;
|
||||
}
|
||||
|
||||
const gid = `inst-${installationId}`;
|
||||
const dedicated = groups.find((g) => g.id === gid);
|
||||
const group: Group = {
|
||||
id: gid,
|
||||
name: accountLogin.trim() || `Installation ${installationId}`,
|
||||
adminIds: [],
|
||||
installationId,
|
||||
};
|
||||
await saveGroups(
|
||||
kv,
|
||||
dedicated ? groups.map((g) => (g.id === gid ? { ...g, ...group } : g)) : [...groups, group],
|
||||
);
|
||||
return group;
|
||||
}
|
||||
|
||||
export interface AccessScope {
|
||||
isSuper: boolean;
|
||||
/** Groups the user may view. When isSuper, this is every group. */
|
||||
groups: Group[];
|
||||
/** Ids of accessible groups, for quick membership checks. */
|
||||
groupIds: Set<string>;
|
||||
/** The user's role per accessible group (absent for super admins). */
|
||||
roles: Map<string, GroupRole>;
|
||||
}
|
||||
|
||||
export function resolveScope(
|
||||
env: Env,
|
||||
groups: Group[],
|
||||
userId: string,
|
||||
login: string,
|
||||
): AccessScope {
|
||||
const isSuper = isAdminUser(env, userId, login);
|
||||
const visible = isSuper ? groups : groups.filter((g) => memberRole(g, userId, login) != null);
|
||||
const roles = new Map<string, GroupRole>();
|
||||
for (const g of visible) {
|
||||
const role = memberRole(g, userId, login);
|
||||
if (role) roles.set(g.id, role);
|
||||
}
|
||||
return {
|
||||
isSuper,
|
||||
groups: visible,
|
||||
groupIds: new Set(visible.map((g) => g.id)),
|
||||
roles,
|
||||
};
|
||||
}
|
||||
|
||||
/** The user's role in a group, or undefined when they have no access. */
|
||||
export function roleAt(scope: AccessScope, groupId: string): GroupRole | undefined {
|
||||
if (scope.isSuper) return "owner";
|
||||
return scope.roles.get(groupId);
|
||||
}
|
||||
|
||||
/** Role hierarchy: viewer < admin < owner. */
|
||||
const ROLE_RANK: Record<GroupRole, number> = { viewer: 1, admin: 2, owner: 3 };
|
||||
|
||||
export function roleAtLeast(role: GroupRole | undefined, min: GroupRole): boolean {
|
||||
if (!role) return false;
|
||||
return ROLE_RANK[role] >= ROLE_RANK[min];
|
||||
}
|
||||
|
||||
/** owner | admin can edit routes; viewers are read-only. */
|
||||
export function canEditRoutes(scope: AccessScope, groupId: string): boolean {
|
||||
return roleAtLeast(roleAt(scope, groupId), "admin");
|
||||
}
|
||||
|
||||
/** Only owners (and super admins) may manage a group's settings and members. */
|
||||
export function canEditGroup(scope: AccessScope, groupId: string): boolean {
|
||||
return roleAtLeast(roleAt(scope, groupId), "owner");
|
||||
}
|
||||
|
||||
/** True if the user is a super admin or manages at least one group. */
|
||||
export function hasAnyAccess(scope: AccessScope): boolean {
|
||||
return scope.isSuper || scope.groups.length > 0;
|
||||
}
|
||||
193
server/lib/web/invites.ts
Normal file
193
server/lib/web/invites.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { loadGroups, saveGroups, identityMatches, normalizeGroupMembers } from "./groups";
|
||||
import { log } from "../lib/log";
|
||||
|
||||
export interface Invite {
|
||||
groupId: string;
|
||||
/** Invited users can never be owners; only admins or viewers. */
|
||||
role: "admin" | "viewer";
|
||||
expiresAt: number;
|
||||
createdBy: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
const INVITE_TTL = 7 * 24 * 3600;
|
||||
|
||||
function generateInviteToken(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function inviteKey(token: string): string {
|
||||
return `invite:${token}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-group index of invite tokens. Listing pending invites reads this key
|
||||
* instead of `kv.list({ prefix })`, which is eventually consistent and can
|
||||
* lag behind a fresh write by minutes — the index keeps listing reliable.
|
||||
*/
|
||||
function indexKey(groupId: string): string {
|
||||
return `invite:group:${groupId}`;
|
||||
}
|
||||
|
||||
async function readIndex(kv: KVNamespace, groupId: string): Promise<string[]> {
|
||||
try {
|
||||
const raw = await kv.get<string[]>(indexKey(groupId), "json");
|
||||
return Array.isArray(raw) ? raw : [];
|
||||
} catch (err) {
|
||||
log.warn({ err, groupId }, "Failed to read invite index");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function writeIndex(kv: KVNamespace, groupId: string, tokens: string[]): Promise<void> {
|
||||
await kv.put(indexKey(groupId), JSON.stringify(tokens));
|
||||
}
|
||||
|
||||
async function removeFromIndex(kv: KVNamespace, groupId: string, token: string): Promise<void> {
|
||||
const tokens = (await readIndex(kv, groupId)).filter((t) => t !== token);
|
||||
await writeIndex(kv, groupId, tokens);
|
||||
}
|
||||
|
||||
export async function createInvite(kv: KVNamespace, invite: Invite): Promise<string> {
|
||||
const token = generateInviteToken();
|
||||
await kv.put(inviteKey(token), JSON.stringify(invite), { expirationTtl: INVITE_TTL });
|
||||
const tokens = await readIndex(kv, invite.groupId);
|
||||
tokens.push(token);
|
||||
await writeIndex(kv, invite.groupId, tokens);
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function getInvite(kv: KVNamespace, token: string): Promise<Invite | null> {
|
||||
try {
|
||||
const raw = await kv.get<Invite>(inviteKey(token), "json");
|
||||
if (!raw) return null;
|
||||
if (Date.now() > raw.expiresAt) {
|
||||
await kv.delete(inviteKey(token));
|
||||
await removeFromIndex(kv, raw.groupId, token);
|
||||
return null;
|
||||
}
|
||||
return raw;
|
||||
} catch (err) {
|
||||
log.warn({ err }, "Failed to load invite");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function consumeInvite(kv: KVNamespace, token: string): Promise<void> {
|
||||
const raw = await kv.get<Invite>(inviteKey(token), "json");
|
||||
await kv.delete(inviteKey(token));
|
||||
if (raw) await removeFromIndex(kv, raw.groupId, token);
|
||||
}
|
||||
|
||||
/** All pending (unexpired) invites of a group, via the per-group index. */
|
||||
export async function listInvites(
|
||||
kv: KVNamespace,
|
||||
groupId: string,
|
||||
): Promise<Array<Invite & { token: string }>> {
|
||||
try {
|
||||
const tokens = await readIndex(kv, groupId);
|
||||
const out: Array<Invite & { token: string }> = [];
|
||||
const stale: string[] = [];
|
||||
for (const token of tokens) {
|
||||
const invite = await getInvite(kv, token);
|
||||
if (invite && invite.groupId === groupId) {
|
||||
out.push({ ...invite, token });
|
||||
} else {
|
||||
stale.push(token);
|
||||
}
|
||||
}
|
||||
if (stale.length > 0) {
|
||||
await writeIndex(
|
||||
kv,
|
||||
groupId,
|
||||
tokens.filter((t) => !stale.includes(t)),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
} catch (err) {
|
||||
log.warn({ err }, "Failed to list invites");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeInvite(kv: KVNamespace, token: string): Promise<void> {
|
||||
const raw = await kv.get<Invite>(inviteKey(token), "json");
|
||||
await kv.delete(inviteKey(token));
|
||||
if (raw) await removeFromIndex(kv, raw.groupId, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-point every pending invite of a group to its new id (group rename).
|
||||
* Best-effort: a failure leaves the old invites in place (they will be
|
||||
* rejected as group-missing after the rename).
|
||||
*/
|
||||
export async function migrateInvites(kv: KVNamespace, from: string, to: string): Promise<void> {
|
||||
try {
|
||||
const tokens = await readIndex(kv, from);
|
||||
if (tokens.length === 0) {
|
||||
await kv.delete(indexKey(from));
|
||||
return;
|
||||
}
|
||||
const moved: string[] = [];
|
||||
for (const token of tokens) {
|
||||
const invite = await kv.get<Invite>(inviteKey(token), "json");
|
||||
if (invite && invite.groupId === from) {
|
||||
await kv.put(inviteKey(token), JSON.stringify({ ...invite, groupId: to }), {
|
||||
expirationTtl: INVITE_TTL,
|
||||
});
|
||||
moved.push(token);
|
||||
}
|
||||
}
|
||||
await kv.put(indexKey(to), JSON.stringify(moved));
|
||||
await kv.delete(indexKey(from));
|
||||
} catch (err) {
|
||||
log.warn({ err, from, to }, "Failed to migrate invites on group rename");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the accepting user to the invited group (or upgrades their role when
|
||||
* they are already a viewer) and consumes the invite. The invited role is
|
||||
* never an owner — ownership stays with the inviter's discretion.
|
||||
*/
|
||||
export async function acceptInvite(
|
||||
kv: KVNamespace,
|
||||
token: string,
|
||||
userId: string,
|
||||
login: string,
|
||||
): Promise<
|
||||
| { ok: true; groupId: string; role: "admin" | "viewer" }
|
||||
| { ok: false; reason: "invalid" | "group-missing" }
|
||||
> {
|
||||
const invite = await getInvite(kv, token);
|
||||
if (!invite) return { ok: false, reason: "invalid" };
|
||||
const groups = await loadGroups(kv);
|
||||
const group = groups.find((g) => g.id === invite.groupId);
|
||||
if (!group) return { ok: false, reason: "group-missing" };
|
||||
|
||||
const members = normalizeGroupMembers(group);
|
||||
const idx = members.findIndex((m) => identityMatches([m.login], userId, login));
|
||||
if (idx >= 0) {
|
||||
if (invite.role === "admin" && members[idx]!.role === "viewer") {
|
||||
members[idx]!.role = "admin";
|
||||
}
|
||||
} else {
|
||||
members.push({ login, role: invite.role });
|
||||
}
|
||||
const next = groups.map((g) =>
|
||||
g.id === group.id
|
||||
? {
|
||||
...g,
|
||||
members,
|
||||
adminIds: members.filter((m) => m.role === "owner").map((m) => m.login),
|
||||
}
|
||||
: g,
|
||||
);
|
||||
await saveGroups(kv, next);
|
||||
await consumeInvite(kv, token);
|
||||
return { ok: true, groupId: group.id, role: invite.role };
|
||||
}
|
||||
389
server/lib/web/oauth.ts
Normal file
389
server/lib/web/oauth.ts
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
import type { H3Event } from "h3";
|
||||
import {
|
||||
appendResponseHeader,
|
||||
createError,
|
||||
getHeader,
|
||||
getQuery,
|
||||
readBody,
|
||||
sendRedirect,
|
||||
setResponseStatus,
|
||||
} from "h3";
|
||||
import {
|
||||
getOAuthURL,
|
||||
handleOAuthCallback as handleGithubOAuthCallback,
|
||||
getInstallationAccount,
|
||||
} from "../github/oauth";
|
||||
import { removeToken, saveDiscordLink, saveTelegramLink } from "../github/store";
|
||||
import { createAdminSession, adminCookie, getAdminSession } from "./session";
|
||||
import {
|
||||
loadGroups,
|
||||
saveGroups,
|
||||
resolveScope,
|
||||
hasAnyAccess,
|
||||
ensureInstallationGroup,
|
||||
normalizeGroupMembers,
|
||||
roleAt,
|
||||
} from "./groups";
|
||||
import { clientIp } from "./auth";
|
||||
import { recordAudit } from "../lib/audit";
|
||||
import { sendMessage } from "../drivers/telegram/rest";
|
||||
import { cfEnv } from "../cf";
|
||||
import type { Env, Group } from "../types";
|
||||
|
||||
interface PendingState {
|
||||
redirectTo: string;
|
||||
expiresAt: number;
|
||||
discordUserId?: string;
|
||||
telegramUserId?: string;
|
||||
telegramChatId?: string;
|
||||
}
|
||||
|
||||
function linkedPage(login: string): string {
|
||||
return `<!doctype html><html lang="zh"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>绑定成功</title><style>body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:#f6f7f9;color:#1f2328}.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:32px 40px;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,.06)}.ok{color:#16a34a;font-size:40px}h1{font-size:18px;margin:12px 0 4px}p{color:#57606a;font-size:14px;margin:0}</style></head><body><div class="card"><div class="ok">✓</div><h1>GitHub 账号已绑定</h1><p>已连接为 <b>@${login}</b>,现在可以回到 Discord 用 GitHub 评论了。</p></div></body></html>`;
|
||||
}
|
||||
|
||||
function generateRandomHex(length: number): string {
|
||||
const bytes = new Uint8Array(length);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function safeRedirectPath(value: string | undefined): string {
|
||||
if (!value) return "/";
|
||||
if (!value.startsWith("/")) return "/";
|
||||
if (value.startsWith("//")) return "/";
|
||||
if (/^\/\\/.test(value)) return "/";
|
||||
return value;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function selfSignupEnabled(env: Env): boolean {
|
||||
const flag = (env.ALLOW_SELF_SIGNUP ?? "").trim().toLowerCase();
|
||||
return flag === "1" || flag === "true" || flag === "yes" || flag === "on";
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-in self service: users without any group access get a personal group
|
||||
* they own, so they can configure their own routing without a super admin.
|
||||
* The group id is deterministic (`u-{userId}`), so it is created at most once.
|
||||
*/
|
||||
async function ensurePersonalGroup(env: Env, userId: string, login: string): Promise<boolean> {
|
||||
if (!selfSignupEnabled(env)) return false;
|
||||
const groups = await loadGroups(env.KV);
|
||||
const gid = `u-${userId}`;
|
||||
if (groups.some((g) => g.id === gid)) return true;
|
||||
const personal: Group = {
|
||||
id: gid,
|
||||
name: `@${login}`,
|
||||
members: [{ login, role: "owner" }],
|
||||
adminIds: [login],
|
||||
};
|
||||
await saveGroups(env.KV, [...groups, personal]);
|
||||
await recordAudit(env.DB, {
|
||||
ts: Date.now(),
|
||||
actorId: userId,
|
||||
actorLogin: login,
|
||||
action: "group.create",
|
||||
targetType: "group",
|
||||
targetId: gid,
|
||||
groupId: gid,
|
||||
detail: { auto: true },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-install choice page: pick which group the installation binds to.
|
||||
* Options are the groups the signed-in user owns (role `owner`), plus a
|
||||
* default "create a new group" choice.
|
||||
*/
|
||||
function installPage(opts: {
|
||||
installationId: number;
|
||||
accountLogin: string;
|
||||
owned: Group[];
|
||||
}): string {
|
||||
const { installationId, accountLogin, owned } = opts;
|
||||
const accountLine = accountLogin
|
||||
? `<p>账号:<b>${escapeHtml(accountLogin)}</b>(安装 ID <code>${installationId}</code>)</p>`
|
||||
: `<p>安装 ID:<code>${installationId}</code></p>`;
|
||||
const ownedOptions = owned
|
||||
.map(
|
||||
(g) =>
|
||||
`<label class="opt"><input type="radio" name="group" value="${escapeHtml(g.id)}"><span><b>${escapeHtml(g.name)}</b> <code>${escapeHtml(g.id)}</code></span></label>`,
|
||||
)
|
||||
.join("");
|
||||
const ownedNote = owned.length
|
||||
? '<p class="hint">也可以选择绑定到你有 owner 权限的已有分组:</p>'
|
||||
: "";
|
||||
return `<!doctype html><html lang="zh"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>安装 GitHub App</title><style>body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:#f6f7f9;color:#1f2328}.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:28px 32px;width:min(480px,92vw);box-shadow:0 1px 3px rgba(0,0,0,.06)}h1{font-size:17px;margin:0 0 4px}p{color:#57606a;font-size:13.5px;margin:6px 0}code{background:#f0f1f3;border-radius:4px;padding:1px 5px;font-size:12.5px}.opt{display:flex;align-items:flex-start;gap:8px;padding:9px 10px;border:1px solid #e5e7eb;border-radius:8px;margin-top:8px;cursor:pointer}.opt:hover{background:#fafbfc}.hint{font-size:12.5px;color:#8b949e;margin-top:10px}.btn{display:inline-block;margin-top:14px;background:#1f2328;color:#fff;border:0;border-radius:8px;padding:10px 18px;font-size:14px;cursor:pointer}.btn:hover{background:#32383f}.skip{margin-left:12px;color:#8b949e;font-size:13px;text-decoration:none}</style></head><body><div class="card"><h1>GitHub App 安装成功</h1>${accountLine}<p>将安装绑定到哪个分组?建议直接创建新分组,之后可以在控制台添加路由与成员。</p><form method="post" action="/auth/github/install/bind"><input type="hidden" name="installation_id" value="${installationId}"><label class="opt"><input type="radio" name="group" value="" checked><span><b>创建新分组</b> <code>inst-${installationId}</code></span></label>${ownedNote}${ownedOptions}<button class="btn" type="submit">确定</button></form></div></body></html>`;
|
||||
}
|
||||
|
||||
/** GET /auth/github — start the OAuth flow. */
|
||||
export async function handleOAuthStart(event: H3Event): Promise<void> {
|
||||
const env = cfEnv(event);
|
||||
const query = getQuery(event);
|
||||
const redirectTo = safeRedirectPath(String(query["redirect"] ?? ""));
|
||||
const state = generateRandomHex(16);
|
||||
const pending: PendingState = {
|
||||
redirectTo,
|
||||
expiresAt: Date.now() + 10 * 60 * 1000,
|
||||
};
|
||||
await env.KV.put(`state:${state}`, JSON.stringify(pending), { expirationTtl: 600 });
|
||||
await sendRedirect(event, getOAuthURL(env.GITHUB_CLIENT_ID ?? "", state));
|
||||
}
|
||||
|
||||
/** GET /auth/github/install — post-install choice page. */
|
||||
export async function handleInstallPage(event: H3Event): Promise<string | void> {
|
||||
const env = cfEnv(event);
|
||||
const query = getQuery(event);
|
||||
const rawId = String(query["installation_id"] ?? "");
|
||||
const installationId = Number(rawId);
|
||||
if (!rawId || !Number.isInteger(installationId) || installationId <= 0) {
|
||||
throw createError({ statusCode: 400, statusMessage: "Missing installation_id" });
|
||||
}
|
||||
const session = await getAdminSession(env.KV, getHeader(event, "cookie"));
|
||||
if (!session) {
|
||||
const target = `/auth/github/install?installation_id=${installationId}`;
|
||||
await sendRedirect(event, `/auth/github?redirect=${encodeURIComponent(target)}`);
|
||||
return;
|
||||
}
|
||||
const accountLogin =
|
||||
(await getInstallationAccount(env.GITHUB_APP_ID ?? "", env.GITHUB_PRIVATE_KEY ?? "", installationId)) ??
|
||||
"";
|
||||
const groups = await loadGroups(env.KV);
|
||||
const scope = resolveScope(env, groups, session.userId, session.login);
|
||||
const owned = groups.filter((g) => roleAt(scope, g.id) === "owner");
|
||||
return installPage({ installationId, accountLogin, owned });
|
||||
}
|
||||
|
||||
/** POST /auth/github/install/bind — provision the chosen binding. */
|
||||
export async function handleInstallBind(event: H3Event): Promise<void> {
|
||||
const env = cfEnv(event);
|
||||
const session = await getAdminSession(env.KV, getHeader(event, "cookie"));
|
||||
if (!session) {
|
||||
await sendRedirect(event, "/admin?error=forbidden");
|
||||
return;
|
||||
}
|
||||
const body = (await readBody(event).catch(() => ({}))) as Record<string, string | undefined>;
|
||||
const rawId = String(body["installation_id"] ?? "");
|
||||
const installationId = Number(rawId);
|
||||
if (!Number.isInteger(installationId) || installationId <= 0) {
|
||||
throw createError({ statusCode: 400, statusMessage: "Missing installation_id" });
|
||||
}
|
||||
const chosenGroupId = String(body["group"] ?? "").trim();
|
||||
const groups = await loadGroups(env.KV);
|
||||
const scope = resolveScope(env, groups, session.userId, session.login);
|
||||
|
||||
const bind = async (groupId: string, group: Group | null): Promise<void> => {
|
||||
if (!group) {
|
||||
await sendRedirect(event, "/admin?error=install");
|
||||
return;
|
||||
}
|
||||
const next = groups.map((g) => (g.id === group.id ? { ...g, installationId } : g));
|
||||
await saveGroups(env.KV, next);
|
||||
await recordAudit(env.DB, {
|
||||
ts: Date.now(),
|
||||
actorId: session.userId,
|
||||
actorLogin: session.login,
|
||||
action: "installation.bind",
|
||||
targetType: "group",
|
||||
targetId: groupId,
|
||||
groupId,
|
||||
detail: { installationId },
|
||||
ip: clientIp(event),
|
||||
});
|
||||
await sendRedirect(event, "/admin?install=ok");
|
||||
};
|
||||
|
||||
if (chosenGroupId) {
|
||||
// Binding to an existing group requires owner permission on it.
|
||||
const group = groups.find((g) => g.id === chosenGroupId);
|
||||
if (!group || roleAt(scope, chosenGroupId) !== "owner") {
|
||||
await sendRedirect(event, "/admin?error=forbidden");
|
||||
return;
|
||||
}
|
||||
return bind(chosenGroupId, group);
|
||||
}
|
||||
|
||||
// Default: auto-create a dedicated inst-{id} group.
|
||||
const accountLogin =
|
||||
(await getInstallationAccount(env.GITHUB_APP_ID ?? "", env.GITHUB_PRIVATE_KEY ?? "", installationId)) ??
|
||||
"";
|
||||
const group = await ensureInstallationGroup(env.KV, installationId, accountLogin);
|
||||
if (!group) {
|
||||
await sendRedirect(event, "/admin?error=install");
|
||||
return;
|
||||
}
|
||||
await recordAudit(env.DB, {
|
||||
ts: Date.now(),
|
||||
actorId: session.userId,
|
||||
actorLogin: session.login,
|
||||
action: "installation.created",
|
||||
targetType: "group",
|
||||
targetId: group.id,
|
||||
groupId: group.id,
|
||||
detail: { source: "setup_url", account: accountLogin || undefined },
|
||||
ip: clientIp(event),
|
||||
});
|
||||
|
||||
// Self-service SaaS: the installer manages their own auto-created group.
|
||||
if (selfSignupEnabled(env)) {
|
||||
const members = normalizeGroupMembers(group);
|
||||
const alreadyMember = members.some(
|
||||
(m) => m.login.toLowerCase() === session.login.toLowerCase() || m.login === session.userId,
|
||||
);
|
||||
if (!alreadyMember) {
|
||||
const updated: Group = {
|
||||
...group,
|
||||
members: [...members, { login: session.login, role: "owner" }],
|
||||
adminIds: [...new Set([...(group.adminIds ?? []), session.login])],
|
||||
};
|
||||
const all = await loadGroups(env.KV);
|
||||
await saveGroups(env.KV, all.map((g) => (g.id === group.id ? updated : g)));
|
||||
await recordAudit(env.DB, {
|
||||
ts: Date.now(),
|
||||
actorId: session.userId,
|
||||
actorLogin: session.login,
|
||||
action: "group.member.add",
|
||||
targetType: "group",
|
||||
targetId: group.id,
|
||||
groupId: group.id,
|
||||
detail: { login: session.login, role: "owner", auto: true },
|
||||
ip: clientIp(event),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await sendRedirect(event, "/admin?install=ok");
|
||||
}
|
||||
|
||||
/** GET /auth/github/callback — OAuth callback. */
|
||||
export async function handleOAuthCallback(event: H3Event): Promise<unknown> {
|
||||
const env = cfEnv(event);
|
||||
const query = getQuery(event);
|
||||
const code = String(query["code"] ?? "");
|
||||
const state = String(query["state"] ?? "");
|
||||
if (!code || !state) {
|
||||
setResponseStatus(event, 400);
|
||||
return { error: "Missing code or state" };
|
||||
}
|
||||
|
||||
const raw = await env.KV.get(`state:${state}`, "json");
|
||||
if (!raw) {
|
||||
setResponseStatus(event, 400);
|
||||
return { error: "Invalid or expired state" };
|
||||
}
|
||||
const pending = raw as PendingState;
|
||||
if (Date.now() > pending.expiresAt) {
|
||||
await env.KV.delete(`state:${state}`);
|
||||
setResponseStatus(event, 400);
|
||||
return { error: "Invalid or expired state" };
|
||||
}
|
||||
await env.KV.delete(`state:${state}`);
|
||||
|
||||
const result = await handleGithubOAuthCallback(
|
||||
env.GITHUB_CLIENT_ID ?? "",
|
||||
env.GITHUB_CLIENT_SECRET ?? "",
|
||||
code,
|
||||
state,
|
||||
env.KV,
|
||||
);
|
||||
if (!result) {
|
||||
setResponseStatus(event, 400);
|
||||
return { error: "OAuth failed" };
|
||||
}
|
||||
|
||||
// Discord account-linking flow: bind the Discord user to this GitHub account.
|
||||
if (pending.discordUserId) {
|
||||
await saveDiscordLink(env.DB, pending.discordUserId, result.userId);
|
||||
const isBrowserLink = (getHeader(event, "accept") ?? "").includes("text/html");
|
||||
if (isBrowserLink) return linkedPage(result.login);
|
||||
return { ok: true, discordUserId: pending.discordUserId, login: result.login };
|
||||
}
|
||||
|
||||
// Telegram account-linking flow: bind the Telegram user to this GitHub account.
|
||||
if (pending.telegramUserId) {
|
||||
await saveTelegramLink(env.DB, pending.telegramUserId, result.userId);
|
||||
if (pending.telegramChatId && env.TELEGRAM_TOKEN) {
|
||||
await sendMessage(
|
||||
env.TELEGRAM_TOKEN,
|
||||
pending.telegramChatId,
|
||||
`✅ GitHub 账号已绑定:**@${result.login}**。现在可以用 /gh comment 评论了。`,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
return { ok: true, telegramUserId: pending.telegramUserId, login: result.login };
|
||||
}
|
||||
|
||||
const isBrowser = (getHeader(event, "accept") ?? "").includes("text/html");
|
||||
if (isBrowser) {
|
||||
// Invite accept flow: the redirect target is the invite page, which
|
||||
// processes the token after the session exists. Skip the access gate so
|
||||
// non-members can get in and accept.
|
||||
const isInviteFlow =
|
||||
pending.redirectTo.startsWith("/admin/invite") ||
|
||||
pending.redirectTo.startsWith("/admin/invite?");
|
||||
|
||||
let groups = await loadGroups(env.KV);
|
||||
let scope = resolveScope(env, groups, result.userId, result.login);
|
||||
|
||||
if (!hasAnyAccess(scope) && !isInviteFlow) {
|
||||
const created = await ensurePersonalGroup(env, result.userId, result.login);
|
||||
if (created) {
|
||||
groups = await loadGroups(env.KV);
|
||||
scope = resolveScope(env, groups, result.userId, result.login);
|
||||
}
|
||||
}
|
||||
if (!hasAnyAccess(scope) && !isInviteFlow) {
|
||||
await sendRedirect(event, "/admin?error=forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = await createAdminSession(env.KV, result.userId, result.login);
|
||||
appendResponseHeader(event, "Set-Cookie", adminCookie(sessionId));
|
||||
await recordAudit(env.DB, {
|
||||
ts: Date.now(),
|
||||
actorId: result.userId,
|
||||
actorLogin: result.login,
|
||||
action: "session.login",
|
||||
ip: clientIp(event),
|
||||
});
|
||||
await sendRedirect(event, pending.redirectTo);
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: result.userId,
|
||||
login: result.login,
|
||||
redirectTo: pending.redirectTo,
|
||||
};
|
||||
}
|
||||
|
||||
/** DELETE /auth/token/:userId — revoke a stored user token. */
|
||||
export async function handleTokenDelete(event: H3Event, userId: string): Promise<unknown> {
|
||||
const env = cfEnv(event);
|
||||
const session = await getAdminSession(env.KV, getHeader(event, "cookie"));
|
||||
if (!session) {
|
||||
setResponseStatus(event, 401);
|
||||
return { error: "Unauthorized" };
|
||||
}
|
||||
await removeToken(env.KV, userId);
|
||||
await recordAudit(env.DB, {
|
||||
ts: Date.now(),
|
||||
actorId: session.userId,
|
||||
actorLogin: session.login,
|
||||
action: "token.delete",
|
||||
targetType: "token",
|
||||
targetId: userId,
|
||||
ip: clientIp(event),
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
35
server/lib/web/richheader.ts
Normal file
35
server/lib/web/richheader.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { H3Event } from "h3";
|
||||
import { getQuery } from "h3";
|
||||
|
||||
function esc(s: string): string {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/** GET /api/richheader — Open Graph page for Telegram avatar link-preview cards. */
|
||||
export function handleRichHeader(event: H3Event): string {
|
||||
const query = getQuery(event);
|
||||
const title = String(query["title"] ?? "");
|
||||
const content = String(query["content"] ?? "");
|
||||
const avatar = String(query["avatar"] ?? "");
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="${esc(title)}">
|
||||
${content ? `<meta property="og:title" content="${esc(content)}">` : ""}
|
||||
${avatar ? `<meta property="og:image" content="${esc(avatar)}">` : ""}
|
||||
</head>
|
||||
<body style="font-family: system-ui, -apple-system, sans-serif; text-align: center; padding: 40px; max-width: 600px; margin: 0 auto;">
|
||||
${avatar ? `<img src="${esc(avatar)}" alt="Avatar" style="width: 128px; height: 128px; border-radius: 50%; margin-bottom: 20px;">` : ""}
|
||||
${title ? `<h1 style="margin: 0 0 10px 0; color: #333;">${esc(title)}</h1>` : ""}
|
||||
${content ? `<p style="margin: 0; color: #666;">${esc(content)}</p>` : ""}
|
||||
<p style="margin-top: 40px; font-size: 14px; color: #999;">This page is used for Open Graph meta tags (richheader) only.</p>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
77
server/lib/web/session.ts
Normal file
77
server/lib/web/session.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import type { Env } from "../types";
|
||||
|
||||
const SESSION_COOKIE = "wh_admin_session";
|
||||
const SESSION_TTL = 7 * 24 * 3600;
|
||||
|
||||
export interface AdminSession {
|
||||
userId: string;
|
||||
login: string;
|
||||
}
|
||||
|
||||
export function isAdminUser(env: Env, userId: string, login: string): boolean {
|
||||
const ids = (env.ADMIN_USER_IDS ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (ids.length === 0) return false;
|
||||
return ids.includes(userId) || ids.some((id) => id.toLowerCase() === login.toLowerCase());
|
||||
}
|
||||
|
||||
function generateSessionId(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export async function createAdminSession(
|
||||
kv: KVNamespace,
|
||||
userId: string,
|
||||
login: string,
|
||||
): Promise<string> {
|
||||
const sessionId = generateSessionId();
|
||||
const session: AdminSession = { userId, login };
|
||||
await kv.put(`session:${sessionId}`, JSON.stringify(session), {
|
||||
expirationTtl: SESSION_TTL,
|
||||
});
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
export function adminCookie(sessionId: string): string {
|
||||
return `${SESSION_COOKIE}=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${SESSION_TTL}`;
|
||||
}
|
||||
|
||||
export function clearAdminCookie(): string {
|
||||
return `${SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`;
|
||||
}
|
||||
|
||||
function parseCookies(header: string | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!header) return out;
|
||||
for (const part of header.split(";")) {
|
||||
const idx = part.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
out[part.slice(0, idx).trim()] = part.slice(idx + 1).trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function getAdminSession(
|
||||
kv: KVNamespace,
|
||||
cookieHeader: string | undefined,
|
||||
): Promise<AdminSession | null> {
|
||||
const sessionId = parseCookies(cookieHeader)[SESSION_COOKIE];
|
||||
if (!sessionId) return null;
|
||||
const raw = await kv.get<AdminSession>(`session:${sessionId}`, "json");
|
||||
if (!raw) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
export async function destroyAdminSession(
|
||||
kv: KVNamespace,
|
||||
cookieHeader: string | undefined,
|
||||
): Promise<void> {
|
||||
const sessionId = parseCookies(cookieHeader)[SESSION_COOKIE];
|
||||
if (sessionId) await kv.delete(`session:${sessionId}`);
|
||||
}
|
||||
38
server/lib/web/tenants.ts
Normal file
38
server/lib/web/tenants.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { log } from "../lib/log";
|
||||
|
||||
/**
|
||||
* Per-group webhook tenant secrets. A group can opt into its own webhook
|
||||
* ingress (`POST /webhook/{groupId}`) with an independent secret, so SaaS
|
||||
* users can configure their own GitHub/Gitea/custom webhooks without sharing
|
||||
* (or even knowing) the operator's global secrets.
|
||||
*/
|
||||
const TENANT_KEY = (groupId: string): string => `tenant:${groupId}`;
|
||||
|
||||
/** 32 random bytes → 64 hex chars. */
|
||||
export function generateTenantSecret(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export async function getTenantSecret(kv: KVNamespace, groupId: string): Promise<string | null> {
|
||||
try {
|
||||
return await kv.get(TENANT_KEY(groupId), "text");
|
||||
} catch (err) {
|
||||
log.warn({ err, groupId }, "Failed to read tenant webhook secret");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate (or regenerate) the group's webhook secret. */
|
||||
export async function setTenantSecret(kv: KVNamespace, groupId: string): Promise<string> {
|
||||
const secret = generateTenantSecret();
|
||||
await kv.put(TENANT_KEY(groupId), secret);
|
||||
return secret;
|
||||
}
|
||||
|
||||
export async function deleteTenantSecret(kv: KVNamespace, groupId: string): Promise<void> {
|
||||
await kv.delete(TENANT_KEY(groupId));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue