Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
DISCORD_APP_ID=
DISCORD_APP_PUBLIC_KEY=

# Settings -> OAuth2
DISCORD_CLIENT_SECRET=

# Settings -> Bot
# Required to register commands and fetch the list of commands in the web app
# Technically not required to actually run the bot
Expand All @@ -12,3 +15,24 @@ DISCORD_BOT_TOKEN=
# Set this with your local ngrok URL for the pokemon images to be served correctly.
# Ex: https://1b539072925d.ngrok.app
ROOT_URL=

# Encryption: a custom secret key for encrypting sensitive data
# This is used to encrypt the user's Discord token in the database
# If you don't set this, the app will use a default key
ENCRYPTION_KEY=

# Prisma / Postgres
# These are used to connect to the database
# See here: https://vercel.com/docs/storage/vercel-postgres/quickstart
POSTGRES_DATABASE=
POSTGRES_HOST=
POSTGRES_PASSWORD=
POSTGRES_PRISMA_URL=
POSTGRES_URL=
POSTGRES_URL_NON_POOLING=
POSTGRES_URL_NO_SSL=

# JWT for cookies
# This is used to sign the JWT token for the user's session
# If you don't set this, the app will use a default key
JWT_SECRET=
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@ yarn-error.log*
.env.development.local
.env.test.local
.env.production.local
.env

# vercel
.vercel

*.tsbuildinfo

notes.md
55 changes: 32 additions & 23 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,46 @@
"version": "1.0.0",
"scripts": {
"dev": "next",
"build": "next build",
"build": "npx prisma generate && next build",
"start": "next start",
"type-check": "tsc",
"register-commands": "tsx ./scripts/register-commands"
},
"dependencies": {
"@t3-oss/env-nextjs": "^0.7.1",
"autoprefixer": "^10.4.16",
"discord-api-types": "^0.37.54",
"dotenv": "^16.3.1",
"ky": "^0.33.3",
"nanoid": "^4.0.2",
"next": "^14.0.2",
"postcss": "^8.4.30",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwindcss": "^3.3.3",
"@prisma/client": "^5.15.0",
"@t3-oss/env-nextjs": "^0.10.1",
"autoprefixer": "^10.4.19",
"axios": "^1.7.2",
"cookie": "^0.6.0",
"crypto-js": "^4.2.0",
"discord-api-types": "^0.37.87",
"dotenv": "^16.4.5",
"jsonwebtoken": "^9.0.2",
"ky": "^1.3.0",
"nanoid": "^5.0.7",
"next": "^14.2.3",
"postcss": "^8.4.38",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.4",
"tweetnacl": "^1.0.3",
"zod": "^3.22.4"
"zod": "^3.23.8"
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^4.1.0",
"@types/node": "^20.5.1",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"eslint": "^8.47.0",
"eslint-config-next": "^13.4.19",
"prettier": "^3.0.2",
"prettier-plugin-tailwindcss": "^0.5.4",
"tsx": "^3.12.7",
"typescript": "^5.1.6"
"@ianvs/prettier-plugin-sort-imports": "^4.2.1",
"@types/cookie": "^0.6.0",
"@types/crypto-js": "^4.2.2",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.14.2",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"eslint": "^9.4.0",
"eslint-config-next": "^14.2.3",
"prettier": "^3.3.1",
"prettier-plugin-tailwindcss": "^0.6.1",
"prisma": "^5.15.0",
"tsx": "^4.12.0",
"typescript": "^5.4.5"
},
"license": "MIT",
"description": "Discord bot template using Next.js that runs at the edge. Uses Discord interactions webhooks to receive commands.",
Expand Down
16 changes: 16 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
generator client {
provider = "prisma-client-js"
}

datasource db {
provider = "postgresql"
url = env("POSTGRES_PRISMA_URL")
directUrl = env("POSTGRES_URL_NON_POOLING")
}

model User {
id String @id @default(uuid())
userId String @unique
accessToken String @unique
refreshToken String @unique
}
88 changes: 88 additions & 0 deletions src/app/api/auth/discord/redirect/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { CONFIG } from "@/config";
import { encryptTokens } from '@/utils/encrypt';
import { OAuth2CrendialsResponse,OAuthTokenExchangeRequestParams } from '@/utils/types';
import { createUser, getUserDetails } from "@/utils/user";
import axios, { AxiosRequestConfig } from 'axios';
import { serialize } from "cookie";
import { sign } from "jsonwebtoken";
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from 'next/server';

