added frontend + securing beta server invites
This commit is contained in:
2
web/app/api/auth/[...nextauth]/route.ts
Normal file
2
web/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { handlers } from "@/auth" // Referring to the auth.ts we just created
|
||||
export const { GET, POST } = handlers
|
||||
126
web/app/api/guilds/[guildId]/level_bridger/route.ts
Normal file
126
web/app/api/guilds/[guildId]/level_bridger/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { auth } from "@/auth";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getLevelBridges, createLevelBridge, updateLevelBridge, deleteLevelBridge } from "@/lib/discord";
|
||||
|
||||
// GET - Fetch level bridges for a guild
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ guildId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
const { guildId } = await params;
|
||||
|
||||
if (!session?.accessToken) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const bridges = await getLevelBridges(guildId, session.accessToken as string);
|
||||
return NextResponse.json(bridges);
|
||||
} catch (error) {
|
||||
console.error("Error fetching bridges:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Create a new bridge
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ guildId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
const { guildId } = await params;
|
||||
|
||||
if (!session?.accessToken) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const result = await createLevelBridge(guildId, body, session.accessToken as string);
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error || "Failed to create bridge" },
|
||||
{ status: 400, statusText: result.error } // Pass status text sometimes helps debugging
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error creating bridge:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT - Update (or create) a bridge
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ guildId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
const { guildId } = await params;
|
||||
|
||||
if (!session?.accessToken) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const result = await updateLevelBridge(guildId, body, session.accessToken as string);
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error || "Failed to update bridge" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error updating bridge:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Delete a bridge
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ guildId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
const { guildId } = await params;
|
||||
|
||||
if (!session?.accessToken) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const result = await deleteLevelBridge(guildId, body.in_role_id, session.accessToken as string);
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error || "Failed to delete bridge" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error deleting bridge:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
27
web/app/api/guilds/[guildId]/roles/route.ts
Normal file
27
web/app/api/guilds/[guildId]/roles/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { auth } from "@/auth";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getGuildRoles } from "@/lib/discord";
|
||||
|
||||
// GET - Fetch all roles for a guild
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ guildId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
const { guildId } = await params;
|
||||
|
||||
if (!session?.accessToken) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const roles = await getGuildRoles(guildId, session.accessToken as string);
|
||||
return NextResponse.json(roles);
|
||||
} catch (error) {
|
||||
console.error("Error fetching roles:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
126
web/app/api/guilds/[guildId]/tracks/route.ts
Normal file
126
web/app/api/guilds/[guildId]/tracks/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { auth } from "@/auth";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getLevelTracks, createLevelTrack, updateLevelTrack, deleteLevelTrack } from "@/lib/discord";
|
||||
|
||||
// GET - Fetch level tracks for a guild
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ guildId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
const { guildId } = await params;
|
||||
|
||||
if (!session?.accessToken) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const tracks = await getLevelTracks(guildId, session.accessToken as string);
|
||||
return NextResponse.json(tracks);
|
||||
} catch (error) {
|
||||
console.error("Error fetching tracks:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Create a new track
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ guildId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
const { guildId } = await params;
|
||||
|
||||
if (!session?.accessToken) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const result = await createLevelTrack(guildId, body, session.accessToken as string);
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error || "Failed to create track" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error creating track:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH - Update an existing track
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ guildId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
const { guildId } = await params;
|
||||
|
||||
if (!session?.accessToken) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const result = await updateLevelTrack(guildId, body, session.accessToken as string);
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error || "Failed to update track" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error updating track:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Delete a track
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ guildId: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
const { guildId } = await params;
|
||||
|
||||
if (!session?.accessToken) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const result = await deleteLevelTrack(guildId, body.track_name, session.accessToken as string);
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error || "Failed to delete track" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error deleting track:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
85
web/app/api/oauth/callback/route.ts
Normal file
85
web/app/api/oauth/callback/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { checkBetaServer } from "@/lib/discord";
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
const guildId = searchParams.get("guild_id");
|
||||
const error = searchParams.get("error");
|
||||
|
||||
if (error) {
|
||||
return NextResponse.redirect(new URL("/dashboard?error=access_denied", request.url));
|
||||
}
|
||||
|
||||
if (!code || !state || !guildId) {
|
||||
return NextResponse.redirect(new URL("/dashboard?error=invalid_request", request.url));
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const storedState = cookieStore.get("oauth_invite_state")?.value;
|
||||
const storedGuildId = cookieStore.get("oauth_invite_guild")?.value;
|
||||
|
||||
// 1. Verify State
|
||||
if (!storedState || state !== storedState) {
|
||||
return NextResponse.redirect(new URL("/dashboard?error=state_mismatch", request.url));
|
||||
}
|
||||
|
||||
// 2. Verify Guild ID Match (Optional but recommended extra layer)
|
||||
if (storedGuildId && guildId !== storedGuildId) {
|
||||
return NextResponse.redirect(new URL("/dashboard?error=guild_mismatch", request.url));
|
||||
}
|
||||
|
||||
// 3. CRITICAL: Check Beta Server Eligibility
|
||||
// We only exchange the code (and thus add the bot) if this check passes.
|
||||
const [isBeta] = await checkBetaServer([guildId]);
|
||||
|
||||
if (!isBeta) {
|
||||
console.warn(`Blocked attempt to add bot to non-beta server: ${guildId}`);
|
||||
return NextResponse.redirect(new URL("/dashboard?error=not_beta_server", request.url));
|
||||
}
|
||||
|
||||
// 4. Exchange Code for Token (Finalize Bot Join)
|
||||
const appUrl = process.env.APP_URL;
|
||||
if (!appUrl) {
|
||||
console.error("APP_URL env var is not set");
|
||||
return NextResponse.redirect(new URL("/dashboard?error=config_error", request.url));
|
||||
}
|
||||
const redirectUri = `${appUrl}/api/oauth/callback`;
|
||||
|
||||
const tokenResponse = await fetch("https://discord.com/api/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: process.env.AUTH_DISCORD_ID as string,
|
||||
client_secret: process.env.AUTH_DISCORD_SECRET as string,
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
console.error("Failed to exchange token:", errorText);
|
||||
return NextResponse.redirect(new URL("/dashboard?error=token_exchange_failed", request.url));
|
||||
}
|
||||
|
||||
// Clean up cookies
|
||||
cookieStore.delete("oauth_invite_state");
|
||||
cookieStore.delete("oauth_invite_guild");
|
||||
|
||||
// Success!
|
||||
return NextResponse.redirect(new URL(`/dashboard?success=bot_added&guild_id=${guildId}`, request.url));
|
||||
|
||||
} catch (error) {
|
||||
console.error("Callback handler error:", error);
|
||||
return NextResponse.redirect(new URL("/dashboard?error=internal_server_error", request.url));
|
||||
}
|
||||
}
|
||||
66
web/app/api/oauth/invite/route.ts
Normal file
66
web/app/api/oauth/invite/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import crypto from "crypto";
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const guildId = searchParams.get("guild_id");
|
||||
|
||||
if (!guildId) {
|
||||
return new NextResponse("Missing guild_id", { status: 400 });
|
||||
}
|
||||
|
||||
// Generate a random state for security
|
||||
const state = crypto.randomBytes(16).toString("hex");
|
||||
|
||||
// Set the state in a secure cookie (HttpOnly, Secure, SameSite)
|
||||
// This cookie allows us to verify the callback originated from this flow
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("oauth_invite_state", state, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 300, // 5 minutes to complete the flow
|
||||
path: "/",
|
||||
});
|
||||
|
||||
// Store the target guild ID in a cookie too, to verify against the callback's guild_id
|
||||
// This prevents someone from starting a flow for Guild A and swapping the callback to Guild B (though state mismatch would likely catch it too)
|
||||
cookieStore.set("oauth_invite_guild", guildId, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 300,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
const appUrl = process.env.APP_URL;
|
||||
if (!appUrl) {
|
||||
console.error("APP_URL env var is not set");
|
||||
return new NextResponse("Configuration Error", { status: 500 });
|
||||
}
|
||||
const redirectUri = `${appUrl}/api/oauth/callback`;
|
||||
const clientId = process.env.AUTH_DISCORD_ID;
|
||||
|
||||
// Construct Discord OAuth2 Authorization URL
|
||||
// We use response_type=code because we enabled "Requires OAuth2 Code Grant" for the bot
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId as string,
|
||||
permissions: "8", // Administrator (as requested in original link)
|
||||
scope: "bot", // Add 'applications.commands' if needed, but 'bot' is the primary one here
|
||||
redirect_uri: redirectUri,
|
||||
response_type: "code",
|
||||
state: state,
|
||||
guild_id: guildId,
|
||||
disable_guild_select: "true",
|
||||
});
|
||||
|
||||
return NextResponse.redirect(`https://discord.com/oauth2/authorize?${params.toString()}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to initiate invite flow:", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user