feat(api): initial api implementation
vi did:web:vt3e.cat
Fri, 08 May 2026 01:38:02 +0100
8 files changed,
223 insertions(+),
13 deletions(-)
M
src/config.ts
→
src/config.ts
@@ -12,7 +12,8 @@ });
const discordSchema = z.object({ token: z.string(), - app_id: z.string(), + app_id: z.string(), + client_secret: z.string() }); const Project = z.object({
M
src/database/schema/index.ts
→
src/database/schema/index.ts
@@ -4,3 +4,4 @@ export * from "./attachments";
export * from "./bugs"; export * from "./moderators"; export * from "./userPreferences"; +export * from "./sessions";
A
src/database/schema/sessions.ts
@@ -0,0 +1,11 @@
+import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; +import { sql } from "drizzle-orm"; + +export const sessions = sqliteTable("sessions", { + id: text("id").primaryKey(), + userId: text("user_id").notNull(), + username: text("username").notNull(), + createdAt: integer("created_at", { mode: "timestamp" }) + .default(sql`(unixepoch())`) + .notNull(), +});
M
src/discord.ts
→
src/discord.ts
@@ -12,7 +12,7 @@ intents: [
GatewayIntentBits.GuildMessages, GatewayIntentBits.Guilds, GatewayIntentBits.MessageContent, - ], + ], }); client.on(Events.ClientReady, async (client) => {
M
src/index.ts
→
src/index.ts
@@ -3,6 +3,7 @@ import logger from "./utils/logging.ts";
import { migrateDb } from "./database/index.ts"; import client from "./discord.ts"; +import "./server"; async function main() { await migrateDb();
A
src/server/discord.ts
@@ -0,0 +1,6 @@
+import client from "@/discord"; + +export async function getUser(id: string) { + const res = await client.users.fetch(id); + return res; +}
M
src/server/index.ts
→
src/server/index.ts
@@ -1,15 +1,206 @@
-import { loggers } from "@/utils/logging"; -const logger = loggers.server; +import config from "@/config"; +import _logger from "@/utils/logging"; +import { db } from "@/database"; +import { tickets, ticketMessages, moderators, sessions, type Ticket } from "@/database/schema"; +import { desc, eq, asc } from "drizzle-orm"; +import type { BunRequest } from "bun"; +import { getUser } from "./discord"; + +const logger = _logger.child({ name: "server" }); + +const CLIENT_ID = config.discord.app_id +const CLIENT_SECRET = config.discord.client_secret +const REDIRECT_URI = 'http://127.0.0.1:3000/auth/callback'; + +const FRONTEND_URL = process.env.FRONTEND_URL || 'http://127.0.0.1:4321'; + +type AnonymisedTicket = Omit<Ticket, "authorId" | "anonymousId"> +function anonymiseTicket(ticket: Ticket): AnonymisedTicket { + const _ticket = ticket as Partial<Ticket> + delete _ticket.authorId; + return _ticket as AnonymisedTicket +} + +async function getSessionUser(req: BunRequest) { + const cookieHeader = req.headers.get("Cookie"); + if (!cookieHeader) return null; + const sessionCookie = cookieHeader.split("; ").find(c => c.startsWith("session=")); + if (!sessionCookie) return null; + const sessionId = sessionCookie.split("=")[1]; + + const sessionList = await db.select().from(sessions).where(eq(sessions.id, sessionId)).limit(1); + if (sessionList.length === 0) return null; -const CLIENT_ID = "1350116449611677770"; -const CLIENT_SECRET = "92ibZmzOj4eIcejl0ve0MDmQ_Gk3aznS"; + return { id: sessionList[0].userId, username: sessionList[0].username }; +} const server = Bun.serve({ - routes: { - "/path": { - GET: (req) => new Response("Hello"), - }, - }, + hostname: "127.0.0.1", + + routes: { + "/login": () => { + const redirectUri = "https://discord.com/oauth2/authorize?client_id=1350116449611677770&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fauth%2Fcallback&scope=identify+guilds" + + return new Response(null, { + status: 302, + headers: { + Location: redirectUri, + }, + }); + }, + "/auth/callback": { + GET: async (req) => { + const url = new URL(req.url); + const code = url.searchParams.get("code"); + if (!code) return new Response("No code provided", { status: 400, statusText: "No code provided" }) + + const params = new URLSearchParams(); + params.append("client_id", CLIENT_ID); + params.append("client_secret", CLIENT_SECRET); + params.append("grant_type", "authorization_code"); + params.append("code", code); + params.append("redirect_uri", REDIRECT_URI); + + const tokenResponse = await fetch("https://discord.com/api/oauth2/token", { + method: "POST", + body: params, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + + const data = await tokenResponse.json(); + console.log(data) + if (data.error) return new Response(data.error_description, { status: 400, statusText: data.error }) + + const accessToken = data.access_token; + const userResponse = await fetch("https://discord.com/api/users/@me", { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + const userData = await userResponse.json(); + + const sessionId = crypto.randomUUID(); + await db.insert(sessions).values({ + id: sessionId, + userId: userData.id, + username: userData.username + }); + + return new Response(null, { + status: 302, + headers: { + Location: `${FRONTEND_URL}/tickets`, + "Set-Cookie": `session=${sessionId}; Path=/; HttpOnly; SameSite=Lax`, + }, + }); + } + }, + "/me": { + GET: async (req: BunRequest) => { + const user = await getSessionUser(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + + const modCheck = await db.select().from(moderators).where(eq(moderators.user_id, user.id)).limit(1); + const isMod = modCheck.length > 0; + + return new Response(JSON.stringify({ ...user, isMod }), { + headers: { "Content-Type": "application/json" } + }); + } + }, + "/tickets": { + GET: async (req: BunRequest) => { + const user = await getSessionUser(req); + if (!user) return new Response(null, { status: 302, headers: { Location: "/login" } }); + + const modCheck = await db.select().from(moderators).where(eq(moderators.user_id, user.id)).limit(1); + if (modCheck.length === 0) { + return new Response("Forbidden: You must be a moderator to view tickets.", { status: 403 }); + } + + const url = new URL(req.url); + const limitStr = url.searchParams.get("limit") || "50"; + const limit = parseInt(limitStr, 10); + + const allTickets = await db.select().from(tickets).limit(limit).orderBy(desc(tickets.createdAt)); + const anonymisedTickets = allTickets.map(anonymiseTicket); + + return new Response(JSON.stringify(anonymisedTickets), { headers: { "Content-Type": "application/json" } }); + } + }, + "/tickets/:id": { + GET: async (req: BunRequest) => { + const user = await getSessionUser(req); + if (!user) return new Response(null, { status: 302, headers: { Location: "/login" } }); + + const modCheck = await db.select().from(moderators).where(eq(moderators.user_id, user.id)).limit(1); + if (modCheck.length === 0) return new Response("Forbidden: You must be a moderator to view tickets.", { status: 403 }); + + const { id } = req.params; + const ticketId = parseInt(id, 10); + if (isNaN(ticketId)) return new Response("Invalid ID", { status: 400 }); + + const ticketList = await db.select().from(tickets).where(eq(tickets.id, ticketId)).limit(1); + const _ticket = ticketList[0]; + if (!_ticket) return new Response("Ticket not found", { status: 404 }); + const ticket = anonymiseTicket(_ticket); + + const messages = await db.select().from(ticketMessages).where(eq(ticketMessages.ticketId, ticketId)).orderBy(asc(ticketMessages.createdAt)); + + const authors = [...new Set(messages.map(m => m.authorId))]; + const authorData = await Promise.all(authors.map(async (authorId) => { + try { + return await getUser(authorId); + } catch { + return { id: authorId, username: "Unknown" }; + } + })); + + const newMessages = messages.map((message) => { + const author = authorData.find((a) => a.id === message.authorId); + return { ...message, author }; + }); + + return new Response(JSON.stringify({ ticket, messages: newMessages }), { headers: { "Content-Type": "application/json" } }); + }, + DELETE: async (req: BunRequest) => { + const user = await getSessionUser(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + + const modCheck = await db.select().from(moderators).where(eq(moderators.user_id, user.id)).limit(1); + if (modCheck.length === 0) return new Response("Forbidden: You must be a moderator to delete tickets.", { status: 403 }); + + const { id } = req.params; + const ticketId = parseInt(id, 10); + if (isNaN(ticketId)) return new Response("Invalid ID", { status: 400 }); + + const deleted = await db.delete(tickets).where(eq(tickets.id, ticketId)).returning(); + if (deleted.length === 0) return new Response("Ticket not found", { status: 404 }); + + return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json" } }); + } + }, + "/moderators": { + GET: async (req: BunRequest) => { + const user = await getSessionUser(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + + const modCheck = await db.select().from(moderators).where(eq(moderators.user_id, user.id)).limit(1); + if (modCheck.length === 0) return new Response("Forbidden", { status: 403 }); + + const mods = await db.select().from(moderators); + const ids = mods.map(mod => mod.user_id); + + const moderatorUsers = await Promise.all(ids.map(id => getUser(id))); + const hydrated = mods.map((mod, index) => ({ ...moderatorUsers[index], pronouns: mod })); + + return new Response(JSON.stringify(hydrated), { headers: { "Content-Type": "application/json" } }); + } + } + }, }); const PORT = process.env.PORT || 3000;
M
src/utils/logging.ts
→
src/utils/logging.ts
@@ -21,8 +21,7 @@ export const loggers = {
events: logger.child({ name: "events" }), interactions: logger.child({ name: "interactions" }), config: logger.child({ name: "config" }), - automod: logger.child({ name: "automod" }), - server: logger.child({ name: "server" }), + automod: logger.child({ name: "automod" }) }; export default logger;