- 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.
171 lines
5.2 KiB
TypeScript
171 lines
5.2 KiB
TypeScript
import { redirect } from 'next/navigation';
|
||
import { NextResponse } from 'next/server';
|
||
import { ZodError } from 'zod';
|
||
import { hasManageGuildPermission } from '@nexumi/shared';
|
||
import { fetchDiscordUserGuildsCached, refreshDiscordToken } from './discord-oauth';
|
||
import { BadRequestError, UpstreamError } from './api-errors';
|
||
import { hasOwnerGuildBypass } from './owner-auth';
|
||
import { prisma } from './prisma';
|
||
import {
|
||
getSession,
|
||
SessionRequiredError,
|
||
updateSession,
|
||
type SessionPayload
|
||
} from './session';
|
||
|
||
export { BadRequestError, UpstreamError } from './api-errors';
|
||
|
||
export class UnauthorizedError extends Error {
|
||
constructor(message = 'Authentication required') {
|
||
super(message);
|
||
this.name = 'UnauthorizedError';
|
||
}
|
||
}
|
||
|
||
export class ForbiddenError extends Error {
|
||
constructor(message = 'You do not have access to this resource') {
|
||
super(message);
|
||
this.name = 'ForbiddenError';
|
||
}
|
||
}
|
||
|
||
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.
|
||
*/
|
||
export async function requireAuth(): Promise<SessionPayload> {
|
||
const session = await getSession();
|
||
if (!session) {
|
||
throw new UnauthorizedError();
|
||
}
|
||
return ensureFreshSession(session);
|
||
}
|
||
|
||
/**
|
||
* Use in Server Components / layouts. Redirects to /login instead of throwing,
|
||
* since there is no JSON response to return.
|
||
*/
|
||
export async function requireAuthOrRedirect(): Promise<SessionPayload> {
|
||
const session = await getSession();
|
||
if (!session) {
|
||
redirect('/login');
|
||
}
|
||
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 with SUPPORT+
|
||
* bypass Discord membership checks. Use in API routes.
|
||
*/
|
||
export async function requireGuildAccess(guildId: string): Promise<SessionPayload> {
|
||
const session = await requireAuth();
|
||
const dbGuild = await prisma.guild.findUnique({ where: { id: guildId } });
|
||
if (!dbGuild) {
|
||
throw new ForbiddenError('Nexumi is not installed on this server');
|
||
}
|
||
|
||
if (await hasOwnerGuildBypass(session.user.id)) {
|
||
return session;
|
||
}
|
||
|
||
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');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Same as {@link requireGuildAccess} but for Server Components: redirects
|
||
* instead of throwing.
|
||
*/
|
||
export async function requireGuildAccessOrRedirect(guildId: string): Promise<SessionPayload> {
|
||
const session = await requireAuthOrRedirect();
|
||
const dbGuild = await prisma.guild.findUnique({ where: { id: guildId } });
|
||
if (!dbGuild) {
|
||
redirect('/dashboard');
|
||
}
|
||
|
||
if (await hasOwnerGuildBypass(session.user.id)) {
|
||
return session;
|
||
}
|
||
|
||
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');
|
||
}
|
||
}
|
||
|
||
export function toApiErrorResponse(error: unknown): NextResponse {
|
||
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 },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
console.error('[api]', error);
|
||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||
}
|