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; };