mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
fix(invites): list invites via per-group KV index instead of kv.list
Cloudflare KV's list operation is eventually consistent and can lag
behind a fresh write, so pending invites were invisible right after
creation (GET /admin/api/groups/:id/invites returned {invites: []}).
Keep a per-group token index (invite:group:{id}) updated on create,
revoke, accept and expiry; the members panel also inserts the freshly
created invite into the list immediately and copies the absolute link.
This commit is contained in:
parent
ce9f6147f1
commit
a0e15975df
4 changed files with 76 additions and 8 deletions
|
|
@ -162,9 +162,25 @@ async function createInvite(): Promise<void> {
|
||||||
formError.value = "";
|
formError.value = "";
|
||||||
try {
|
try {
|
||||||
const url = await invitesApi.create(props.group.id, inviteRole.value);
|
const url = await invitesApi.create(props.group.id, inviteRole.value);
|
||||||
|
const token = url.split("token=")[1] ?? "";
|
||||||
|
if (token) {
|
||||||
|
invites.value = [
|
||||||
|
{
|
||||||
|
token,
|
||||||
|
groupId: props.group.id,
|
||||||
|
role: inviteRole.value,
|
||||||
|
expiresAt: Date.now() + 7 * 86400_000,
|
||||||
|
createdBy: "",
|
||||||
|
},
|
||||||
|
...invites.value,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
copyText(`${window.location.origin}${url}`);
|
||||||
|
copiedToken.value = token;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
copiedToken.value = "";
|
||||||
|
}, 2000);
|
||||||
await loadInvites();
|
await loadInvites();
|
||||||
copyText(url);
|
|
||||||
copiedToken.value = "";
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
formError.value = err instanceof Error ? err.message : String(err);
|
formError.value = err instanceof Error ? err.message : String(err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,7 @@ Filters accept either a single string or an array of strings:
|
||||||
| `token-reverse:{sha256}` | User id for reverse lookup by token | 0.9 × token expiry |
|
| `token-reverse:{sha256}` | User id for reverse lookup by token | 0.9 × token expiry |
|
||||||
| `state:{hex}` | `{ redirectTo, expiresAt, discordUserId?, telegramUserId?, telegramChatId? }` | 600 seconds |
|
| `state:{hex}` | `{ redirectTo, expiresAt, discordUserId?, telegramUserId?, telegramChatId? }` | 600 seconds |
|
||||||
| `invite:{token}` | `{ groupId, role, expiresAt, createdBy, note? }` | 7 days |
|
| `invite:{token}` | `{ groupId, role, expiresAt, createdBy, note? }` | 7 days |
|
||||||
|
| `invite:group:{id}` | Token index per group (keeps invite listing consistent) | Permanent |
|
||||||
| `delivery:{id}` | Webhook delivery id (dedup marker) | 300 seconds |
|
| `delivery:{id}` | Webhook delivery id (dedup marker) | 300 seconds |
|
||||||
| `msg:{routeId}:{key}:{target}` | Message id tracking for in-place updates (e.g. `workflow_run`) | 7 days |
|
| `msg:{routeId}:{key}:{target}` | Message id tracking for in-place updates (e.g. `workflow_run`) | 7 days |
|
||||||
| `cmd:guild:{id}` | Guild id whose commands were registered (dedup) | Permanent |
|
| `cmd:guild:{id}` | Guild id whose commands were registered (dedup) | Permanent |
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,7 @@ owner(及超级管理员)可在分组的「成员」面板创建一次性邀
|
||||||
| `token-reverse:{sha256}` | 用于按 Token 反查的用户 id | 0.9 × Token 有效期 |
|
| `token-reverse:{sha256}` | 用于按 Token 反查的用户 id | 0.9 × Token 有效期 |
|
||||||
| `state:{hex}` | `{ redirectTo, expiresAt, discordUserId?, telegramUserId?, telegramChatId? }` | 600 秒 |
|
| `state:{hex}` | `{ redirectTo, expiresAt, discordUserId?, telegramUserId?, telegramChatId? }` | 600 秒 |
|
||||||
| `invite:{token}` | `{ groupId, role, expiresAt, createdBy, note? }` | 7 天 |
|
| `invite:{token}` | `{ groupId, role, expiresAt, createdBy, note? }` | 7 天 |
|
||||||
|
| `invite:group:{id}` | 每组的 Token 索引(保证邀请列表一致性) | 永久 |
|
||||||
| `delivery:{id}` | Webhook 投递 id(去重标记) | 300 秒 |
|
| `delivery:{id}` | Webhook 投递 id(去重标记) | 300 秒 |
|
||||||
| `msg:{routeId}:{key}:{target}` | 原地更新用消息 id 追踪(如 `workflow_run`) | 7 天 |
|
| `msg:{routeId}:{key}:{target}` | 原地更新用消息 id 追踪(如 `workflow_run`) | 7 天 |
|
||||||
| `cmd:guild:{id}` | 已注册命令的服务器 id(去重标记) | 永久 |
|
| `cmd:guild:{id}` | 已注册命令的服务器 id(去重标记) | 永久 |
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,40 @@ function inviteKey(token: string): string {
|
||||||
return `invite:${token}`;
|
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> {
|
export async function createInvite(kv: KVNamespace, invite: Invite): Promise<string> {
|
||||||
const token = generateInviteToken();
|
const token = generateInviteToken();
|
||||||
await kv.put(inviteKey(token), JSON.stringify(invite), { expirationTtl: INVITE_TTL });
|
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;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,6 +67,7 @@ export async function getInvite(kv: KVNamespace, token: string): Promise<Invite
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
if (Date.now() > raw.expiresAt) {
|
if (Date.now() > raw.expiresAt) {
|
||||||
await kv.delete(inviteKey(token));
|
await kv.delete(inviteKey(token));
|
||||||
|
await removeFromIndex(kv, raw.groupId, token);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return raw;
|
return raw;
|
||||||
|
|
@ -46,18 +78,34 @@ export async function getInvite(kv: KVNamespace, token: string): Promise<Invite
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function consumeInvite(kv: KVNamespace, token: string): Promise<void> {
|
export async function consumeInvite(kv: KVNamespace, token: string): Promise<void> {
|
||||||
|
const raw = await kv.get<Invite>(inviteKey(token), "json");
|
||||||
await kv.delete(inviteKey(token));
|
await kv.delete(inviteKey(token));
|
||||||
|
if (raw) await removeFromIndex(kv, raw.groupId, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All pending (unexpired) invites of a group. */
|
/** 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 }>> {
|
export async function listInvites(
|
||||||
|
kv: KVNamespace,
|
||||||
|
groupId: string,
|
||||||
|
): Promise<Array<Invite & { token: string }>> {
|
||||||
try {
|
try {
|
||||||
const { keys } = await kv.list({ prefix: "invite:" });
|
const tokens = await readIndex(kv, groupId);
|
||||||
const out: Array<Invite & { token: string }> = [];
|
const out: Array<Invite & { token: string }> = [];
|
||||||
for (const key of keys) {
|
const stale: string[] = [];
|
||||||
const token = key.name.slice("invite:".length);
|
for (const token of tokens) {
|
||||||
const invite = await getInvite(kv, token);
|
const invite = await getInvite(kv, token);
|
||||||
if (invite && invite.groupId === groupId) out.push({ ...invite, 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;
|
return out;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -67,7 +115,9 @@ export async function listInvites(kv: KVNamespace, groupId: string): Promise<Arr
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revokeInvite(kv: KVNamespace, token: string): Promise<void> {
|
export async function revokeInvite(kv: KVNamespace, token: string): Promise<void> {
|
||||||
|
const raw = await kv.get<Invite>(inviteKey(token), "json");
|
||||||
await kv.delete(inviteKey(token));
|
await kv.delete(inviteKey(token));
|
||||||
|
if (raw) await removeFromIndex(kv, raw.groupId, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue