From 320e2cc121181c67d04edc4fb26d40319de9a65d Mon Sep 17 00:00:00 2001 From: creations Date: Sat, 3 May 2025 13:56:57 -0400 Subject: [PATCH] Add guild routes, setup tables, validators, and update environment example Added routes for guild creation, deletion, joining, leaving, and invites Set up Cassandra tables for guilds, invites, and members Added validators for guild input Updated .env.example with required config values --- .env.example | 18 +++- config/index.ts | 8 +- config/setup/index.ts | 52 ++++++---- config/setup/tables/guilds/index.ts | 34 +++++++ config/setup/tables/guilds/invites.ts | 20 ++++ config/setup/tables/guilds/members.ts | 32 ++++++ src/lib/validators/guild.ts | 29 ++++++ src/routes/guild/[id]/delete.ts | 92 +++++++++++++++++ src/routes/guild/[id]/invite/create.ts | 110 +++++++++++++++++++++ src/routes/guild/[id]/leave.ts | 96 ++++++++++++++++++ src/routes/guild/create.ts | 104 +++++++++++++++++++ src/routes/guild/join[id].ts | 132 +++++++++++++++++++++++++ types/config.d.ts | 4 + types/validation.d.ts | 1 + 14 files changed, 708 insertions(+), 24 deletions(-) create mode 100644 config/setup/tables/guilds/index.ts create mode 100644 config/setup/tables/guilds/invites.ts create mode 100644 config/setup/tables/guilds/members.ts create mode 100644 src/lib/validators/guild.ts create mode 100644 src/routes/guild/[id]/delete.ts create mode 100644 src/routes/guild/[id]/invite/create.ts create mode 100644 src/routes/guild/[id]/leave.ts create mode 100644 src/routes/guild/create.ts create mode 100644 src/routes/guild/join[id].ts diff --git a/.env.example b/.env.example index 256b06a..30fb523 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,31 @@ +# Environment # NODE_ENV=development HOST=0.0.0.0 PORT=8080 # Redis (Required) REDIS_URL=redis://localhost:6379 -REDIS_TTL=3600 +REDIS_TTL=3600 # Time-to-live in seconds (default: 1 hour) # Cassandra (Required) CASSANDRA_HOST=localhost CASSANDRA_PORT=9042 CASSANDRA_DATACENTER=datacenter1 CASSANDRA_CONTACT_POINTS=localhost +CASSANDRA_KEYSPACE=void_db -# Optional -CASSANDRA_KEYSPACE= +# Cassandra (Optional) CASSANDRA_USERNAME= CASSANDRA_AUTH_ENABLED=false CASSANDRA_PASSWORD= + +# JWT (Required) +JWT_SECRET=your_secret_here +JWT_EXPIRATION=1d +JWT_ISSUER=void + +# JWT (Optional) +JWT_ALGORITHM=HS256 + +# Frontend (Required) +FRONTEND_ORIGIN=http://localhost:3000 diff --git a/config/index.ts b/config/index.ts index 861117a..6aa914e 100644 --- a/config/index.ts +++ b/config/index.ts @@ -7,6 +7,10 @@ const environment: Environment = { process.env.NODE_ENV === "development" || process.argv.includes("--dev"), }; +const frontend: FrontendConfig = { + origin: process.env.FRONTEND_ORIGIN || "", +}; + function verifyRequiredVariables(): void { const requiredVariables = [ "HOST", @@ -24,6 +28,8 @@ function verifyRequiredVariables(): void { "JWT_SECRET", "JWT_EXPIRATION", "JWT_ISSUER", + + "FRONTEND_ORIGIN", ]; let hasError = false; @@ -45,4 +51,4 @@ export * from "@config/cassandra"; export * from "@config/jwt"; export * from "@config/redis"; -export { environment, verifyRequiredVariables }; +export { environment, frontend, verifyRequiredVariables }; diff --git a/config/setup/index.ts b/config/setup/index.ts index c8a7637..785c933 100644 --- a/config/setup/index.ts +++ b/config/setup/index.ts @@ -1,10 +1,39 @@ -import { readdir } from "node:fs/promises"; +import { readdir, stat } from "node:fs/promises"; import { extname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { cassandra as cassandraConfig, verifyRequiredVariables } from "@config"; import { logger } from "@creations.works/logger"; import { cassandra } from "@lib/cassandra"; +async function loadTables(dir: string): Promise { + const entries = await readdir(dir); + + for (const entry of entries) { + const fullPath = join(dir, entry); + const stats = await stat(fullPath); + + if (stats.isDirectory()) { + await loadTables(fullPath); + continue; + } + + if (extname(entry) !== ".ts") continue; + + const modulePath = pathToFileURL(fullPath).href; + const mod = await import(modulePath); + + if (typeof mod.default === "function") { + await mod.default(); + logger.info(`Ran default export from ${fullPath}`); + } else if (typeof mod.createTable === "function") { + await mod.createTable(); + logger.info(`Ran createTable from ${fullPath}`); + } else { + logger.warn(`No callable export found in ${fullPath}`); + } + } +} + async function setup(): Promise { verifyRequiredVariables(); await cassandra.connect({ withKeyspace: false }); @@ -29,24 +58,7 @@ async function setup(): Promise { logger.info(`Keyspace "${keyspace}" ensured and selected.`); const tablesDir = resolve("config", "setup", "tables"); - const files = await readdir(tablesDir); - - for (const file of files) { - if (extname(file) !== ".ts") continue; - - const modulePath = pathToFileURL(join(tablesDir, file)).href; - const mod = await import(modulePath); - - if (typeof mod.default === "function") { - await mod.default(); - logger.info(`Ran default export from ${file}`); - } else if (typeof mod.createTable === "function") { - await mod.createTable(); - logger.info(`Ran createTable from ${file}`); - } else { - logger.warn(`No callable export found in ${file}`); - } - } + await loadTables(tablesDir); logger.info("Setup complete."); } @@ -58,7 +70,7 @@ setup() }) .finally(() => { cassandra.shutdown().catch((error: Error) => { - logger.error(["Error shutting down Cassandra client:", error as Error]); + logger.error(["Error shutting down Cassandra client:", error]); }); process.exit(0); diff --git a/config/setup/tables/guilds/index.ts b/config/setup/tables/guilds/index.ts new file mode 100644 index 0000000..f7ea061 --- /dev/null +++ b/config/setup/tables/guilds/index.ts @@ -0,0 +1,34 @@ +import { cassandra } from "@lib/cassandra"; + +async function createTable() { + const client = cassandra.getClient(); + + // main guilds table + await client.execute(` + CREATE TABLE IF NOT EXISTS guilds ( + id TEXT PRIMARY KEY, + name TEXT, + icon TEXT, + owner_id TEXT, + + system_channel_id TEXT, + rules_channel_id TEXT, + anouncements_channel_id TEXT, + + created_at TIMESTAMP, + updated_at TIMESTAMP + ); + `); + + // lookup table to enforce unique guild names per user + await client.execute(` + CREATE TABLE IF NOT EXISTS guilds_by_owner ( + owner_id TEXT, + name TEXT, + guild_id TEXT, + PRIMARY KEY ((owner_id), name) + ); + `); +} + +export { createTable }; diff --git a/config/setup/tables/guilds/invites.ts b/config/setup/tables/guilds/invites.ts new file mode 100644 index 0000000..67a5c32 --- /dev/null +++ b/config/setup/tables/guilds/invites.ts @@ -0,0 +1,20 @@ +import { cassandra } from "@lib/cassandra"; + +async function createTable() { + const client = cassandra.getClient(); + + await client.execute(` + CREATE TABLE IF NOT EXISTS guild_invites ( + invite_code TEXT PRIMARY KEY, + guild_id TEXT, + created_by TEXT, + created_at TIMESTAMP, + expires_at TIMESTAMP, + max_uses INT, + uses INT, + is_revoked BOOLEAN + ); + `); +} + +export { createTable }; diff --git a/config/setup/tables/guilds/members.ts b/config/setup/tables/guilds/members.ts new file mode 100644 index 0000000..694aba2 --- /dev/null +++ b/config/setup/tables/guilds/members.ts @@ -0,0 +1,32 @@ +import { cassandra } from "@lib/cassandra"; + +async function createTable() { + const client = cassandra.getClient(); + + await client.execute(` + CREATE TABLE IF NOT EXISTS guild_members ( + guild_id TEXT, + user_id TEXT, + roles SET, + joined_at TIMESTAMP, + is_banned BOOLEAN, + invite_id TEXT, + PRIMARY KEY ((guild_id), user_id) + ); + `); + + // reverse lookup table for all guilds a user is in + await client.execute(` + CREATE TABLE IF NOT EXISTS members_by_user ( + user_id TEXT, + guild_id TEXT, + roles SET, + joined_at TIMESTAMP, + is_banned BOOLEAN, + invite_id TEXT, + PRIMARY KEY ((user_id), guild_id) + ); + `); +} + +export { createTable }; diff --git a/src/lib/validators/guild.ts b/src/lib/validators/guild.ts new file mode 100644 index 0000000..1bc6f74 --- /dev/null +++ b/src/lib/validators/guild.ts @@ -0,0 +1,29 @@ +const guildNameRestrictions: genericValidation = { + length: { min: 3, max: 100 }, + regex: /^[\p{L}\p{N} ._-]+$/u, +}; + +function isValidGuildName(rawName: string): validationResult { + const name = rawName.trim().normalize("NFC"); + + if (!name) return { valid: false, error: "Guild name is required" }; + + if (name.length < guildNameRestrictions.length.min) + return { valid: false, error: "Guild name is too short" }; + + if (name.length > guildNameRestrictions.length.max) + return { valid: false, error: "Guild name is too long" }; + + if (!guildNameRestrictions.regex.test(name)) + return { valid: false, error: "Guild name contains invalid characters" }; + + if (/^[._ -]|[._ -]$/.test(name)) + return { + valid: false, + error: "Guild name can't start or end with special characters", + }; + + return { valid: true, name }; +} + +export { guildNameRestrictions, isValidGuildName }; diff --git a/src/routes/guild/[id]/delete.ts b/src/routes/guild/[id]/delete.ts new file mode 100644 index 0000000..2c801df --- /dev/null +++ b/src/routes/guild/[id]/delete.ts @@ -0,0 +1,92 @@ +import { logger } from "@creations.works/logger"; +import { cassandra } from "@lib/cassandra"; +import { jsonResponse } from "@lib/http"; +import type { Client } from "cassandra-driver"; + +const routeDef: RouteDef = { + method: "DELETE", + accepts: "application/json", + returns: "application/json;charset=utf-8", +}; + +async function handler(request: ExtendedRequest): Promise { + const user: UserSession | null = request.session; + if (!user) { + return jsonResponse(401, { + message: "Unauthorized", + error: "You must be logged in to delete a guild", + }); + } + + const { id: guildId } = request.params; + const { name: providedName } = request.query; + + if (!guildId || !providedName) { + return jsonResponse(400, { + message: "Missing parameters", + error: "Both guild ID and name are required", + }); + } + + const client: Client = cassandra.getClient(); + + const result = await client.execute( + "SELECT name, owner_id FROM guilds WHERE id = ?", + [guildId], + { prepare: true }, + ); + + if (result.rowLength === 0) { + return jsonResponse(404, { + message: "Not Found", + error: "Guild does not exist", + }); + } + + const row = result.first(); + + if (row.owner_id !== user.id) { + return jsonResponse(403, { + message: "Forbidden", + error: "You do not own this guild", + }); + } + + if (row.name !== providedName) { + return jsonResponse(409, { + message: "Conflict", + error: "Guild name does not match provided value", + }); + } + + try { + await client.execute("DELETE FROM guilds WHERE id = ?", [guildId], { + prepare: true, + }); + + await client.execute( + "DELETE FROM guilds_by_owner WHERE owner_id = ? AND name = ?", + [user.id, row.name], + { prepare: true }, + ); + + logger.custom( + "[GUILD DELETE]", + `(${guildId})`, + `${row.name} - Owner: ${user.id}`, + "31", + ); + + return jsonResponse(200, { + message: "Guild deleted successfully", + data: { id: guildId, name: row.name }, + }); + } catch (error) { + return jsonResponse(500, { + message: "Internal server error", + error: "Failed to delete guild", + }); + } +} + +export { handler, routeDef }; diff --git a/src/routes/guild/[id]/invite/create.ts b/src/routes/guild/[id]/invite/create.ts new file mode 100644 index 0000000..a638610 --- /dev/null +++ b/src/routes/guild/[id]/invite/create.ts @@ -0,0 +1,110 @@ +import { frontend } from "@config"; +import { logger } from "@creations.works/logger"; +import { cassandra } from "@lib/cassandra"; +import { jsonResponse } from "@lib/http"; +import { pika } from "@lib/pika"; +import type { Client } from "cassandra-driver"; + +const routeDef: RouteDef = { + method: "POST", + accepts: "application/json", + returns: "application/json;charset=utf-8", + needsBody: "json", +}; + +async function handler( + request: ExtendedRequest, + requestBody: unknown, +): Promise { + const user: UserSession | null = request.session; + if (!user) { + return jsonResponse(401, { + message: "Unauthorized", + error: "You must be logged in to create an invite", + }); + } + + const { id: guildId } = request.params; + if (!guildId) { + return jsonResponse(400, { + message: "Missing guild ID", + }); + } + + const { max_uses, expires_at } = requestBody as { + max_uses?: number; + expires_at?: string | null; + }; + + const cassandraClient: Client = cassandra.getClient(); + + // TODO: When permissions are implemented, check if the user has permission to create an invite + const guild = await cassandraClient.execute( + "SELECT owner_id FROM guilds WHERE id = ?", + [guildId], + { prepare: true }, + ); + + if (guild.rowLength === 0) { + return jsonResponse(404, { + message: "Guild not found", + }); + } + + const guildData = guild.first(); + if (guildData.owner_id !== user.id) { + return jsonResponse(403, { + message: "Forbidden", + error: "You are not the owner of this guild", + }); + } + + const inviteCode = pika.gen("invite"); + const now = new Date(); + const expiresAt = expires_at ? new Date(expires_at) : null; + + try { + await cassandraClient.execute( + `INSERT INTO guild_invites ( + invite_code, guild_id, created_by, created_at, + expires_at, max_uses, uses, is_revoked + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + inviteCode, + guildId, + user.id, + now, + expiresAt, + max_uses ?? null, + 0, + false, + ], + { prepare: true }, + ); + + logger.custom( + "[GUILD INVITE]", + `(${inviteCode})`, + `Guild: ${guildId} by ${user.id}`, + "35", + ); + + return jsonResponse(201, { + message: "Invite created successfully", + data: { + url: `${frontend.origin}/invite/${inviteCode}`, // TODO: Update this to use the correct route ? + invite: inviteCode, + guild: guildId, + expires_at: expiresAt, + max_uses: max_uses ?? null, + }, + }); + } catch (error) { + return jsonResponse(500, { + message: "Internal server error", + error: "Failed to create invite", + }); + } +} + +export { handler, routeDef }; diff --git a/src/routes/guild/[id]/leave.ts b/src/routes/guild/[id]/leave.ts new file mode 100644 index 0000000..7c14f8e --- /dev/null +++ b/src/routes/guild/[id]/leave.ts @@ -0,0 +1,96 @@ +import { logger } from "@creations.works/logger"; +import { cassandra } from "@lib/cassandra"; +import { jsonResponse } from "@lib/http"; +import type { Client } from "cassandra-driver"; + +const routeDef: RouteDef = { + method: "DELETE", + accepts: "*/*", + returns: "application/json;charset=utf-8", +}; + +async function handler(request: ExtendedRequest): Promise { + const user: UserSession | null = request.session; + if (!user) { + return jsonResponse(401, { + message: "Unauthorized", + error: "You must be logged in to leave a guild", + }); + } + + const { id: guildId } = request.params; + if (!guildId) { + return jsonResponse(400, { + message: "Missing guild ID", + }); + } + + const client: Client = cassandra.getClient(); + + const guild = await client.execute( + "SELECT owner_id FROM guilds WHERE id = ?", + [guildId], + { prepare: true }, + ); + + if (guild.rowLength === 0) { + return jsonResponse(404, { + message: "Guild not found", + }); + } + + const ownerId = guild.first().owner_id; + if (ownerId === user.id) { + return jsonResponse(403, { + message: "You cannot leave a guild you own", + }); + } + + const membership = await client.execute( + "SELECT user_id FROM guild_members WHERE guild_id = ? AND user_id = ?", + [guildId, user.id], + { prepare: true }, + ); + + if (membership.rowLength === 0) { + return jsonResponse(409, { + message: "You are not a member of this guild", + }); + } + + try { + await client.execute( + "DELETE FROM guild_members WHERE guild_id = ? AND user_id = ?", + [guildId, user.id], + { prepare: true }, + ); + + await client.execute( + "DELETE FROM members_by_user WHERE user_id = ? AND guild_id = ?", + [user.id, guildId], + { prepare: true }, + ); + + logger.custom( + "[GUILD LEAVE]", + `(${guildId})`, + `User ${user.id} left`, + "33", + ); + + return jsonResponse(200, { + message: "Left guild successfully", + data: { + guild_id: guildId, + }, + }); + } catch (error) { + logger.error(error as Error); + return jsonResponse(500, { + message: "Internal server error", + error: "Failed to leave guild", + }); + } +} + +export { handler, routeDef }; diff --git a/src/routes/guild/create.ts b/src/routes/guild/create.ts new file mode 100644 index 0000000..d6dc38c --- /dev/null +++ b/src/routes/guild/create.ts @@ -0,0 +1,104 @@ +import { isValidGuildName } from "@/lib/validators/guild"; +import { logger } from "@creations.works/logger"; +import { cassandra } from "@lib/cassandra"; +import { jsonResponse } from "@lib/http"; +import { pika } from "@lib/pika"; +import type { Client } from "cassandra-driver"; + +const routeDef: RouteDef = { + method: "POST", + accepts: "application/json", + returns: "application/json;charset=utf-8", + needsBody: "json", +}; + +async function handler( + request: ExtendedRequest, + requestBody: unknown, +): Promise { + const { name } = requestBody as { name: string }; + + const user: UserSession | null = request.session; + if (!user) { + return jsonResponse(401, { + message: "Unauthorized", + error: "You must be logged in to create a guild", + }); + } + + const errors: string[] = []; + const { valid, name: validName, error } = isValidGuildName(name.trim()); + + if (!valid) { + errors.push(error || "Invalid guild name"); + } + + if (errors.length > 0) { + return jsonResponse(400, { + message: "Validation failed", + error: errors.join("; "), + }); + } + + const cassandraClient: Client = cassandra.getClient(); + + const existing = await cassandraClient.execute( + "SELECT guild_id FROM guilds_by_owner WHERE owner_id = ? AND name = ?", + [user.id, validName], + { prepare: true }, + ); + + if (existing.rowLength > 0) { + return jsonResponse(409, { + message: "Conflict", + error: "You already have a guild with this name", + }); + } + + const guildId = pika.gen("guild"); + const now = new Date(); + + try { + await cassandraClient.execute( + `INSERT INTO guilds ( + id, name, owner_id, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?)`, + [guildId, validName, user.id, now, now], + { prepare: true }, + ); + + await cassandraClient.execute( + `INSERT INTO guilds_by_owner ( + owner_id, name, guild_id + ) VALUES (?, ?, ?)`, + [user.id, validName, guildId], + { prepare: true }, + ); + + logger.custom( + "[GUILD CREATE]", + `(${guildId})`, + `${validName} - Owner: ${user.id}`, + "36", + ); + + return jsonResponse(201, { + message: "Guild created successfully", + data: { + id: guildId, + name: validName, + owner: user.id, + created_at: now, + updated_at: now, + }, + }); + } catch (error) { + return jsonResponse(500, { + message: "Internal server error", + error: "Failed to create guild", + }); + } +} + +export { handler, routeDef }; diff --git a/src/routes/guild/join[id].ts b/src/routes/guild/join[id].ts new file mode 100644 index 0000000..b441b76 --- /dev/null +++ b/src/routes/guild/join[id].ts @@ -0,0 +1,132 @@ +import { logger } from "@creations.works/logger"; +import { cassandra } from "@lib/cassandra"; +import { jsonResponse } from "@lib/http"; +import type { Client } from "cassandra-driver"; + +const routeDef: RouteDef = { + method: "POST", + accepts: "*/*", + returns: "application/json;charset=utf-8", +}; + +async function handler(request: ExtendedRequest): Promise { + const user: UserSession | null = request.session; + if (!user) { + return jsonResponse(401, { + message: "Unauthorized", + error: "You must be logged in to join a guild", + }); + } + + const { id: invite } = request.params; + + if (!invite) { + return jsonResponse(400, { + message: "Missing invite code", + error: "Invite code is required", + }); + } + + const client: Client = cassandra.getClient(); + + const inviteResult = await client.execute( + "SELECT * FROM guild_invites WHERE invite_code = ?", + [invite], + { prepare: true }, + ); + + if (inviteResult.rowLength === 0) { + return jsonResponse(404, { + message: "Invalid invite code", + }); + } + + const inviteData = inviteResult.first(); + + if (inviteData.is_revoked) { + return jsonResponse(403, { + message: "This invite has been revoked", + }); + } + + if (inviteData.expires_at && inviteData.expires_at < new Date()) { + return jsonResponse(403, { + message: "This invite has expired", + }); + } + + if (inviteData.max_uses !== null && inviteData.uses >= inviteData.max_uses) { + return jsonResponse(403, { + message: "This invite has reached its usage limit", + }); + } + + const guildId = inviteData.guild_id; + + const memberResult = await client.execute( + "SELECT is_banned FROM guild_members WHERE guild_id = ? AND user_id = ?", + [guildId, user.id], + { prepare: true }, + ); + + if (memberResult.rowLength > 0) { + const member = memberResult.first(); + if (member.is_banned) { + return jsonResponse(403, { + message: "You are banned from this guild", + }); + } + return jsonResponse(409, { + message: "You are already a member of this guild", + }); + } + + const now = new Date(); + + try { + await client.execute( + `INSERT INTO guild_members ( + guild_id, user_id, roles, joined_at, is_banned, invite_id + ) VALUES (?, ?, ?, ?, ?, ?)`, + [guildId, user.id, ["member"], now, false, invite], + { prepare: true }, + ); + + await client.execute( + `INSERT INTO members_by_user ( + user_id, guild_id, roles, joined_at, is_banned, invite_id + ) VALUES (?, ?, ?, ?, ?, ?)`, + [user.id, guildId, ["member"], now, false, invite], + { prepare: true }, + ); + + await client.execute( + "UPDATE guild_invites SET uses = ? WHERE invite_code = ?", + [inviteData.uses + 1, invite], + { prepare: true }, + ); + + logger.custom( + "[GUILD JOIN VIA INVITE]", + `(${guildId})`, + `User ${user.id} joined with invite ${invite}`, + "32", + ); + + return jsonResponse(200, { + message: "Joined guild successfully", + data: { + guild_id: guildId, + role: "member", + }, + }); + } catch (error) { + logger.error(error as Error); + return jsonResponse(500, { + message: "Internal server error", + error: "Failed to join guild", + }); + } +} + +export { handler, routeDef }; diff --git a/types/config.d.ts b/types/config.d.ts index 729f900..345ed1e 100644 --- a/types/config.d.ts +++ b/types/config.d.ts @@ -21,3 +21,7 @@ type JWTConfig = { issuer: string; algorithm: string; }; + +type FrontendConfig = { + origin: string; +}; diff --git a/types/validation.d.ts b/types/validation.d.ts index 93ca84b..36a9f88 100644 --- a/types/validation.d.ts +++ b/types/validation.d.ts @@ -7,4 +7,5 @@ type validationResult = { valid: boolean; error?: string; username?: string; + name?: string; };