Files
Nexumi/apps/webui/src/lib/auth.ts
TheOnlyMace 8513848440 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.
2026-07-25 15:37:56 +02:00

171 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 });
}