From 9905a83fa061df80b33d24b4fff377b0bbebf194 Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Tue, 24 Dec 2024 12:12:35 +0800 Subject: [PATCH 1/8] feat: add oauth and basic dashboard --- .env.example | 5 + api/package.json | 2 + api/src/db/init.ts | 19 ++ api/src/db/queries/oauth-users.ts | 67 ++++ api/src/index.ts | 393 +++++++++++++++++++++- bun.lockb | Bin 486464 -> 493008 bytes web/components/icons.tsx | 17 + web/components/navbar.tsx | 146 ++++++-- web/config/site.ts | 4 - web/lib/queries.tsx | 33 ++ web/package.json | 1 + web/pages/_app.tsx | 7 +- web/pages/dashboard/index.tsx | 105 ++++++ web/pages/leaderboard/[server].tsx | 19 +- web/pages/leaderboard/[server]/[user].tsx | 19 +- 15 files changed, 767 insertions(+), 70 deletions(-) create mode 100644 api/src/db/queries/oauth-users.ts mode change 100644 => 100755 bun.lockb create mode 100644 web/lib/queries.tsx create mode 100644 web/pages/dashboard/index.tsx diff --git a/.env.example b/.env.example index b86308b..dbdf6da 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,9 @@ DISCORD_TOKEN='TOKEN' DISCORD_TOKEN_DEV='DEV_TOKEN' +DISCORD_CLIENT_ID='CLIENT_ID' +DISCORD_CLIENT_SECRET='CLIENT_SECRET' +WEBSITE_URL='http://localhost:3000' +NEXT_PUBLIC_API_URL='http://localhost:18103' MYSQL_ADDRESS='YOUR_MYSQL_SERVER_ADDRESS' MYSQL_PORT='YOUR_MYSQL_SERVER_PORT' @@ -7,4 +11,5 @@ MYSQL_USER='YOUR_MYSQL_USER' MYSQL_PASSWORD='YOUR_MYSQL_PASSWORD' MYSQL_DATABASE='YOUR_DATABASE_NAME' +JWT_SECRET='YOUR_JWT_SECRET' AUTH="AUTH_KEY_FOR_API" \ No newline at end of file diff --git a/api/package.json b/api/package.json index fb64225..ef5e619 100644 --- a/api/package.json +++ b/api/package.json @@ -10,12 +10,14 @@ "cors": "^2.8.5", "cron": "^3.1.7", "express": "^4.19.2", + "jsonwebtoken": "^9.0.2", "mysql2": "^3.10.3" }, "devDependencies": { "@types/bun": "latest", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.7", "dotenv-cli": "^7.4.2" } } diff --git a/api/src/db/init.ts b/api/src/db/init.ts index dfaf7db..b75e132 100644 --- a/api/src/db/init.ts +++ b/api/src/db/init.ts @@ -44,6 +44,17 @@ export async function initTables() { xp INT NOT NULL ) `; + const createOauthUsersTable = ` + CREATE TABLE IF NOT EXISTS oauth_users ( + id VARCHAR(255) NOT NULL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + username VARCHAR(255) NOT NULL, + avatar VARCHAR(255) NOT NULL, + access_token VARCHAR(255) NOT NULL, + refresh_token VARCHAR(255) NOT NULL, + expires_at TIMESTAMP NOT NULL + ) + `; pool.query(createGuildsTable, (err) => { if (err) { @@ -76,4 +87,12 @@ export async function initTables() { console.log("Tracking table created"); } }); + + pool.query(createOauthUsersTable, (err) => { + if (err) { + console.error("Error creating OAuth users table:", err); + } else { + console.log("OAuth users table created"); + } + }); } diff --git a/api/src/db/queries/oauth-users.ts b/api/src/db/queries/oauth-users.ts new file mode 100644 index 0000000..9f969f8 --- /dev/null +++ b/api/src/db/queries/oauth-users.ts @@ -0,0 +1,67 @@ +import type { QueryError } from "mysql2"; + +import { pool } from ".."; + +export interface OAuthUser { + id: string; + name: string; + username: string; + avatar: string; + access_token: string; + refresh_token: string; + expires_at: Date; +} + +export function getOAuthUser( + id: string +): Promise<[QueryError, null] | [null, OAuthUser]> { + return new Promise((resolve, reject) => { + pool.query( + "SELECT * FROM oauth_users WHERE id = ?", + [id], + (err, results) => { + if (err) { + reject([err, null]); + } else { + resolve([null, (results as OAuthUser[])[0]]); + } + } + ); + }); +} + +export function updateOAuthUser( + oauthUser: Partial +): Promise<[QueryError, false] | [null, true]> { + return new Promise((resolve, reject) => { + pool.query( + ` + INSERT INTO oauth_users (id, name, username, avatar, access_token, refresh_token, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + username = VALUES(username), + avatar = VALUES(avatar), + access_token = VALUES(access_token), + refresh_token = VALUES(refresh_token), + expires_at = VALUES(expires_at) + `, + [ + oauthUser.id, + oauthUser.name, + oauthUser.username, + oauthUser.avatar, + oauthUser.access_token, + oauthUser.refresh_token, + oauthUser.expires_at, + ], + (err) => { + if (err) { + reject([err, false]); + } else { + resolve([null, true]); + } + } + ); + }); +} diff --git a/api/src/index.ts b/api/src/index.ts index c579d15..32de343 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,9 +1,14 @@ +import type { RowDataPacket } from "mysql2"; + +import crypto from "node:crypto"; + import express, { type NextFunction, type Request, type Response, } from "express"; import cors from "cors"; +import jwt, { type JwtPayload } from "jsonwebtoken"; import { getBotInfo, @@ -26,12 +31,25 @@ import { getGuildTrackingData, getUsersTrackingData, } from "./db"; +import { + getOAuthUser, + updateOAuthUser, + type OAuthUser, +} from "./db/queries/oauth-users"; const app = express(); const PORT = 18103; app.use(cors()); app.use(express.json()); +app.use((req, _res, next) => { + if (req.headers.cookie) { + const cookies = parseCookies(req.headers.cookie); + + req.cookies = cookies; + } + next(); +}); app.disable("x-powered-by"); @@ -210,7 +228,7 @@ app.get("/get/dbusage", (_req, res) => { .status(500) .json({ message: "Internal server error" }); } else { - const discordXpBot = results.find( + const discordXpBot = (results as RowDataPacket[]).find( (result) => result.name === process.env.MYSQL_DATABASE ); @@ -687,14 +705,282 @@ app.post("/admin/:action/:guild/:target", authMiddleware, async (req, res) => { } }); -app.get("/invite", (_req, res) => - res - .status(308) - .redirect( - "https://discord.com/oauth2/authorize?client_id=1245807579624378601&permissions=1099780115520&integration_type=0&scope=bot+applications.commands" - ) +const API_URL = + process.env.NODE_ENV === "development" + ? `http://localhost:${PORT}` + : "https://api.chatr.fun"; +const WEBSITE_URL = + process.env.NODE_ENV === "development" + ? `http://localhost:56413` + : "https://chatr.fun"; +const REDIRECT_URI = `${API_URL}/auth/callback`; + +app.get("/auth/login", (_req, res) => { + const params = new URLSearchParams(); + const state = crypto.randomBytes(32).toString("hex"); + + params.append("client_id", process.env.DISCORD_CLIENT_ID!); + params.append("redirect_uri", REDIRECT_URI); + params.append("response_type", "code"); + params.append("scope", "identify guilds"); + params.append("state", state); + + res.appendHeader( + "Set-Cookie", + serializeCookie("state", state, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 60 * 10, + }) + ); + res.redirect(`https://discord.com/oauth2/authorize?${params.toString()}`); +}); + +app.get("/auth/callback", async (req, res) => { + const { code, state } = req.query; + const storedState = req.cookies.get("state"); + + if ( + !code || + typeof code !== "string" || + !state || + typeof state !== "string" || + !storedState + ) + return res.status(400).json({ message: "Illegal request" }); + + if (state !== storedState) + return res.status(400).json({ message: "Invalid state" }); + + const body = new URLSearchParams(); + + body.append("client_id", process.env.DISCORD_CLIENT_ID!); + body.append("client_secret", process.env.DISCORD_CLIENT_SECRET!); + body.append("grant_type", "authorization_code"); + body.append("code", code); + body.append("redirect_uri", REDIRECT_URI); + body.append("scope", "identify guilds"); + + const tokenResponse = await fetch("https://discord.com/api/oauth2/token", { + method: "POST", + body, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + + if (tokenResponse.status !== 200) { + console.error("Error fetching token:", tokenResponse); + + return res.status(500).json({ message: "Internal server error" }); + } + + const tokenData = await tokenResponse.json(); + + const userResponse = await fetch("https://discord.com/api/users/@me", { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + }, + }); + + if (userResponse.status !== 200) { + console.error("Error fetching user:", userResponse); + + return res.status(500).json({ message: "Internal server error" }); + } + + const userData = await userResponse.json(); + + const [err, success] = await updateOAuthUser({ + id: userData.id, + name: userData.display_name ?? userData.username, + username: userData.username, + avatar: `https://cdn.discordapp.com/avatars/${userData.id}/${userData.avatar}.webp`, + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token, + expires_at: new Date( + new Date().getTime() + tokenData.expires_in * 1000 + ), + }); + + if (!success) { + console.error("Error updating OAuth user:", err); + + return res.status(500).json({ message: "Internal server error" }); + } + + const token = jwt.sign( + { + sub: userData.id, + }, + process.env.JWT_SECRET!, + { + expiresIn: "30d", + } + ); + + res.appendHeader( + "Set-Cookie", + serializeCookie("token", token, { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 60 * 60 * 24 * 400, + }) + ); + res.redirect(`${WEBSITE_URL}/dashboard`); +}); + +app.get( + "/auth/user", + cors({ + origin: ["http://localhost:56413", "https://chatr.fun"], + credentials: true, + }), + async (req, res) => { + const user = await getUserFromRequest(req); + + if (!user) return res.status(401).json({ message: "Unauthorized" }); + + res.json(user); + } +); + +app.post( + "/auth/logout", + cors({ + origin: ["http://localhost:56413", "https://chatr.fun"], + credentials: true, + }), + async (req, res) => { + if (!(await getUserFromRequest(req))) { + return res.status(401).json({ message: "Unauthorized" }); + } + + res.clearCookie("token"); + + return res.sendStatus(200); + } ); +app.get( + "/auth/user/guilds", + cors({ + origin: ["http://localhost:56413", "https://chatr.fun"], + credentials: true, + }), + async (req, res) => { + const user = await getUserFromRequest(req); + + if (!user) return res.status(401).json({ message: "Unauthorized" }); + + let accessToken = user.access_token; + + if (new Date().getTime() > user.expires_at.getTime()) { + const body = new URLSearchParams(); + + body.append("client_id", process.env.DISCORD_CLIENT_ID!); + body.append("client_secret", process.env.DISCORD_CLIENT_SECRET!); + body.append("grant_type", "refresh_token"); + body.append("refresh_token", user.refresh_token); + body.append("scope", "identify guilds"); + + const tokenResponse = await fetch( + "https://discord.com/api/oauth2/token", + { + method: "POST", + body, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + } + ); + + if (tokenResponse.status !== 200) { + console.error("Error fetching token:", tokenResponse); + + return res + .status(500) + .json({ message: "Internal server error" }); + } + + const tokenData = await tokenResponse.json(); + + accessToken = tokenData.access_token; + } + + const botGuildsResponse = await fetch( + `https://discord.com/api/users/@me/guilds`, + { + headers: { + Authorization: `Bot ${process.env.DISCORD_TOKEN_DEV ?? process.env.DISCORD_TOKEN}`, + }, + } + ); + const botGuilds = await botGuildsResponse.json(); + + const userGuildsResponse = await fetch( + `https://discord.com/api/users/@me/guilds`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + const userGuilds = await userGuildsResponse.json(); + + const filteredGuilds = userGuilds.filter( + (guild: any) => guild.owner || (guild.permissions & 0x20) === 0x20 + ); + + res.json( + filteredGuilds + .map((guild: any) => ({ + ...guild, + icon: guild.icon + ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.webp` + : null, + botIsInGuild: botGuilds.some( + (botGuild: any) => botGuild.id === guild.id + ), + })) + .sort((a: any, b: any) => { + if (a.botIsInGuild === b.botIsInGuild) { + return a.name.localeCompare(b.name); + } + + return Number(b.botIsInGuild) - Number(a.botIsInGuild); + }) + ); + } +); + +app.get("/invite", (req, res) => { + const guildId = req.query.guild_id; + + if (!guildId || typeof guildId !== "string") + res.redirect( + "https://discord.com/oauth2/authorize?client_id=1245807579624378601&permissions=1099780115520&integration_type=0&scope=bot+applications.commands" + ); + else { + const params = new URLSearchParams(); + + params.append("client_id", process.env.DISCORD_CLIENT_ID!); + params.append("permissions", "1099780115520"); + params.append("integration_type", "0"); + params.append("scope", "bot applications.commands identify guilds"); + params.append("guild_id", guildId); + params.append("response_type", "code"); + params.append("redirect_uri", REDIRECT_URI); + res.redirect( + `https://discord.com/oauth2/authorize?${params.toString()}` + ); + } +}); + app.get("/support", (_req, res) => res.status(308).redirect("https://discord.gg/fpJVTkVngm") ); @@ -707,6 +993,99 @@ app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); }); +//#region Cookies +// Mostly taken from https://github.com/pilcrowonpaper/oslo/blob/main/src/cookie/index.ts +interface CookieAttributes { + secure?: boolean; + path?: string; + domain?: string; + sameSite?: "lax" | "strict" | "none"; + httpOnly?: boolean; + maxAge?: number; + expires?: Date; +} + +function parseCookies(header: string): Map { + const cookies = new Map(); + const items = header.split("; "); + + for (const item of items) { + const pair = item.split("="); + const rawKey = pair[0]; + const rawValue = pair[1] ?? ""; + + if (!rawKey) continue; + cookies.set(decodeURIComponent(rawKey), decodeURIComponent(rawValue)); + } + + return cookies; +} + +function serializeCookie( + name: string, + value: string, + attributes: CookieAttributes +): string { + const keyValueEntries: Array<[string, string] | [string]> = []; + + keyValueEntries.push([encodeURIComponent(name), encodeURIComponent(value)]); + if (attributes?.domain !== undefined) { + keyValueEntries.push(["Domain", attributes.domain]); + } + if (attributes?.expires !== undefined) { + keyValueEntries.push(["Expires", attributes.expires.toUTCString()]); + } + if (attributes?.httpOnly) { + keyValueEntries.push(["HttpOnly"]); + } + if (attributes?.maxAge !== undefined) { + keyValueEntries.push(["Max-Age", attributes.maxAge.toString()]); + } + if (attributes?.path !== undefined) { + keyValueEntries.push(["Path", attributes.path]); + } + if (attributes?.sameSite === "lax") { + keyValueEntries.push(["SameSite", "Lax"]); + } + if (attributes?.sameSite === "none") { + keyValueEntries.push(["SameSite", "None"]); + } + if (attributes?.sameSite === "strict") { + keyValueEntries.push(["SameSite", "Strict"]); + } + if (attributes?.secure) { + keyValueEntries.push(["Secure"]); + } + + return keyValueEntries.map((pair) => pair.join("=")).join("; "); +} + +async function getUserFromRequest(req: Request): Promise { + const token = req.cookies?.get("token"); + + if (!token) return null; + + let decoded: JwtPayload; + + try { + decoded = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload; + } catch (err) { + // most likely an invalid or expired token + return null; + } + + const userId = decoded.sub; + + if (!userId) return null; + + const [err, user] = await getOAuthUser(userId); + + if (err) return null; + + return user; +} +//#endregion + // TODO: actually implement this in a real way //#region Admin: Roles async function adminRolesGet(guild: string) { diff --git a/bun.lockb b/bun.lockb old mode 100644 new mode 100755 index 4542ee078d1fb69153f3f4610e36c9656e91d828..1f960cfc488252c371a4969434001fbdb01e36f9 GIT binary patch delta 52345 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%0D$y`b=~Gsz}c>&4cB*(btWn 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 diff --git a/web/components/icons.tsx b/web/components/icons.tsx index d96b745..5ef9cb7 100644 --- a/web/components/icons.tsx +++ b/web/components/icons.tsx @@ -164,6 +164,23 @@ export const SearchIcon = (props: IconSvgProps) => ( ); +export const LoaderIcon = (props: IconSvgProps) => ( + + + +); + export const NextUILogo: React.FC = (props) => { const { width, height = 40 } = props; diff --git a/web/components/navbar.tsx b/web/components/navbar.tsx index f9ee30b..641be90 100644 --- a/web/components/navbar.tsx +++ b/web/components/navbar.tsx @@ -7,15 +7,49 @@ import { NavbarBrand, NavbarItem, NavbarMenuItem, + Button, + Image, + Dropdown, + DropdownTrigger, + DropdownMenu, + DropdownItem, } from "@nextui-org/react"; import { link as linkStyles } from "@nextui-org/theme"; import NextLink from "next/link"; import clsx from "clsx"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/router"; import { siteConfig } from "@/config/site"; -import { TwitterIcon, GithubIcon, DiscordIcon } from "@/components/icons"; +import { + TwitterIcon, + GithubIcon, + DiscordIcon, + LoaderIcon, +} from "@/components/icons"; +import { API_URL, useUser } from "@/lib/queries"; export const Navbar = () => { + const router = useRouter(); + const queryClient = useQueryClient(); + const { user, isLoading } = useUser(); + + const logout = useMutation({ + mutationFn: () => + fetch(`${API_URL}/auth/logout`, { + method: "POST", + credentials: "include", + }), + onSuccess: () => { + if (router.pathname.includes("dashboard")) { + router.push("/"); + } + queryClient.invalidateQueries({ + queryKey: ["user"], + }); + }, + }); + return ( @@ -61,16 +95,46 @@ export const Navbar = () => { - {/* */} + {isLoading ? ( + + ) : user ? ( + + + {user.name + + + + Dashboard + + logout.mutate()} + > + Log out + + + + ) : ( + + )} @@ -83,24 +147,54 @@ export const Navbar = () => {
- {siteConfig.navItems.map((item, index) => ( - - + {siteConfig.navItems.map((item) => ( + + {item.label} - + ))} + {isLoading ? ( + + + + ) : user ? ( + <> + + + Dashboard + + + + logout.mutate()} + > + Log out + + +
+ {user.name +

{user.name}

+
+ + ) : null}
diff --git a/web/config/site.ts b/web/config/site.ts index 6abfa7f..c3ba1e2 100644 --- a/web/config/site.ts +++ b/web/config/site.ts @@ -8,10 +8,6 @@ export const siteConfig = { label: "Home", href: "/", }, - { - label: "Dashboard", - href: "https://dashboard.chatr.fun", - }, { label: "Docs", href: "https://docs.chatr.fun", diff --git a/web/lib/queries.tsx b/web/lib/queries.tsx new file mode 100644 index 0000000..8c61569 --- /dev/null +++ b/web/lib/queries.tsx @@ -0,0 +1,33 @@ +import { useQuery } from "@tanstack/react-query"; + +export interface User { + id: string; + name: string; + username: string; + avatar: string; + access_token: string; + refresh_token: string; + expires_at: Date; +} + +export const API_URL = + process.env.NODE_ENV === "development" + ? "http://localhost:18103" + : "https://api.chatr.fun"; + +export const useUser = () => { + const query = useQuery({ + queryKey: ["user"], + queryFn: async () => { + const res = await fetch(`${API_URL}/auth/user`, { + credentials: "include", + }); + + if (res.status === 401) return null; + + return await res.json(); + }, + }); + + return { user: query.data, isLoading: query.isLoading }; +}; diff --git a/web/package.json b/web/package.json index 7565764..0c32a5b 100644 --- a/web/package.json +++ b/web/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@nextui-org/react": "^2.3.0", + "@tanstack/react-query": "^5.62.9", "@types/node": "20.5.7", "@types/react": "18.3.3", "@types/react-dom": "18.3.0", diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index 3c5a301..3139e3e 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -3,17 +3,22 @@ import type { AppProps } from "next/app"; import { NextUIProvider } from "@nextui-org/react"; import { ThemeProvider as NextThemesProvider } from "next-themes"; import { useRouter } from "next/router"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { fontSans, fontMono } from "@/config/fonts"; import "@/styles/globals.css"; +const queryClient = new QueryClient(); + export default function App({ Component, pageProps }: AppProps) { const router = useRouter(); return ( - + + + ); diff --git a/web/pages/dashboard/index.tsx b/web/pages/dashboard/index.tsx new file mode 100644 index 0000000..62e2491 --- /dev/null +++ b/web/pages/dashboard/index.tsx @@ -0,0 +1,105 @@ +import { useRouter } from "next/router"; +import { useQuery } from "@tanstack/react-query"; +import { Button, Image } from "@nextui-org/react"; +import Link from "next/link"; + +import DefaultLayout from "@/layouts/default"; +import { API_URL, useUser } from "@/lib/queries"; +import { subtitle, title } from "@/components/primitives"; +import { LoaderIcon } from "@/components/icons"; + +interface Guild { + id: string; + name: string; + icon?: string; + botIsInGuild: boolean; +} + +export default function Dashboard() { + const router = useRouter(); + const { user, isLoading: userLoading } = useUser(); + const { data: guilds, isLoading: guildsLoading } = useQuery({ + queryKey: ["guilds"], + queryFn: async () => { + const res = await fetch(`${API_URL}/auth/user/guilds`, { + credentials: "include", + }); + + if (res.status === 401) return null; + + return await res.json(); + }, + }); + + if (!user && !userLoading) return router.push("/"); + + return ( + +
+
+

Dashboard

+

+ Manage and update your server's settings. +

+
+ {userLoading || guildsLoading || !guilds ? ( + + ) : ( + <> + {guilds.length === 0 ? ( +

You are not admin in any servers.

+ ) : ( +
+ {guilds.map((guild) => ( +
+
+ {guild.name + + {guild.name} + +
+ +
+ ))} +
+ )} + + )} +
+
+ ); +} diff --git a/web/pages/leaderboard/[server].tsx b/web/pages/leaderboard/[server].tsx index 3c53ccd..88e7265 100644 --- a/web/pages/leaderboard/[server].tsx +++ b/web/pages/leaderboard/[server].tsx @@ -10,13 +10,13 @@ import DefaultLayout from "@/layouts/default"; import { Leaderboard } from "@/types/leaderboard"; import { PropsGuilds } from "@/types/props"; import { ChartOptions, ChartPointsFormatted } from "@/types/chart"; +import { API_URL } from "@/lib/queries"; const Odometer = dynamic(import("react-odometerjs"), { ssr: false, }); interface PageState { - urlToFetch: string; isLoading: boolean; discordGuildExists: boolean; discordGuildId: string; @@ -36,10 +36,6 @@ class IndexPage extends Component { super(props); this.state = { - urlToFetch: - process.env.NODE_ENV === "development" - ? "http://localhost:18103" - : "https://api.chatr.fun", isLoading: true, discordGuildExists: props.discordGuildExists, discordGuildId: props.discordGuildId, @@ -162,7 +158,7 @@ class IndexPage extends Component { if (this.state.discordGuildExists == null) { return; } else { - fetch(`${this.state.urlToFetch}/get/${this.state.discordGuildId}`) + fetch(`${API_URL}/get/${this.state.discordGuildId}`) .then((response) => response.json()) .then((data) => { const points = data.totalXp; @@ -227,7 +223,6 @@ class IndexPage extends Component { render() { const { - discordGuildExists, odometerPoints, odometerMembersBeingTracked, odometerMembers, @@ -235,15 +230,6 @@ class IndexPage extends Component { leaderboard, } = this.state; - if (!discordGuildExists) { - // Redirect to 404 - if (typeof window != "undefined") { - window.location.href = "/404"; - } - - return null; - } - return (
@@ -485,6 +471,7 @@ export async function getServerSideProps(context: { odometerMembersBeingTracked: null, leaderboard: null, }, + notFound: true, }; } } catch (error) { diff --git a/web/pages/leaderboard/[server]/[user].tsx b/web/pages/leaderboard/[server]/[user].tsx index f390b90..f07f9c6 100644 --- a/web/pages/leaderboard/[server]/[user].tsx +++ b/web/pages/leaderboard/[server]/[user].tsx @@ -8,13 +8,13 @@ import DefaultLayout from "@/layouts/default"; import "odometer/themes/odometer-theme-default.css"; import { ChartOptions, ChartPointsFormatted } from "@/types/chart"; import { PropsUsers } from "@/types/props"; +import { API_URL } from "@/lib/queries"; const Odometer = dynamic(import("react-odometerjs"), { ssr: false, }); interface PageState { - urlToFetch: string; isLoading: boolean; discordAccountExists: boolean; discordUserId: string; @@ -38,10 +38,6 @@ class IndexPage extends Component { super(props); this.state = { - urlToFetch: - process.env.NODE_ENV === "development" - ? "http://localhost:18103" - : "https://api.chatr.fun", isLoading: true, // Flag to indicate whether a request is in progress discordAccountExists: props.discordAccountExists, discordUserId: props.discordUserId, @@ -171,7 +167,7 @@ class IndexPage extends Component { return; } else { fetch( - `${this.state.urlToFetch}/get/${this.state.discordGuildId}/${this.state.discordUserId}` + `${API_URL}/get/${this.state.discordGuildId}/${this.state.discordUserId}` ) .then((response) => response.json()) .then((data) => { @@ -239,7 +235,6 @@ class IndexPage extends Component { render() { const { - discordAccountExists, odometerPoints, odometerPointsNeededToNextLevel, odometerPointsNeededForNextLevel, @@ -248,15 +243,6 @@ class IndexPage extends Component { chartOptions, } = this.state; - if (!discordAccountExists) { - // Redirect to 404 - if (typeof window != "undefined") { - window.location.href = "/404"; - } - - return null; - } - return (
@@ -403,6 +389,7 @@ export async function getServerSideProps(context: { odometerPointsNeededForNextLevel: null, odometerProgressToNextLevelPercentage: null, }, + notFound: true, }; } } catch (error) { From bb42393954187ae3ea8421cfd5a3b997b58f6f21 Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Tue, 24 Dec 2024 12:20:08 +0800 Subject: [PATCH 2/8] feat(dashboard): use name for default icon --- web/components/server-icon.tsx | 28 ++++++++++++++++++++++++++++ web/pages/dashboard/index.tsx | 15 +++------------ 2 files changed, 31 insertions(+), 12 deletions(-) create mode 100644 web/components/server-icon.tsx diff --git a/web/components/server-icon.tsx b/web/components/server-icon.tsx new file mode 100644 index 0000000..e827f42 --- /dev/null +++ b/web/components/server-icon.tsx @@ -0,0 +1,28 @@ +import { Image } from "@nextui-org/react"; + +export function ServerIcon({ + guild, +}: { + guild: { name: string; icon?: string }; +}) { + if (!guild.icon) { + return ( +
+ {guild.name.match(/[A-Z]/g)?.join("")} +
+ ); + } + + return ( + {guild.name + ); +} diff --git a/web/pages/dashboard/index.tsx b/web/pages/dashboard/index.tsx index 62e2491..17c2d62 100644 --- a/web/pages/dashboard/index.tsx +++ b/web/pages/dashboard/index.tsx @@ -1,12 +1,13 @@ import { useRouter } from "next/router"; import { useQuery } from "@tanstack/react-query"; -import { Button, Image } from "@nextui-org/react"; +import { Button } from "@nextui-org/react"; import Link from "next/link"; import DefaultLayout from "@/layouts/default"; import { API_URL, useUser } from "@/lib/queries"; import { subtitle, title } from "@/components/primitives"; import { LoaderIcon } from "@/components/icons"; +import { ServerIcon } from "@/components/server-icon"; interface Guild { id: string; @@ -60,17 +61,7 @@ export default function Dashboard() { className="bg-gray-800 p-6 rounded-lg flex flex-col justify-center space-y-4 shadow-lg" >
- {guild.name + {guild.name} From bc6aeb1944178c6a4a8bd56ebbec539e94b0fb4e Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Tue, 24 Dec 2024 13:10:26 +0800 Subject: [PATCH 3/8] feat(dashboard): ssr --- web/lib/queries.tsx | 15 ++++ web/pages/dashboard/index.tsx | 159 ++++++++++++++++++---------------- 2 files changed, 98 insertions(+), 76 deletions(-) diff --git a/web/lib/queries.tsx b/web/lib/queries.tsx index 8c61569..a0bba7f 100644 --- a/web/lib/queries.tsx +++ b/web/lib/queries.tsx @@ -1,4 +1,5 @@ import { useQuery } from "@tanstack/react-query"; +import { createContext, useContext } from "react"; export interface User { id: string; @@ -15,7 +16,20 @@ export const API_URL = ? "http://localhost:18103" : "https://api.chatr.fun"; +const UserContext = createContext(null); + +export const UserProvider = ({ + children, + user, +}: { + children: React.ReactNode; + user: User; +}) => { + return {children}; +}; + export const useUser = () => { + const user = useContext(UserContext); const query = useQuery({ queryKey: ["user"], queryFn: async () => { @@ -27,6 +41,7 @@ export const useUser = () => { return await res.json(); }, + initialData: user, }); return { user: query.data, isLoading: query.isLoading }; diff --git a/web/pages/dashboard/index.tsx b/web/pages/dashboard/index.tsx index 17c2d62..27b1dad 100644 --- a/web/pages/dashboard/index.tsx +++ b/web/pages/dashboard/index.tsx @@ -1,12 +1,10 @@ -import { useRouter } from "next/router"; -import { useQuery } from "@tanstack/react-query"; import { Button } from "@nextui-org/react"; import Link from "next/link"; +import { GetServerSidePropsContext } from "next"; import DefaultLayout from "@/layouts/default"; -import { API_URL, useUser } from "@/lib/queries"; +import { API_URL, User, UserProvider } from "@/lib/queries"; import { subtitle, title } from "@/components/primitives"; -import { LoaderIcon } from "@/components/icons"; import { ServerIcon } from "@/components/server-icon"; interface Guild { @@ -16,81 +14,90 @@ interface Guild { botIsInGuild: boolean; } -export default function Dashboard() { - const router = useRouter(); - const { user, isLoading: userLoading } = useUser(); - const { data: guilds, isLoading: guildsLoading } = useQuery({ - queryKey: ["guilds"], - queryFn: async () => { - const res = await fetch(`${API_URL}/auth/user/guilds`, { - credentials: "include", - }); +export default function Dashboard({ + user, + guilds, +}: { + user: User; + guilds: Guild[]; +}) { + return ( + + +
+
+

Dashboard

+

+ Manage and update your server's settings. +

+
+ {guilds.length === 0 ? ( +

You are not admin in any servers.

+ ) : ( +
+ {guilds.map((guild) => ( +
+
+ + + {guild.name} + +
+ +
+ ))} +
+ )} +
+
+
+ ); +} - if (res.status === 401) return null; +export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { + const userResponse = await fetch(`${API_URL}/auth/user`, { + headers: { + cookie: ctx.req.headers.cookie ?? "", + }, + }); - return await res.json(); + if (userResponse.status === 401) + return { + props: { guilds: null }, + redirect: { + destination: `${API_URL}/auth/login`, + permanent: false, + }, + }; + + const guildsResponse = await fetch(`${API_URL}/auth/user/guilds`, { + headers: { + cookie: ctx.req.headers.cookie ?? "", }, }); - if (!user && !userLoading) return router.push("/"); + const user = await userResponse.json(); + const guilds = await guildsResponse.json(); - return ( - -
-
-

Dashboard

-

- Manage and update your server's settings. -

-
- {userLoading || guildsLoading || !guilds ? ( - - ) : ( - <> - {guilds.length === 0 ? ( -

You are not admin in any servers.

- ) : ( -
- {guilds.map((guild) => ( -
-
- - - {guild.name} - -
- -
- ))} -
- )} - - )} -
-
- ); -} + return { props: { user, guilds } }; +}; From f45b39a00e62f60205d83150998f804c78dbcc9a Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Tue, 24 Dec 2024 13:14:25 +0800 Subject: [PATCH 4/8] fix(api): add 308 status code for /invite --- api/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/index.ts b/api/src/index.ts index 32de343..f6dc5d8 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -962,7 +962,7 @@ app.get("/invite", (req, res) => { const guildId = req.query.guild_id; if (!guildId || typeof guildId !== "string") - res.redirect( + res.status(308).redirect( "https://discord.com/oauth2/authorize?client_id=1245807579624378601&permissions=1099780115520&integration_type=0&scope=bot+applications.commands" ); else { From 7fda857b6ea0e64a3292d0b6512f26e3e35d87c4 Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Wed, 25 Dec 2024 18:40:53 +0800 Subject: [PATCH 5/8] feat(dashboard): actual settings page can't save yet though, still have to deal with cors --- api/package.json | 44 ++--- api/src/db/queries/guilds.ts | 2 +- api/src/db/queries/oauth-users.ts | 9 ++ api/src/index.ts | 259 +++++++++++++++++++----------- bun.lockb | Bin 493008 -> 493904 bytes web/components/navbar.tsx | 7 +- web/components/server-icon.tsx | 26 ++- web/lib/queries.tsx | 10 +- web/package.json | 70 ++++---- web/pages/dashboard/[server].tsx | 175 ++++++++++++++++++++ web/pages/dashboard/index.tsx | 18 +-- web/types/api.d.ts | 16 ++ 12 files changed, 459 insertions(+), 177 deletions(-) create mode 100644 web/pages/dashboard/[server].tsx create mode 100644 web/types/api.d.ts diff --git a/api/package.json b/api/package.json index ef5e619..6276045 100644 --- a/api/package.json +++ b/api/package.json @@ -1,23 +1,23 @@ { - "name": "@chatr/api", - "type": "module", - "version": "0.1.0", - "scripts": { - "dev": "bun with-env bun --watch src/index.ts --dev", - "with-env": "dotenv -e ../.env --" - }, - "dependencies": { - "cors": "^2.8.5", - "cron": "^3.1.7", - "express": "^4.19.2", - "jsonwebtoken": "^9.0.2", - "mysql2": "^3.10.3" - }, - "devDependencies": { - "@types/bun": "latest", - "@types/cors": "^2.8.17", - "@types/express": "^4.17.21", - "@types/jsonwebtoken": "^9.0.7", - "dotenv-cli": "^7.4.2" - } -} + "name": "@chatr/api", + "type": "module", + "version": "0.1.0", + "scripts": { + "dev": "bun with-env bun --watch src/index.ts --dev", + "with-env": "dotenv -e ../.env --" + }, + "dependencies": { + "cors": "^2.8.5", + "cron": "^3.1.7", + "express": "^4.19.2", + "jsonwebtoken": "^9.0.2", + "mysql2": "^3.10.3" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.7", + "dotenv-cli": "^7.4.2" + } +} \ No newline at end of file diff --git a/api/src/db/queries/guilds.ts b/api/src/db/queries/guilds.ts index f9a1663..e26bf8f 100644 --- a/api/src/db/queries/guilds.ts +++ b/api/src/db/queries/guilds.ts @@ -5,7 +5,7 @@ import { pool } from ".."; export interface Guild { id: string; name: string; - icon: string; + icon?: string; members: number; cooldown: number; updates_enabled: 0 | 1; diff --git a/api/src/db/queries/oauth-users.ts b/api/src/db/queries/oauth-users.ts index 9f969f8..a0eba00 100644 --- a/api/src/db/queries/oauth-users.ts +++ b/api/src/db/queries/oauth-users.ts @@ -12,6 +12,15 @@ export interface OAuthUser { expires_at: Date; } +export type OAuthUserWithoutTokens = Without< + OAuthUser, + "access_token" | "refresh_token" | "expires_at" +>; + +type Without = { + [L in keyof T]: L extends K ? undefined : T[L]; +}; + export function getOAuthUser( id: string ): Promise<[QueryError, null] | [null, OAuthUser]> { diff --git a/api/src/index.ts b/api/src/index.ts index f6dc5d8..4716dea 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -834,129 +834,159 @@ app.get("/auth/callback", async (req, res) => { res.redirect(`${WEBSITE_URL}/dashboard`); }); -app.get( - "/auth/user", +const userRouter = express.Router(); + +userRouter.use( cors({ origin: ["http://localhost:56413", "https://chatr.fun"], credentials: true, - }), - async (req, res) => { - const user = await getUserFromRequest(req); + }) +); + +userRouter.get("/", async (req, res) => { + const user = await getUserFromRequest(req); + + if (!user) return res.status(401).json({ message: "Unauthorized" }); - if (!user) return res.status(401).json({ message: "Unauthorized" }); + res.json({ + ...user, + access_token: undefined, + refresh_token: undefined, + expires_at: undefined, + }); +}); - res.json(user); +userRouter.delete("/", async (req, res) => { + if (!(await getUserFromRequest(req))) { + return res.status(401).json({ message: "Unauthorized" }); } -); -app.post( - "/auth/logout", - cors({ - origin: ["http://localhost:56413", "https://chatr.fun"], - credentials: true, - }), - async (req, res) => { - if (!(await getUserFromRequest(req))) { - return res.status(401).json({ message: "Unauthorized" }); + res.clearCookie("token"); + + return res.sendStatus(200); +}); + +app.use("/user/me", userRouter); + +app.get("/auth/user/guilds", async (req, res) => { + const user = await getUserFromRequest(req); + + if (!user) return res.status(401).json({ message: "Unauthorized" }); + + const botGuildsResponse = await fetch( + "https://discord.com/api/users/@me/guilds", + { + headers: { + Authorization: `Bot ${process.env.DISCORD_TOKEN_DEV ?? process.env.DISCORD_TOKEN}`, + }, + } + ); + const botGuilds = await botGuildsResponse.json(); + + const [err, accessToken] = await getAccessToken(user); + + if (err) return res.status(500).json({ message: err }); + + const userGuildsResponse = await fetch( + "https://discord.com/api/users/@me/guilds", + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, } + ); + const userGuilds = await userGuildsResponse.json(); - res.clearCookie("token"); + const filteredGuilds = userGuilds.filter( + (guild: any) => guild.owner || (guild.permissions & 0x20) === 0x20 + ); - return res.sendStatus(200); - } -); + res.json( + filteredGuilds + .map((guild: any) => ({ + ...guild, + icon: guild.icon + ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.webp` + : null, + botIsInGuild: botGuilds.some( + (botGuild: any) => botGuild.id === guild.id + ), + })) + .sort((a: any, b: any) => { + if (a.botIsInGuild === b.botIsInGuild) { + return a.name.localeCompare(b.name); + } -app.get( - "/auth/user/guilds", + return Number(b.botIsInGuild) - Number(a.botIsInGuild); + }) + ); +}); + +app.options( + "/auth/update-guild", + cors({ + origin: ["http://localhost:56413", "https://chatr.fun"], + credentials: true, + }) +); +app.put( + "/auth/update-guild", cors({ origin: ["http://localhost:56413", "https://chatr.fun"], credentials: true, }), async (req, res) => { - const user = await getUserFromRequest(req); - - if (!user) return res.status(401).json({ message: "Unauthorized" }); + if (!(await getUserFromRequest(req))) + return res.status(401).json({ message: "Unauthorized" }); - let accessToken = user.access_token; + const body = req.body; + const { guild } = req.body; - if (new Date().getTime() > user.expires_at.getTime()) { - const body = new URLSearchParams(); + if (!guild) return res.status(400).json({ message: "Illegal request" }); - body.append("client_id", process.env.DISCORD_CLIENT_ID!); - body.append("client_secret", process.env.DISCORD_CLIENT_SECRET!); - body.append("grant_type", "refresh_token"); - body.append("refresh_token", user.refresh_token); - body.append("scope", "identify guilds"); + if (body.cooldown) { + await setCooldown(guild, body.cooldown); + } - const tokenResponse = await fetch( - "https://discord.com/api/oauth2/token", - { - method: "POST", - body, - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - } - ); + if (body.updates.enabled === true) { + await enableUpdates(guild); + } else if (body.updates.enabled === false) { + await disableUpdates(guild); + } - if (tokenResponse.status !== 200) { - console.error("Error fetching token:", tokenResponse); + if (body.updates.channel) { + await setUpdatesChannel(guild, body.updates.channel); + } - return res - .status(500) - .json({ message: "Internal server error" }); - } + return res.sendStatus(204); + } +); - const tokenData = await tokenResponse.json(); +// TODO: fetch from the bot itself using discord.js +// (would allow us to do permission filtering) +app.get("/channels/:guild", authMiddleware, async (req, res) => { + const { guild } = req.params; - accessToken = tokenData.access_token; + const channelsResponse = await fetch( + `https://discord.com/api/v10/guilds/${guild}/channels`, + { + headers: { + Authorization: `Bot ${process.env.DISCORD_TOKEN_DEV ?? process.env.DISCORD_TOKEN}`, + }, } + ); + const channelsData = await channelsResponse.json(); - const botGuildsResponse = await fetch( - `https://discord.com/api/users/@me/guilds`, - { - headers: { - Authorization: `Bot ${process.env.DISCORD_TOKEN_DEV ?? process.env.DISCORD_TOKEN}`, - }, - } - ); - const botGuilds = await botGuildsResponse.json(); - - const userGuildsResponse = await fetch( - `https://discord.com/api/users/@me/guilds`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ); - const userGuilds = await userGuildsResponse.json(); + if (channelsData.code === 50007) { + return res.status(404).json({ message: "Guild not found" }); + } - const filteredGuilds = userGuilds.filter( - (guild: any) => guild.owner || (guild.permissions & 0x20) === 0x20 - ); + const channels = channelsData + .filter((channel: any) => channel.type === 0) + .sort((a: any, b: any) => a.position - b.position); - res.json( - filteredGuilds - .map((guild: any) => ({ - ...guild, - icon: guild.icon - ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.webp` - : null, - botIsInGuild: botGuilds.some( - (botGuild: any) => botGuild.id === guild.id - ), - })) - .sort((a: any, b: any) => { - if (a.botIsInGuild === b.botIsInGuild) { - return a.name.localeCompare(b.name); - } - - return Number(b.botIsInGuild) - Number(a.botIsInGuild); - }) - ); - } -); + res.json(channels); +}); app.get("/invite", (req, res) => { const guildId = req.query.guild_id; @@ -1084,6 +1114,45 @@ async function getUserFromRequest(req: Request): Promise { return user; } + +async function getAccessToken( + user: OAuthUser +): Promise<[string, null] | [null, string]> { + let accessToken = user.access_token; + + if (new Date().getTime() > user.expires_at.getTime()) { + const body = new URLSearchParams(); + + body.append("client_id", process.env.DISCORD_CLIENT_ID!); + body.append("client_secret", process.env.DISCORD_CLIENT_SECRET!); + body.append("grant_type", "refresh_token"); + body.append("refresh_token", user.refresh_token); + body.append("scope", "identify guilds"); + + const tokenResponse = await fetch( + "https://discord.com/api/oauth2/token", + { + method: "POST", + body, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + } + ); + + if (tokenResponse.status !== 200) { + console.error("Error fetching token:", tokenResponse); + + return ["Internal server error", null]; + } + + const tokenData = await tokenResponse.json(); + + accessToken = tokenData.access_token; + } + + return [null, accessToken]; +} //#endregion // TODO: actually implement this in a real way diff --git a/bun.lockb b/bun.lockb index 1f960cfc488252c371a4969434001fbdb01e36f9..e595e3c2b9e6fdf2f356499b611df8de5a10a700 100755 GIT binary patch delta 24376 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 delta 24080 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 KE?OM>!G8h##mHd* diff --git a/web/components/navbar.tsx b/web/components/navbar.tsx index 641be90..a430732 100644 --- a/web/components/navbar.tsx +++ b/web/components/navbar.tsx @@ -19,6 +19,7 @@ import NextLink from "next/link"; import clsx from "clsx"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/router"; +import NextImage from "next/image"; import { siteConfig } from "@/config/site"; import { @@ -36,8 +37,8 @@ export const Navbar = () => { const logout = useMutation({ mutationFn: () => - fetch(`${API_URL}/auth/logout`, { - method: "POST", + fetch(`${API_URL}/user/me`, { + method: "DELETE", credentials: "include", }), onSuccess: () => { @@ -106,6 +107,7 @@ export const Navbar = () => { {user.name {
{user.name +
{guild.name.match(/[A-Z]/g)?.join("")}
); @@ -15,14 +28,17 @@ export function ServerIcon({ return ( {guild.name ); } diff --git a/web/lib/queries.tsx b/web/lib/queries.tsx index a0bba7f..f427737 100644 --- a/web/lib/queries.tsx +++ b/web/lib/queries.tsx @@ -1,15 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { createContext, useContext } from "react"; -export interface User { - id: string; - name: string; - username: string; - avatar: string; - access_token: string; - refresh_token: string; - expires_at: Date; -} +import { User } from "@/types/api"; export const API_URL = process.env.NODE_ENV === "development" diff --git a/web/package.json b/web/package.json index 0c32a5b..501f577 100644 --- a/web/package.json +++ b/web/package.json @@ -1,34 +1,38 @@ { - "name": "@chatr/web", - "version": "0.1.0", - "private": true, - "scripts": { - "dev": "next dev --port 56413", - "build": "next build", - "start": "next start --port 56414", - "lint": "next lint" - }, - "dependencies": { - "@nextui-org/react": "^2.3.0", - "@tanstack/react-query": "^5.62.9", - "@types/node": "20.5.7", - "@types/react": "18.3.3", - "@types/react-dom": "18.3.0", - "autoprefixer": "10.4.19", - "clsx": "^2.0.0", - "framer-motion": "^11.1.1", - "highcharts": "^11.4.6", - "highcharts-react-official": "^3.2.1", - "intl-messageformat": "^10.5.0", - "next": "14.2.1", - "next-themes": "^0.3.0", - "postcss": "8.4.38", - "react": "18.3.1", - "react-dom": "18.3.1", - "react-odometer": "^0.0.1", - "react-odometerjs": "^3.1.3", - "tailwind-variants": "^0.2.1", - "tailwindcss": "3.4.3", - "typescript": "5.5.4" - } -} + "name": "@chatr/web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "bun with-env next dev --port 56413", + "build": "next build", + "start": "bun with-env next start --port 56414", + "lint": "next lint", + "with-env": "dotenv -e ../.env --" + }, + "dependencies": { + "@nextui-org/react": "^2.3.0", + "@tanstack/react-query": "^5.62.9", + "@types/node": "20.5.7", + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", + "autoprefixer": "10.4.19", + "clsx": "^2.0.0", + "framer-motion": "^11.1.1", + "highcharts": "^11.4.6", + "highcharts-react-official": "^3.2.1", + "intl-messageformat": "^10.5.0", + "next": "14.2.1", + "next-themes": "^0.3.0", + "postcss": "8.4.38", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-odometer": "^0.0.1", + "react-odometerjs": "^3.1.3", + "tailwind-variants": "^0.2.1", + "tailwindcss": "3.4.3", + "typescript": "5.5.4" + }, + "devDependencies": { + "dotenv-cli": "^8.0.0" + } +} \ No newline at end of file diff --git a/web/pages/dashboard/[server].tsx b/web/pages/dashboard/[server].tsx new file mode 100644 index 0000000..7a6c0c7 --- /dev/null +++ b/web/pages/dashboard/[server].tsx @@ -0,0 +1,175 @@ +import { GetServerSidePropsContext } from "next"; +import { + Autocomplete, + AutocompleteItem, + Button, + Checkbox, + Input, +} from "@nextui-org/react"; +import { FormEvent, useCallback, useState } from "react"; + +import { API_URL, UserProvider } from "@/lib/queries"; +import { User } from "@/types/api"; +import DefaultLayout from "@/layouts/default"; +import { ServerIcon } from "@/components/server-icon"; + +export default function Dashboard({ + user, + guild, + channels, +}: { + user: User; + guild: any; + channels: any; +}) { + const [cooldown, setCooldown] = useState( + (guild.cooldown / 1000).toString() + ); + const [updatesEnabled, setUpdatesEnabled] = useState( + guild.updates_enabled === 1 + ); + const [updatesChannel, setUpdatesChannel] = useState( + guild.updates_channel_id + ); + + const onSubmit = useCallback(async (e: FormEvent) => { + e.preventDefault(); + await fetch(`${API_URL}/auth/update-guild`, { + body: JSON.stringify({ + guild: guild.id, + cooldown: parseInt(cooldown) * 1000, + updates: { + enabled: updatesEnabled, + channel: updatesChannel, + }, + }), + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + method: "PUT", + }); + }, []); + + return ( + + +
+
+
+ +

+ {guild.name} +

+
+
+
+
+
+ + setCooldown(e.target.value) + } + /> +
+
+

+ Level up messages +

+
+ + setUpdatesEnabled(e.target.checked) + } + > + Enable level up messages + + + setUpdatesChannel( + id as string | null + ) + } + > + {(channel: any) => ( + + {"#" + channel.name} + + )} + +
+

+ Whether or not and where to send level up + messages to. +

+
+
+ +
+
+
+
+ ); +} + +export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { + const userResponse = await fetch(`${API_URL}/user/me`, { + headers: { + cookie: ctx.req.headers.cookie ?? "", + }, + }); + + if (userResponse.status === 401) + return { + props: { user: null, guild: null, channels: null }, + redirect: { + destination: `${API_URL}/auth/login`, + permanent: false, + }, + }; + + const guildResponse = await fetch(`${API_URL}/get/${ctx.params!.server}`, { + headers: { + cookie: ctx.req.headers.cookie ?? "", + }, + }); + + const channelsResponse = await fetch( + `${API_URL}/channels/${ctx.params!.server}`, + { + headers: { + Authorization: process.env.AUTH!, + }, + } + ); + + const user = await userResponse.json(); + const { guild } = await guildResponse.json(); + const channels = await channelsResponse.json(); + + console.log(channels); + + return { props: { user, guild, channels } }; +}; diff --git a/web/pages/dashboard/index.tsx b/web/pages/dashboard/index.tsx index 27b1dad..9131009 100644 --- a/web/pages/dashboard/index.tsx +++ b/web/pages/dashboard/index.tsx @@ -3,16 +3,10 @@ import Link from "next/link"; import { GetServerSidePropsContext } from "next"; import DefaultLayout from "@/layouts/default"; -import { API_URL, User, UserProvider } from "@/lib/queries"; +import { API_URL, UserProvider } from "@/lib/queries"; import { subtitle, title } from "@/components/primitives"; import { ServerIcon } from "@/components/server-icon"; - -interface Guild { - id: string; - name: string; - icon?: string; - botIsInGuild: boolean; -} +import { Guild, User } from "@/types/api"; export default function Dashboard({ user, @@ -41,7 +35,11 @@ export default function Dashboard({ className="bg-gray-800 p-6 rounded-lg flex flex-col justify-center space-y-4 shadow-lg" >
- + {guild.name} @@ -83,7 +81,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { if (userResponse.status === 401) return { - props: { guilds: null }, + props: { user: null, guilds: null }, redirect: { destination: `${API_URL}/auth/login`, permanent: false, diff --git a/web/types/api.d.ts b/web/types/api.d.ts new file mode 100644 index 0000000..7facb9e --- /dev/null +++ b/web/types/api.d.ts @@ -0,0 +1,16 @@ +export interface User { + id: string; + name: string; + username: string; + avatar: string; + access_token: string; + refresh_token: string; + expires_at: Date; +} + +export interface Guild { + id: string; + name: string; + icon?: string; + botIsInGuild: boolean; +} From 783ace1fd98c0f3069852a54b14ea81d75cb00c6 Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Wed, 25 Dec 2024 18:44:30 +0800 Subject: [PATCH 6/8] fix(dashboard): undo the cursed stuff I did --- api/src/index.ts | 51 +++++++++++++++++--------------- web/components/navbar.tsx | 4 +-- web/pages/dashboard/[server].tsx | 2 +- 3 files changed, 30 insertions(+), 27 deletions(-) diff --git a/api/src/index.ts b/api/src/index.ts index 4716dea..1255e76 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -834,39 +834,42 @@ app.get("/auth/callback", async (req, res) => { res.redirect(`${WEBSITE_URL}/dashboard`); }); -const userRouter = express.Router(); - -userRouter.use( +app.get( + "/auth/user", cors({ origin: ["http://localhost:56413", "https://chatr.fun"], credentials: true, - }) -); - -userRouter.get("/", async (req, res) => { - const user = await getUserFromRequest(req); - - if (!user) return res.status(401).json({ message: "Unauthorized" }); + }), + async (req, res) => { + const user = await getUserFromRequest(req); - res.json({ - ...user, - access_token: undefined, - refresh_token: undefined, - expires_at: undefined, - }); -}); + if (!user) return res.status(401).json({ message: "Unauthorized" }); -userRouter.delete("/", async (req, res) => { - if (!(await getUserFromRequest(req))) { - return res.status(401).json({ message: "Unauthorized" }); + res.json({ + ...user, + access_token: undefined, + refresh_token: undefined, + expires_at: undefined, + }); } +); - res.clearCookie("token"); +app.post( + "/auth/logout", + cors({ + origin: ["http://localhost:56413", "https://chatr.fun"], + credentials: true, + }), + async (req, res) => { + if (!(await getUserFromRequest(req))) { + return res.status(401).json({ message: "Unauthorized" }); + } - return res.sendStatus(200); -}); + res.clearCookie("token"); -app.use("/user/me", userRouter); + return res.sendStatus(200); + } +); app.get("/auth/user/guilds", async (req, res) => { const user = await getUserFromRequest(req); diff --git a/web/components/navbar.tsx b/web/components/navbar.tsx index a430732..ae9ee8c 100644 --- a/web/components/navbar.tsx +++ b/web/components/navbar.tsx @@ -37,8 +37,8 @@ export const Navbar = () => { const logout = useMutation({ mutationFn: () => - fetch(`${API_URL}/user/me`, { - method: "DELETE", + fetch(`${API_URL}/auth/logout`, { + method: "POST", credentials: "include", }), onSuccess: () => { diff --git a/web/pages/dashboard/[server].tsx b/web/pages/dashboard/[server].tsx index 7a6c0c7..b449ee9 100644 --- a/web/pages/dashboard/[server].tsx +++ b/web/pages/dashboard/[server].tsx @@ -135,7 +135,7 @@ export default function Dashboard({ } export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { - const userResponse = await fetch(`${API_URL}/user/me`, { + const userResponse = await fetch(`${API_URL}/auth/user`, { headers: { cookie: ctx.req.headers.cookie ?? "", }, From b26742b309e291464a572ea0e27fad50809aad6c Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Wed, 25 Dec 2024 19:13:26 +0800 Subject: [PATCH 7/8] feat(dashboard): actually works --- api/src/index.ts | 105 +++++++++++++++++-------------- web/components/navbar.tsx | 8 ++- web/lib/queries.tsx | 2 +- web/pages/dashboard/[server].tsx | 8 +-- web/pages/dashboard/index.tsx | 4 +- 5 files changed, 71 insertions(+), 56 deletions(-) diff --git a/api/src/index.ts b/api/src/index.ts index 1255e76..1923e22 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -40,7 +40,7 @@ import { const app = express(); const PORT = 18103; -app.use(cors()); +// app.use(cors()); app.use(express.json()); app.use((req, _res, next) => { if (req.headers.cookie) { @@ -274,7 +274,7 @@ app.get("/get/tracking/:guild/:user", async (req, res) => { return res.status(200).json(data); }); -app.get("/get/:guild/:user", async (req, res) => { +app.get("/get/:guild/:user", cors(), async (req, res) => { const { guild, user } = req.params; const [err, result] = await getUser(user, guild); @@ -289,7 +289,7 @@ app.get("/get/:guild/:user", async (req, res) => { } }); -app.get("/get/:guild", async (req, res) => { +app.get("/get/:guild", cors(), async (req, res) => { const { guild } = req.params; const [guildErr, guildData] = await getGuild(guild); @@ -834,8 +834,16 @@ app.get("/auth/callback", async (req, res) => { res.redirect(`${WEBSITE_URL}/dashboard`); }); +app.options( + "/user/me", + cors({ + origin: ["http://localhost:56413", "https://chatr.fun"], + credentials: true, + }) +); + app.get( - "/auth/user", + "/user/me", cors({ origin: ["http://localhost:56413", "https://chatr.fun"], credentials: true, @@ -854,8 +862,8 @@ app.get( } ); -app.post( - "/auth/logout", +app.delete( + "/user/me", cors({ origin: ["http://localhost:56413", "https://chatr.fun"], credentials: true, @@ -871,7 +879,48 @@ app.post( } ); -app.get("/auth/user/guilds", async (req, res) => { +app.options( + "/dashboard/update-guild", + cors({ + origin: ["http://localhost:56413", "https://chatr.fun"], + credentials: true, + }) +); + +app.post( + "/dashboard/update-guild", + cors({ + origin: ["http://localhost:56413", "https://chatr.fun"], + credentials: true, + }), + async (req, res) => { + if (!(await getUserFromRequest(req))) + return res.status(401).json({ message: "Unauthorized" }); + + const body = req.body; + const { guild } = req.body; + + if (!guild) return res.status(400).json({ message: "Illegal request" }); + + if (body.cooldown) { + await setCooldown(guild, body.cooldown); + } + + if (body.updates.enabled === true) { + await enableUpdates(guild); + } else if (body.updates.enabled === false) { + await disableUpdates(guild); + } + + if (body.updates.channel) { + await setUpdatesChannel(guild, body.updates.channel); + } + + return res.sendStatus(200); + } +); + +app.get("/user/me/guilds", async (req, res) => { const user = await getUserFromRequest(req); if (!user) return res.status(401).json({ message: "Unauthorized" }); @@ -925,49 +974,9 @@ app.get("/auth/user/guilds", async (req, res) => { ); }); -app.options( - "/auth/update-guild", - cors({ - origin: ["http://localhost:56413", "https://chatr.fun"], - credentials: true, - }) -); -app.put( - "/auth/update-guild", - cors({ - origin: ["http://localhost:56413", "https://chatr.fun"], - credentials: true, - }), - async (req, res) => { - if (!(await getUserFromRequest(req))) - return res.status(401).json({ message: "Unauthorized" }); - - const body = req.body; - const { guild } = req.body; - - if (!guild) return res.status(400).json({ message: "Illegal request" }); - - if (body.cooldown) { - await setCooldown(guild, body.cooldown); - } - - if (body.updates.enabled === true) { - await enableUpdates(guild); - } else if (body.updates.enabled === false) { - await disableUpdates(guild); - } - - if (body.updates.channel) { - await setUpdatesChannel(guild, body.updates.channel); - } - - return res.sendStatus(204); - } -); - // TODO: fetch from the bot itself using discord.js // (would allow us to do permission filtering) -app.get("/channels/:guild", authMiddleware, async (req, res) => { +app.get("/dashboard/channels/:guild", authMiddleware, async (req, res) => { const { guild } = req.params; const channelsResponse = await fetch( diff --git a/web/components/navbar.tsx b/web/components/navbar.tsx index ae9ee8c..d250b4e 100644 --- a/web/components/navbar.tsx +++ b/web/components/navbar.tsx @@ -115,7 +115,13 @@ export const Navbar = () => { /> - + +

Signed in as

+

+ {user.name} +

+
+ Dashboard { const query = useQuery({ queryKey: ["user"], queryFn: async () => { - const res = await fetch(`${API_URL}/auth/user`, { + const res = await fetch(`${API_URL}/user/me`, { credentials: "include", }); diff --git a/web/pages/dashboard/[server].tsx b/web/pages/dashboard/[server].tsx index b449ee9..5513626 100644 --- a/web/pages/dashboard/[server].tsx +++ b/web/pages/dashboard/[server].tsx @@ -34,7 +34,7 @@ export default function Dashboard({ const onSubmit = useCallback(async (e: FormEvent) => { e.preventDefault(); - await fetch(`${API_URL}/auth/update-guild`, { + await fetch(`${API_URL}/dashboard/update-guild`, { body: JSON.stringify({ guild: guild.id, cooldown: parseInt(cooldown) * 1000, @@ -47,7 +47,7 @@ export default function Dashboard({ headers: { "Content-Type": "application/json", }, - method: "PUT", + method: "POST", }); }, []); @@ -135,7 +135,7 @@ export default function Dashboard({ } export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { - const userResponse = await fetch(`${API_URL}/auth/user`, { + const userResponse = await fetch(`${API_URL}/user/me`, { headers: { cookie: ctx.req.headers.cookie ?? "", }, @@ -157,7 +157,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { }); const channelsResponse = await fetch( - `${API_URL}/channels/${ctx.params!.server}`, + `${API_URL}/dashboard/channels/${ctx.params!.server}`, { headers: { Authorization: process.env.AUTH!, diff --git a/web/pages/dashboard/index.tsx b/web/pages/dashboard/index.tsx index 9131009..ef64a97 100644 --- a/web/pages/dashboard/index.tsx +++ b/web/pages/dashboard/index.tsx @@ -73,7 +73,7 @@ export default function Dashboard({ } export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { - const userResponse = await fetch(`${API_URL}/auth/user`, { + const userResponse = await fetch(`${API_URL}/user/me`, { headers: { cookie: ctx.req.headers.cookie ?? "", }, @@ -88,7 +88,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { }, }; - const guildsResponse = await fetch(`${API_URL}/auth/user/guilds`, { + const guildsResponse = await fetch(`${API_URL}/user/me/guilds`, { headers: { cookie: ctx.req.headers.cookie ?? "", }, From 402544b4732aa73fb3b23cbff201fa865514f792 Mon Sep 17 00:00:00 2001 From: ToastedToast Date: Wed, 25 Dec 2024 19:13:53 +0800 Subject: [PATCH 8/8] fix(dashboard): update logout endpoint --- web/components/navbar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/navbar.tsx b/web/components/navbar.tsx index d250b4e..15de7bf 100644 --- a/web/components/navbar.tsx +++ b/web/components/navbar.tsx @@ -37,8 +37,8 @@ export const Navbar = () => { const logout = useMutation({ mutationFn: () => - fetch(`${API_URL}/auth/logout`, { - method: "POST", + fetch(`${API_URL}/user/me`, { + method: "DELETE", credentials: "include", }), onSuccess: () => {