Implement command cooldown management and enhance command execution flow
- Introduced `applyCommandCooldown` function to manage per-command cooldowns after execution, improving command rate limiting. - Updated command execution flow to include cooldown application, ensuring users are informed of cooldown status. - Enhanced error handling in interaction replies, allowing for more graceful error management and user feedback. - Improved the handling of interaction responses across various commands, ensuring consistent user experience. - Added permission checks for new commands, ensuring only authorized users can execute specific actions.
This commit is contained in:
@@ -2,10 +2,18 @@ import { redirect } from 'next/navigation';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { ZodError } from 'zod';
|
||||
import { hasManageGuildPermission } from '@nexumi/shared';
|
||||
import { fetchDiscordUserGuildsCached } from './discord-oauth';
|
||||
import { fetchDiscordUserGuildsCached, refreshDiscordToken } from './discord-oauth';
|
||||
import { BadRequestError, UpstreamError } from './api-errors';
|
||||
import { hasOwnerGuildBypass } from './owner-auth';
|
||||
import { prisma } from './prisma';
|
||||
import { getSession, type SessionPayload } from './session';
|
||||
import {
|
||||
getSession,
|
||||
SessionRequiredError,
|
||||
updateSession,
|
||||
type SessionPayload
|
||||
} from './session';
|
||||
|
||||
export { BadRequestError, UpstreamError } from './api-errors';
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor(message = 'Authentication required') {
|
||||
@@ -21,6 +29,37 @@ export class ForbiddenError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const TOKEN_REFRESH_SKEW_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Refreshes the Discord access token when expired (or about to expire).
|
||||
* Throws {@link UnauthorizedError} when refresh is impossible.
|
||||
*/
|
||||
export async function ensureFreshSession(session: SessionPayload): Promise<SessionPayload> {
|
||||
const expiresAt = session.tokenExpiresAt ?? 0;
|
||||
if (expiresAt > Date.now() + TOKEN_REFRESH_SKEW_MS) {
|
||||
return session;
|
||||
}
|
||||
|
||||
if (!session.refreshToken) {
|
||||
throw new UnauthorizedError('Session expired – please log in again');
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await refreshDiscordToken(session.refreshToken);
|
||||
const next: SessionPayload = {
|
||||
...session,
|
||||
accessToken: token.access_token,
|
||||
refreshToken: token.refresh_token || session.refreshToken,
|
||||
tokenExpiresAt: Date.now() + token.expires_in * 1000
|
||||
};
|
||||
await updateSession(next);
|
||||
return next;
|
||||
} catch {
|
||||
throw new UnauthorizedError('Session expired – please log in again');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use in API routes. Throws {@link UnauthorizedError} when no session exists.
|
||||
*/
|
||||
@@ -29,7 +68,7 @@ export async function requireAuth(): Promise<SessionPayload> {
|
||||
if (!session) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
return session;
|
||||
return ensureFreshSession(session);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,14 +80,18 @@ export async function requireAuthOrRedirect(): Promise<SessionPayload> {
|
||||
if (!session) {
|
||||
redirect('/login');
|
||||
}
|
||||
return session;
|
||||
try {
|
||||
return await ensureFreshSession(session);
|
||||
} catch {
|
||||
redirect('/login');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the current session user has Manage Guild permission on the given
|
||||
* guild (via Discord OAuth guild list) and that the bot is actually installed
|
||||
* there (tracked via the `Guild` table). Owner-panel users bypass Discord
|
||||
* membership checks. Use in API routes.
|
||||
* there (tracked via the `Guild` table). Owner-panel users with SUPPORT+
|
||||
* bypass Discord membership checks. Use in API routes.
|
||||
*/
|
||||
export async function requireGuildAccess(guildId: string): Promise<SessionPayload> {
|
||||
const session = await requireAuth();
|
||||
@@ -61,12 +104,19 @@ export async function requireGuildAccess(guildId: string): Promise<SessionPayloa
|
||||
return session;
|
||||
}
|
||||
|
||||
const guilds = await fetchDiscordUserGuildsCached(session.accessToken);
|
||||
const guild = guilds.find((entry) => entry.id === guildId);
|
||||
if (!guild || !hasManageGuildPermission(guild.permissions)) {
|
||||
throw new ForbiddenError('Missing Manage Guild permission for this server');
|
||||
try {
|
||||
const guilds = await fetchDiscordUserGuildsCached(session.accessToken);
|
||||
const guild = guilds.find((entry) => entry.id === guildId);
|
||||
if (!guild || !hasManageGuildPermission(guild.permissions)) {
|
||||
throw new ForbiddenError('Missing Manage Guild permission for this server');
|
||||
}
|
||||
return session;
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError) {
|
||||
throw error;
|
||||
}
|
||||
throw new UnauthorizedError('Discord session expired – please log in again');
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,21 +134,31 @@ export async function requireGuildAccessOrRedirect(guildId: string): Promise<Ses
|
||||
return session;
|
||||
}
|
||||
|
||||
const guilds = await fetchDiscordUserGuildsCached(session.accessToken);
|
||||
const guild = guilds.find((entry) => entry.id === guildId);
|
||||
if (!guild || !hasManageGuildPermission(guild.permissions)) {
|
||||
redirect('/dashboard');
|
||||
try {
|
||||
const guilds = await fetchDiscordUserGuildsCached(session.accessToken);
|
||||
const guild = guilds.find((entry) => entry.id === guildId);
|
||||
if (!guild || !hasManageGuildPermission(guild.permissions)) {
|
||||
redirect('/dashboard');
|
||||
}
|
||||
return session;
|
||||
} catch {
|
||||
redirect('/login');
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export function toApiErrorResponse(error: unknown): NextResponse {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
if (error instanceof UnauthorizedError || error instanceof SessionRequiredError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 401 });
|
||||
}
|
||||
if (error instanceof ForbiddenError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 403 });
|
||||
}
|
||||
if (error instanceof BadRequestError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
if (error instanceof UpstreamError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status });
|
||||
}
|
||||
if (error instanceof ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation failed', issues: error.issues },
|
||||
|
||||
Reference in New Issue
Block a user