fix: allow duplicate route IDs across groups and add group_id to send_logs

- Change validateRoutes to per-group ID uniqueness instead of global
- Remove post-merge cross-group route ID uniqueness checks
- Include groupId in msg:* KV key to prevent collision
- Add group_id column to send_logs table (migration 0004)
- Record groupId in send_logs for accurate permission filtering
- Use groupId from log entries for admin log access checks
This commit is contained in:
RhenCloud 2026-08-08 23:00:15 +08:00
parent ab98c84896
commit 03eef7ae89
5 changed files with 26 additions and 29 deletions

View file

@ -0,0 +1 @@
ALTER TABLE send_logs ADD COLUMN group_id TEXT;

View file

@ -6,6 +6,7 @@ function createMockDB(): D1Database {
const insertCols = [ const insertCols = [
"ts", "ts",
"route_id", "route_id",
"group_id",
"event", "event",
"repo", "repo",
"target", "target",

View file

@ -69,6 +69,7 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
const base: { const base: {
ts: number; ts: number;
routeId: string; routeId: string;
groupId: string | undefined;
event: string; event: string;
repo: string | undefined; repo: string | undefined;
target: string; target: string;
@ -78,6 +79,7 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
} = { } = {
ts: Date.now(), ts: Date.now(),
routeId: route.id, routeId: route.id,
groupId: route.groupId,
event: event.event, event: event.event,
repo: (event.payload.repository as { full_name?: string } | undefined)?.full_name, repo: (event.payload.repository as { full_name?: string } | undefined)?.full_name,
target: targetStr, target: targetStr,
@ -91,7 +93,8 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
const driver = getDriver(target); const driver = getDriver(target);
let result: SendResult; let result: SendResult;
if (message.updateKey) { if (message.updateKey) {
const kvKey = `msg:${route.id}:${message.updateKey}:${targetStr}`; const groupPrefix = route.groupId ? `${route.groupId}:` : "";
const kvKey = `msg:${groupPrefix}${route.id}:${message.updateKey}:${targetStr}`;
const existingId = await env.KV.get(kvKey); const existingId = await env.KV.get(kvKey);
if (existingId) { if (existingId) {
result = await driver.edit(message, target, env, existingId); result = await driver.edit(message, target, env, existingId);

View file

@ -4,6 +4,7 @@ export interface SendRecord {
id?: number; id?: number;
ts: number; ts: number;
routeId: string; routeId: string;
groupId?: string;
event: string; event: string;
repo?: string; repo?: string;
target: string; target: string;
@ -22,12 +23,13 @@ export interface SendRecord {
} }
const COLUMNS = const COLUMNS =
"id, ts, route_id, event, repo, target, ok, error, status, message_id, delivery_id, platform, actor, action, duration_ms, error_code, attempts, detail"; "id, ts, route_id, group_id, event, repo, target, ok, error, status, message_id, delivery_id, platform, actor, action, duration_ms, error_code, attempts, detail";
interface LogRow { interface LogRow {
id: number; id: number;
ts: number; ts: number;
route_id: string; route_id: string;
group_id: string | null;
event: string; event: string;
repo: string | null; repo: string | null;
target: string; target: string;
@ -50,6 +52,7 @@ function toRecord(r: LogRow): SendRecord {
id: r.id, id: r.id,
ts: r.ts, ts: r.ts,
routeId: r.route_id, routeId: r.route_id,
groupId: r.group_id ?? undefined,
event: r.event, event: r.event,
repo: r.repo ?? undefined, repo: r.repo ?? undefined,
target: r.target, target: r.target,
@ -72,12 +75,13 @@ export async function recordSend(db: D1Database, record: SendRecord): Promise<vo
try { try {
await db await db
.prepare( .prepare(
`INSERT INTO send_logs (ts, route_id, event, repo, target, ok, error, status, message_id, delivery_id, platform, actor, action, duration_ms, error_code, attempts, detail) `INSERT INTO send_logs (ts, route_id, group_id, event, repo, target, ok, error, status, message_id, delivery_id, platform, actor, action, duration_ms, error_code, attempts, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) )
.bind( .bind(
record.ts, record.ts,
record.routeId, record.routeId,
record.groupId ?? null,
record.event, record.event,
record.repo ?? null, record.repo ?? null,
record.target, record.target,

View file

@ -52,15 +52,23 @@ function validateRoutes(
if (!Array.isArray(routes)) return { ok: false, error: "routes must be an array" }; if (!Array.isArray(routes)) return { ok: false, error: "routes must be an array" };
if (routes.length > 200) return { ok: false, error: "too many routes" }; if (routes.length > 200) return { ok: false, error: "too many routes" };
const seen = new Set<string>(); const seenByGroup = new Map<string, Set<string>>();
for (let i = 0; i < routes.length; i++) { for (let i = 0; i < routes.length; i++) {
const r = routes[i] as Record<string, unknown>; const r = routes[i] as Record<string, unknown>;
if (!r || typeof r !== "object") return { ok: false, error: `route[${i}] is not an object` }; 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)) { if (typeof r.id !== "string" || !ID_RE.test(r.id)) {
return { ok: false, error: `route[${i}].id is invalid` }; return { ok: false, error: `route[${i}].id is invalid` };
} }
if (seen.has(r.id)) return { ok: false, error: `duplicate route id "${r.id}"` }; const gid = (r.groupId as string) ?? "__nogroup__";
seen.add(r.id); 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. // Skip full validation for routes that are unchanged from what is stored.
const prev = unchanged?.get(r.id); const prev = unchanged?.get(r.id);
if (prev && deepEqual(r, prev)) continue; if (prev && deepEqual(r, prev)) continue;
@ -282,12 +290,8 @@ export function createAdminRoutes(): Hono<{ Bindings: Env }> {
if (s.scope.isSuper) { if (s.scope.isSuper) {
return c.json({ logs: await getSendLog(c.env.DB, limit) }); return c.json({ logs: await getSendLog(c.env.DB, limit) });
} }
const all = await loadRoutes(c.env.KV);
const allowed = new Set(
all.filter((r) => r.groupId != null && s.scope.groupIds.has(r.groupId)).map((r) => r.id),
);
const logs = (await getSendLog(c.env.DB, 200)) const logs = (await getSendLog(c.env.DB, 200))
.filter((l) => allowed.has(l.routeId)) .filter((l) => l.groupId != null && s.scope.groupIds.has(l.groupId))
.slice(0, limit); .slice(0, limit);
return c.json({ logs }); return c.json({ logs });
}); });
@ -300,9 +304,7 @@ export function createAdminRoutes(): Hono<{ Bindings: Env }> {
const entry = await getSendLogById(c.env.DB, id); const entry = await getSendLogById(c.env.DB, id);
if (!entry) return c.json({ error: "Log entry not found" }, 404); if (!entry) return c.json({ error: "Log entry not found" }, 404);
if (!s.scope.isSuper) { if (!s.scope.isSuper) {
const all = await loadRoutes(c.env.KV); if (!entry.groupId || !s.scope.groupIds.has(entry.groupId)) {
const route = all.find((r) => r.id === entry.routeId);
if (!route?.groupId || !s.scope.groupIds.has(route.groupId)) {
return c.json({ error: "Forbidden" }, 403); return c.json({ error: "Forbidden" }, 403);
} }
} }
@ -344,13 +346,6 @@ export function createAdminRoutes(): Hono<{ Bindings: Env }> {
]; ];
} }
// Guard against duplicate ids across the merged set.
const ids = new Set<string>();
for (const r of nextAll) {
if (ids.has(r.id)) return c.json({ error: `duplicate route id "${r.id}"` }, 400);
ids.add(r.id);
}
try { try {
await saveRoutes(c.env.KV, nextAll); await saveRoutes(c.env.KV, nextAll);
} catch (err) { } catch (err) {
@ -422,13 +417,6 @@ export function createAdminRoutes(): Hono<{ Bindings: Env }> {
const others = existing.filter((r) => r.groupId !== groupId); const others = existing.filter((r) => r.groupId !== groupId);
const nextAll = [...others, ...result.routes]; const nextAll = [...others, ...result.routes];
// Guard against ids colliding with routes in other groups.
const ids = new Set<string>();
for (const r of nextAll) {
if (ids.has(r.id)) return c.json({ error: `duplicate route id "${r.id}"` }, 400);
ids.add(r.id);
}
try { try {
await saveRoutes(c.env.KV, nextAll); await saveRoutes(c.env.KV, nextAll);
} catch (err) { } catch (err) {