Update configuration and README for improved setup and security; disable default API and emoji sync, and enhance Discord permission checks in API routes.
This commit is contained in:
146
dashboard/app/api/proxy/[...path]/route.ts
Normal file
146
dashboard/app/api/proxy/[...path]/route.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Authenticated server-side proxy to the bot FastAPI backend.
|
||||
* - Requires Discord OAuth session
|
||||
* - Guild routes: user must have Manage Server / Admin on that guild
|
||||
* - Admin routes: user must be in ADMIN_IDS
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { isAdmin } from "@/lib/utils";
|
||||
import { userCanManageGuild } from "@/lib/guild-auth";
|
||||
|
||||
const BOT_API_URL =
|
||||
process.env.DASHBOARD_API_URL ||
|
||||
process.env.NEXT_PUBLIC_API_URL ||
|
||||
"http://127.0.0.1:8000/api/v1";
|
||||
|
||||
const API_KEY = process.env.DASHBOARD_API_KEY;
|
||||
|
||||
type RouteContext = { params: { path: string[] } };
|
||||
|
||||
async function authorizeProxyRequest(
|
||||
path: string,
|
||||
session: { user?: { id?: string }; accessToken?: string }
|
||||
): Promise<NextResponse | null> {
|
||||
if (!session.user) {
|
||||
return NextResponse.json({ detail: "Unauthorized — sign in required." }, { status: 401 });
|
||||
}
|
||||
|
||||
const accessToken = session.accessToken;
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ detail: "Missing Discord access token." }, { status: 401 });
|
||||
}
|
||||
|
||||
const normalized = path.replace(/\/$/, "");
|
||||
|
||||
if (normalized.startsWith("admin")) {
|
||||
if (!isAdmin(session.user.id)) {
|
||||
return NextResponse.json({ detail: "Forbidden — admin access required." }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const guildMatch = normalized.match(/^guilds\/(\d+)/);
|
||||
if (guildMatch) {
|
||||
const guildId = guildMatch[1];
|
||||
const allowed = await userCanManageGuild(accessToken, guildId);
|
||||
if (!allowed) {
|
||||
return NextResponse.json(
|
||||
{ detail: "Forbidden — you cannot manage this server." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function proxyRequest(request: NextRequest, { params }: RouteContext) {
|
||||
if (!API_KEY) {
|
||||
return NextResponse.json(
|
||||
{ detail: "DASHBOARD_API_KEY is not configured on the dashboard server." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
const path = params.path.join("/");
|
||||
|
||||
const authError = await authorizeProxyRequest(path, session ?? {});
|
||||
if (authError) return authError;
|
||||
|
||||
const targetUrl = `${BOT_API_URL.replace(/\/$/, "")}/${path}${request.nextUrl.search}`;
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Authorization", `Bearer ${API_KEY}`);
|
||||
|
||||
if (session?.accessToken) {
|
||||
headers.set("X-Discord-Access-Token", session.accessToken);
|
||||
}
|
||||
if (session?.user?.id) {
|
||||
headers.set("X-Discord-User-Id", session.user.id);
|
||||
}
|
||||
|
||||
const contentType = request.headers.get("content-type");
|
||||
if (contentType) {
|
||||
headers.set("Content-Type", contentType);
|
||||
}
|
||||
|
||||
const init: RequestInit = {
|
||||
method: request.method,
|
||||
headers,
|
||||
cache: "no-store",
|
||||
};
|
||||
|
||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||
init.body = await request.text();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, init);
|
||||
let body = await response.text();
|
||||
|
||||
// Filter guild list to servers the user can manage (bot guild list ∩ user admin guilds)
|
||||
const normalized = path.replace(/\/$/, "");
|
||||
if (
|
||||
request.method === "GET" &&
|
||||
normalized === "guilds" &&
|
||||
response.ok &&
|
||||
session?.accessToken
|
||||
) {
|
||||
try {
|
||||
const botGuilds = JSON.parse(body) as Array<{ id: string }>;
|
||||
const { fetchUserGuilds, canManageGuild } = await import("@/lib/guild-auth");
|
||||
const userGuilds = await fetchUserGuilds(session.accessToken);
|
||||
const manageableIds = new Set(
|
||||
userGuilds.filter(canManageGuild).map((g) => String(g.id))
|
||||
);
|
||||
const filtered = botGuilds.filter((g) => manageableIds.has(String(g.id)));
|
||||
body = JSON.stringify(filtered);
|
||||
} catch {
|
||||
// If filtering fails, return original response rather than breaking the dashboard
|
||||
}
|
||||
}
|
||||
|
||||
return new NextResponse(body, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"Content-Type": response.headers.get("Content-Type") || "application/json",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[API Proxy] Failed to reach bot API at ${targetUrl}:`, error);
|
||||
return NextResponse.json(
|
||||
{ detail: "Could not connect to the bot API backend." },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = proxyRequest;
|
||||
export const POST = proxyRequest;
|
||||
export const PATCH = proxyRequest;
|
||||
export const DELETE = proxyRequest;
|
||||
export const PUT = proxyRequest;
|
||||
Reference in New Issue
Block a user