const axiosConfig: AxiosRequestConfig = {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
}
}
const buildOAuth2RequestPayload = (data: OAuthTokenExchangeRequestParams) => new URLSearchParams(data).toString();

const scope = ["identify"].join(" ");

const OAUTH_QS = new URLSearchParams({
client_id: process.env.DISCORD_APP_ID!,
redirect_uri: CONFIG.REDIRECT_URI,
response_type: "code",
scope
}).toString();

const OAUTH_URL = `https://discord.com/api/oauth2/authorize?${OAUTH_QS}`;


export async function GET(req: NextRequest) {
const code = req.nextUrl.searchParams.get("code");
const error = req.nextUrl.searchParams.get("error");

if (error) {
return NextResponse.json(JSON.stringify(error), { status: 400 });
}

if (!code) {
return NextResponse.redirect(OAUTH_URL);
}

const body = buildOAuth2RequestPayload({
client_id: process.env.DISCORD_APP_ID!,
client_secret: process.env.DISCORD_CLIENT_SECRET!,
grant_type: "authorization_code",
code,
redirect_uri: CONFIG.REDIRECT_URI,
scope
}).toString();

try {
const { data } = await axios.post<OAuth2CrendialsResponse>(CONFIG.OAUTH2_TOKEN, body, axiosConfig);

const { access_token, refresh_token } = data;

const user = await getUserDetails(access_token);

const encryptedTokens = encryptTokens(access_token, refresh_token);

try {
await createUser({
userId: user.data.id,
accessToken: encryptedTokens.accessToken,
refreshToken: encryptedTokens.refreshToken
});

} catch (e) {
console.log(`Error creating user: ${e}`);
}

if (!("id" in user.data)) return NextResponse.json(JSON.stringify("User not found"), { status: 404 });

const token = sign(user.data, process.env.JWT_SECRET!, { expiresIn: "24h" });

cookies().set(CONFIG.COOKIE_NAME, serialize(CONFIG.COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/"
}));

return NextResponse.redirect(CONFIG.BASE_URL);

} catch (e) {
console.log(`Error exchanging code for token: ${e}`);
return NextResponse.json(JSON.stringify(e), { status: 500 });
}
}
2 changes: 1 addition & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import "./globals.css"
import type { Metadata } from "next"
import { Inter } from "next/font/google"

const inter = Inter({ subsets: ["latin"] })
const inter = Inter({ subsets: ["latin"], preload: true });

