From 93ed37b3e986c7b03476ce68de6251bcf9453e09 Mon Sep 17 00:00:00 2001 From: creations Date: Sun, 11 May 2025 10:51:48 -0400 Subject: [PATCH] - better reading of avalable routes - add name validation for channel names and catagories - add create, delete, move for channels --- config/setup/tables/guilds/categories.ts | 29 ++++ config/setup/tables/guilds/channels.ts | 37 +++++ src/index.ts | 1 - src/lib/pika.ts | 5 + src/lib/validators/guild.ts | 67 +++++++- src/lib/validators/index.ts | 1 + src/routes/guild/[id]/channel/create.ts | 187 +++++++++++++++++++++++ src/routes/guild/[id]/channel/delete.ts | 90 +++++++++++ src/routes/guild/[id]/channel/move.ts | 131 ++++++++++++++++ src/server.ts | 17 ++- 10 files changed, 559 insertions(+), 6 deletions(-) create mode 100644 config/setup/tables/guilds/categories.ts create mode 100644 config/setup/tables/guilds/channels.ts create mode 100644 src/routes/guild/[id]/channel/create.ts create mode 100644 src/routes/guild/[id]/channel/delete.ts create mode 100644 src/routes/guild/[id]/channel/move.ts diff --git a/config/setup/tables/guilds/categories.ts b/config/setup/tables/guilds/categories.ts new file mode 100644 index 0000000..b162d63 --- /dev/null +++ b/config/setup/tables/guilds/categories.ts @@ -0,0 +1,29 @@ +import { cassandra } from "@lib/cassandra"; + +async function createTable() { + const client = cassandra.getClient(); + + await client.execute(` + CREATE TABLE IF NOT EXISTS categories ( + id TEXT PRIMARY KEY, + guild_id TEXT, + name TEXT, + position INT, + created_at TIMESTAMP, + updated_at TIMESTAMP + ); + `); + + await client.execute(` + CREATE INDEX IF NOT EXISTS categories_guild_id_idx ON categories (guild_id); + `); +} + +async function dropTable() { + const client = cassandra.getClient(); + + await client.execute("DROP TABLE IF EXISTS categories;"); + await client.execute("DROP INDEX IF EXISTS categories_guild_id_idx;"); +} + +export { createTable, dropTable }; diff --git a/config/setup/tables/guilds/channels.ts b/config/setup/tables/guilds/channels.ts new file mode 100644 index 0000000..b29ff85 --- /dev/null +++ b/config/setup/tables/guilds/channels.ts @@ -0,0 +1,37 @@ +import { cassandra } from "@lib/cassandra"; + +async function createTable() { + const client = cassandra.getClient(); + + await client.execute(` + CREATE TABLE IF NOT EXISTS channels ( + id TEXT PRIMARY KEY, + guild_id TEXT, + name TEXT, + type TEXT, + topic TEXT, + position INT, + category_id TEXT, + created_at TIMESTAMP, + updated_at TIMESTAMP + ); + `); + + await client.execute(` + CREATE INDEX IF NOT EXISTS channels_guild_id_idx ON channels (guild_id); + `); + + await client.execute(` + CREATE INDEX IF NOT EXISTS channels_category_id_idx ON channels (category_id); + `); +} + +async function dropTable() { + const client = cassandra.getClient(); + + await client.execute("DROP TABLE IF EXISTS channels;"); + await client.execute("DROP INDEX IF EXISTS channels_guild_id_idx;"); + await client.execute("DROP INDEX IF EXISTS channels_category_id_idx;"); +} + +export { createTable, dropTable }; diff --git a/src/index.ts b/src/index.ts index ed44a26..ed752ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,6 @@ async function main(): Promise { } await cassandra.connect(); - serverHandler.initialize(); } diff --git a/src/lib/pika.ts b/src/lib/pika.ts index be8c627..ab8bf94 100644 --- a/src/lib/pika.ts +++ b/src/lib/pika.ts @@ -21,6 +21,11 @@ const pika = new Pika([ prefix: "ch", description: "Channel ID", }, + "category", + { + prefix: "cat", + description: "Category ID", + }, "role", { prefix: "role", diff --git a/src/lib/validators/guild.ts b/src/lib/validators/guild.ts index 1bc6f74..884bf9b 100644 --- a/src/lib/validators/guild.ts +++ b/src/lib/validators/guild.ts @@ -26,4 +26,69 @@ function isValidGuildName(rawName: string): validationResult { return { valid: true, name }; } -export { guildNameRestrictions, isValidGuildName }; +const channelNameRestrictions: genericValidation = { + length: { min: 1, max: 100 }, + regex: /^[\p{L}\p{N}\p{Emoji}_\-.]+$/u, // emojis + underscore, dot, dash ? +}; + +function isValidChannelName(rawName: string): validationResult { + const name = rawName.trim().normalize("NFC"); + + if (!name) return { valid: false, error: "Channel name is required" }; + + if (name.length < channelNameRestrictions.length.min) + return { valid: false, error: "Channel name is too short" }; + + if (name.length > channelNameRestrictions.length.max) + return { valid: false, error: "Channel name is too long" }; + + if (/\s/.test(name)) + return { valid: false, error: "Channel name cannot contain spaces" }; + + if (!channelNameRestrictions.regex.test(name)) + return { + valid: false, + error: "Channel name contains invalid characters", + }; + + if (/^[._-]|[._-]$/.test(name)) + return { + valid: false, + error: "Channel name can't start or end with special characters", + }; + + return { valid: true, name }; +} + +const categoryNameRestrictions: genericValidation = { + length: { min: 1, max: 100 }, + regex: /^[\p{L}\p{N} ._-]+$/u, +}; + +function isValidCategoryName(rawName: string): validationResult { + const name = rawName.trim().normalize("NFC"); + + if (!name) return { valid: false, error: "Category name is required" }; + + if (name.length < categoryNameRestrictions.length.min) + return { valid: false, error: "Category name is too short" }; + + if (name.length > categoryNameRestrictions.length.max) + return { valid: false, error: "Category name is too long" }; + + if (!categoryNameRestrictions.regex.test(name)) + return { + valid: false, + error: "Category name contains invalid characters", + }; + + if (/^[._ -]|[._ -]$/.test(name)) + return { + valid: false, + error: "Category name can't start or end with special characters", + }; + + return { valid: true, name }; +} + +export { isValidGuildName, isValidChannelName, isValidCategoryName }; diff --git a/src/lib/validators/index.ts b/src/lib/validators/index.ts index 555cc17..55347db 100644 --- a/src/lib/validators/index.ts +++ b/src/lib/validators/index.ts @@ -1,3 +1,4 @@ export * from "@lib/validators/name"; export * from "@lib/validators/password"; export * from "@lib/validators/email"; +export * from "@lib/validators/guild"; diff --git a/src/routes/guild/[id]/channel/create.ts b/src/routes/guild/[id]/channel/create.ts new file mode 100644 index 0000000..e5da8e5 --- /dev/null +++ b/src/routes/guild/[id]/channel/create.ts @@ -0,0 +1,187 @@ +import { logger } from "@creations.works/logger"; +import { cassandra } from "@lib/cassandra"; +import { jsonResponse } from "@lib/http"; +import { pika } from "@lib/pika"; +import { isValidChannelName } from "@lib/validators"; +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 a channel", + }); + } + + const { name, type, topic, position, category_id } = requestBody as Record< + string, + unknown + >; + + const { id: guildId } = request.params; + if (!guildId || typeof guildId !== "string") { + return jsonResponse(400, { + message: "Missing or invalid guild ID", + }); + } + + const errors: string[] = []; + + if (typeof name !== "string") { + errors.push("Channel name is required"); + } else { + const result = isValidChannelName(name); + if (!result.valid) errors.push(result.error ?? "Invalid channel name"); + } + + if (type !== "text" && type !== "voice") { + errors.push("Invalid or missing type (must be 'text' or 'voice')"); + } + + if ( + topic !== undefined && + (typeof topic !== "string" || topic.length > 1024) + ) { + errors.push("Invalid topic"); + } + + if ( + position !== undefined && + (typeof position !== "number" || position < 0) + ) { + errors.push("Invalid position"); + } + + if (category_id !== undefined) { + if (typeof category_id !== "string") { + errors.push("Invalid category_id"); + } else { + const result = pika.validate(category_id, "category"); + if (!result) errors.push("Invalid category name format"); + } + } + + if (errors.length > 0) { + return jsonResponse(400, { + message: "Validation failed", + error: errors.join("; "), + }); + } + + const now = new Date(); + const channelId = pika.gen("channel"); + const client: Client = cassandra.getClient(); + + let finalPosition = typeof position === "number" ? position : 0; + + if (position === undefined) { + try { + const result = await client.execute( + "SELECT position FROM channels WHERE guild_id = ?", + [guildId], + { prepare: true }, + ); + + let maxPos = -1; + for (const row of result.rows) { + if (typeof row.position === "number" && row.position > maxPos) { + maxPos = row.position; + } + } + + finalPosition = maxPos + 1; + } catch (err) { + logger.error(["Failed to fetch existing channels:", err as Error]); + return jsonResponse(500, { + message: "Internal server error", + error: "Could not determine channel position", + }); + } + } + + if (category_id !== undefined) { + try { + const categoryCheck = await client.execute( + "SELECT id FROM categories WHERE id = ? AND guild_id = ?", + [category_id, guildId], + { prepare: true }, + ); + + if (categoryCheck.rowLength === 0) { + return jsonResponse(400, { + message: "Validation failed", + error: "Provided category does not exist in this guild", + }); + } + } catch (err) { + logger.error(["Failed to verify category:", err as Error]); + return jsonResponse(500, { + message: "Internal server error", + error: "Failed to verify category existence", + }); + } + } + + // TODO: Check if the user has permission to create a channel in this guild + + try { + await client.execute( + `INSERT INTO channels ( + id, guild_id, name, type, topic, position, category_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + channelId, + guildId, + name, + type, + topic ?? null, + finalPosition, + category_id ?? null, + now, + now, + ], + { prepare: true }, + ); + + logger.custom( + "[CHANNEL CREATE]", + `(${channelId})`, + `${name} in guild ${guildId} by ${user.id}`, + "36", + ); + + return jsonResponse(201, { + message: "Channel created successfully", + data: { + id: channelId, + guildId, + name, + type, + topic: topic ?? null, + position: finalPosition, + category_id: category_id ?? null, + created_at: now, + updated_at: now, + }, + }); + } catch (error) { + logger.error(["Failed to create channel:", error as Error]); + return jsonResponse(500, { + message: "Internal server error", + error: "Failed to create channel", + }); + } +} + +export { handler, routeDef }; diff --git a/src/routes/guild/[id]/channel/delete.ts b/src/routes/guild/[id]/channel/delete.ts new file mode 100644 index 0000000..ddeef1d --- /dev/null +++ b/src/routes/guild/[id]/channel/delete.ts @@ -0,0 +1,90 @@ +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: "DELETE", + 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 delete a channel", + }); + } + + const { id: guildId } = request.params; + if (!guildId || typeof guildId !== "string") { + return jsonResponse(400, { + message: "Missing or invalid guild ID", + }); + } + + const { id: channel_id } = requestBody as Record; + + if (typeof channel_id !== "string" || !pika.validate(channel_id, "channel")) { + return jsonResponse(400, { + message: "Validation failed", + error: "Invalid or missing channel_id", + }); + } + + const client: Client = cassandra.getClient(); + + try { + const result = await client.execute( + "SELECT id, guild_id FROM channels WHERE id = ?", + [channel_id], + { prepare: true }, + ); + + if (result.rowLength === 0) { + return jsonResponse(404, { + message: "Channel not found", + error: "The specified channel does not exist", + }); + } + + const channel = result.first(); + + if (channel.guild_id !== guildId) { + return jsonResponse(403, { + message: "Forbidden", + error: "Channel does not belong to this guild", + }); + } + + await client.execute("DELETE FROM channels WHERE id = ?", [channel_id], { + prepare: true, + }); + + logger.custom( + "[CHANNEL DELETE]", + `(${channel_id})`, + `from guild ${guildId} by ${user.id}`, + "31", + ); + + return jsonResponse(200, { + message: "Channel deleted successfully", + }); + } catch (error) { + logger.error(["Failed to delete channel:", error as Error]); + return jsonResponse(500, { + message: "Internal server error", + error: "Failed to delete channel", + }); + } +} + +export { handler, routeDef }; diff --git a/src/routes/guild/[id]/channel/move.ts b/src/routes/guild/[id]/channel/move.ts new file mode 100644 index 0000000..1ea0b67 --- /dev/null +++ b/src/routes/guild/[id]/channel/move.ts @@ -0,0 +1,131 @@ +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 move a channel", + }); + } + + const { id: guildId } = request.params; + if (!guildId || typeof guildId !== "string") { + return jsonResponse(400, { + message: "Missing or invalid guild ID", + }); + } + + const { channel_id, position, category_id } = requestBody as Record< + string, + unknown + >; + + const errors: string[] = []; + + if (typeof channel_id !== "string" || !pika.validate(channel_id, "channel")) { + errors.push("Invalid or missing channel_id"); + } + + if (typeof position !== "number" || position < 0) { + errors.push("Invalid or missing position"); + } + + if (category_id !== undefined) { + if ( + typeof category_id !== "string" || + !pika.validate(category_id, "category") + ) { + errors.push("Invalid category_id"); + } + } + + if (errors.length > 0) { + return jsonResponse(400, { + message: "Validation failed", + error: errors.join("; "), + }); + } + + const client: Client = cassandra.getClient(); + + // TODO: check if user has permission to move the channel + + try { + const existing = await client.execute( + "SELECT id FROM channels WHERE id = ? AND guild_id = ?", + [channel_id, guildId], + { prepare: true }, + ); + + if (existing.rowLength === 0) { + return jsonResponse(404, { + message: "Channel not found", + error: "The specified channel does not exist in this guild", + }); + } + + if (category_id !== undefined) { + const catCheck = await client.execute( + "SELECT id FROM categories WHERE id = ? AND guild_id = ?", + [category_id, guildId], + { prepare: true }, + ); + + if (catCheck.rowLength === 0) { + return jsonResponse(400, { + message: "Validation failed", + error: "Provided category does not exist in this guild", + }); + } + } + + const now = new Date(); + + await client.execute( + "UPDATE channels SET position = ?, category_id = ?, updated_at = ? WHERE id = ? AND guild_id = ?", + [position, category_id ?? null, now, channel_id, guildId], + { prepare: true }, + ); + + logger.custom( + "[CHANNEL MOVE]", + `(${channel_id})`, + `moved to pos ${position}, cat ${category_id ?? "null"} in guild ${guildId} by ${user.id}`, + "33", + ); + + return jsonResponse(200, { + message: "Channel moved successfully", + data: { + id: channel_id, + guildId, + position, + category_id: category_id ?? null, + updated_at: now, + }, + }); + } catch (error) { + logger.error(["Failed to move channel:", error as Error]); + return jsonResponse(500, { + message: "Internal server error", + error: "Failed to move channel", + }); + } +} + +export { handler, routeDef }; diff --git a/src/server.ts b/src/server.ts index b2e522b..ed52e11 100644 --- a/src/server.ts +++ b/src/server.ts @@ -51,12 +51,21 @@ class ServerHandler { const sortedRoutes: [string, string][] = Object.entries( this.router.routes, - ).sort(([pathA]: [string, string], [pathB]: [string, string]) => - pathA.localeCompare(pathB), - ); + ).sort(([pathA], [pathB]) => pathA.localeCompare(pathB)); + + let lastCategory: string | null = null; for (const [path, filePath] of sortedRoutes) { - logger.info(`Route: ${path}, File: ${filePath}`); + const parts = path.split("/").filter(Boolean); + const category = parts.length === 0 ? "" : parts[0]; + + if (category !== lastCategory) { + if (lastCategory !== null) logger.space(); + logger.info(`› ${category}/`); + lastCategory = category; + } + + logger.info(` Route: ${path}, File: ${filePath}`); } }