export const metadata: Metadata = {
title: "NextBot — Next.js Discord Bot Template",
Expand Down
89 changes: 55 additions & 34 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,64 @@
import { Suspense } from "react"
import { GlobalCommands } from "./global-commands"
import { parseUser } from "@/utils/user"
import { redirect } from "next/navigation";
import { CONFIG } from "@/config";

export default async function Page() {

const user = parseUser();

if (!user) {
return redirect(CONFIG.OAUTH2_INVITE_URL);
}

return (
<main className="container mx-auto px-3 py-6">
<section className="grid grid-cols-1 gap-2">
<h1 className="text-4xl font-bold tracking-tight lg:text-5xl">NextBot</h1>
<h2 className="text-xl tracking-tight text-slate-500">
A Discord bot template built with Next.js that runs in the edge runtime.
</h2>
<div className="flex gap-2">
<a
className="ring-offset-background focus-visible:ring-ring inline-flex h-10 w-fit items-center justify-center rounded-md bg-[#7289DA] px-4 py-2 text-sm font-medium text-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
href="https://discord.gg/NmXuqGgkb3"
target="_blank"
rel="noreferrer"
>
Try it out
</a>
<a
className="ring-offset-background focus-visible:ring-ring inline-flex h-10 w-fit items-center justify-center rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
href="https://github.com/jzxhuang/nextjs-discord-bot"
target="_blank"
rel="noreferrer"
>
Github
</a>
</div>
</section>
<main className="">
<div className="container mx-auto px-3 py-6">
<section className="grid grid-cols-1 gap-2">
<h1 className="text-4xl font-bold tracking-tight lg:text-5xl">NextBot</h1>
<h2 className="text-xl tracking-tight text-slate-500">
A Discord bot template built with Next.js that runs in the edge runtime.
</h2>
<div className="flex gap-2">
<a
className="ring-offset-background focus-visible:ring-ring inline-flex h-10 w-fit items-center justify-center rounded-md bg-[#7289DA] px-4 py-2 text-sm font-medium text-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
href="https://discord.gg/NmXuqGgkb3"
target="_blank"
rel="noreferrer"
>
Try it out
</a>
<a
className="ring-offset-background focus-visible:ring-ring inline-flex h-10 w-fit items-center justify-center rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
href="https://github.com/jzxhuang/nextjs-discord-bot"
target="_blank"
rel="noreferrer"
>
Github
</a>
</div>
</section>

<section className="flex flex-col gap-2 pt-12">
<p className="font-semibold">
This is an example of an admin portal might look like. It leverages RSCs to fetch the Slash commands
associated with the Discord bot!
</p>
<Suspense fallback={null}>
<GlobalCommands />
</Suspense>
</section>

<section className="flex flex-col gap-2 pt-12">
<p className="font-semibold">
This is an example of an admin portal might look like. It leverages RSCs to fetch the Slash commands
associated with the Discord bot!
</p>
<Suspense fallback={null}>
<GlobalCommands />
</Suspense>
</section>
<div className="w-fit px-4 py-2 space-y-2">
<h2 className="text-2xl font-semibold">
Welcome, <span className="font-bold tracking-wider text-zinc-600">{user.username}</span>!
</h2>
<pre className="bg-zinc-100 rounded-md p-2">
<code>{JSON.stringify(user, null, 2)}</code>
</pre>
</div>
</div>
</main>
)
}
10 changes: 10 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const CONFIG = {
REDIRECT_URI: process.env.NODE_ENV === 'production' ? 'https://nextjs-discord-bot-with-oauth.vercel.app/api/auth/discord/redirect' : 'http://localhost:3000/api/auth/discord/redirect',
OAUTH2_TOKEN: 'https://discord.com/api/v10/oauth2/token',
OAUTH2_USER: 'https://discord.com/api/v10/users/@me',
OAUTH2_REVOKE_TOKEN: 'https://discord.com/api/v10/oauth2/token/revoke',
OAUTH2_INVITE_URL: process.env.NODE_ENV === "production" ? "https://discord.com/oauth2/authorize?client_id=1237210318464352266&response_type=code&redirect_uri=https%3A%2F%2Fnextjs-discord-bot-with-oauth.vercel.app%2Fapi%2Fauth%2Fdiscord%2Fredirect&scope=identify+guilds" : "https://discord.com/oauth2/authorize?client_id=1237210318464352266&response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fdiscord%2Fredirect&scope=identify+guilds",
COOKIE_NAME: process.env.COOKIE_NAME! || 'discord-session',
BASE_URL: process.env.NODE_ENV === 'production' ? 'https://nextjs-discord-bot-with-oauth.vercel.app' : 'http://localhost:3000'

}
11 changes: 11 additions & 0 deletions src/lib/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { PrismaClient } from '@prisma/client';

declare global {
var prisma: PrismaClient | undefined;
}

export const db = globalThis.prisma || new PrismaClient();

if (process.env.NODE_ENV !== "production") {
globalThis.prisma = db;
}
12 changes: 12 additions & 0 deletions src/utils/encrypt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import CryptoJS from 'crypto-js';

export const encryptToken = (token: string) => CryptoJS.AES.encrypt(token, process.env.ENCRYPTION_KEY! || "esogUSEGOSJEGSEGi");

export const decryptToken = (encrypted: string) => CryptoJS.AES.decrypt(encrypted, process.env.ENCRYPTION_KEY! || "esogUSEGOSJEGSEGi").toString(CryptoJS.enc.Utf8);

export function encryptTokens(accessToken: string, refreshToken: string): { accessToken: string, refreshToken: string } {
return {
accessToken: encryptToken(accessToken).toString(),
refreshToken: encryptToken(refreshToken).toString()
};
}
Loading