From 1e6003079b8f97267ea9782e12b55306ed9eafa7 Mon Sep 17 00:00:00 2001 From: creations Date: Wed, 18 Jun 2025 18:43:47 -0400 Subject: [PATCH] move validationResult to value instead, add create, delete, list, info route for guilds --- src/environment/constants/guild/defaults.ts | 2 +- src/environment/constants/guild/index.ts | 24 +++ src/environment/constants/validation.ts | 1 + .../migrations/up/003_guilds_by_owner.sql | 10 +- src/lib/auth/session.ts | 1 - src/lib/utils/idGenerator.ts | 5 + src/lib/validation/guild/name.ts | 65 ++++++ src/lib/validation/{user => }/mailer.ts | 4 +- src/lib/validation/user/index.ts | 2 +- src/lib/validation/user/name.ts | 4 +- src/routes/guild/create.ts | 201 ++++++++++++++++++ src/routes/guild/delete.ts | 144 +++++++++++++ src/routes/guild/info[id].ts | 120 +++++++++++ src/routes/guild/list.ts | 153 +++++++++++++ src/routes/user/[id].ts | 3 +- src/routes/user/logout.ts | 11 - src/routes/user/register.ts | 10 +- src/routes/user/update/email.ts | 12 +- src/routes/user/update/info.ts | 24 +-- src/routes/user/update/password.ts | 14 +- src/routes/user/verify.ts | 3 +- src/server.ts | 69 +++--- types/lib/validation.ts | 3 +- types/server/requests/guild/base.ts | 39 ++++ types/server/requests/guild/create.ts | 15 ++ types/server/requests/guild/delete.ts | 6 + types/server/requests/guild/index.ts | 4 + types/server/requests/guild/list.ts | 9 + types/server/requests/index.ts | 1 + types/server/routes.ts | 1 + 30 files changed, 870 insertions(+), 90 deletions(-) create mode 100644 src/lib/validation/guild/name.ts rename src/lib/validation/{user => }/mailer.ts (91%) create mode 100644 src/routes/guild/delete.ts create mode 100644 src/routes/guild/info[id].ts create mode 100644 src/routes/guild/list.ts create mode 100644 types/server/requests/guild/base.ts create mode 100644 types/server/requests/guild/create.ts create mode 100644 types/server/requests/guild/delete.ts create mode 100644 types/server/requests/guild/index.ts create mode 100644 types/server/requests/guild/list.ts diff --git a/src/environment/constants/guild/defaults.ts b/src/environment/constants/guild/defaults.ts index f0d23bf..5412025 100644 --- a/src/environment/constants/guild/defaults.ts +++ b/src/environment/constants/guild/defaults.ts @@ -3,7 +3,7 @@ function createDefaultGuildData() { verification_level: 0, default_message_notifications: 0, preferred_locale: "en-US", - features: new Set(), + features: new Array(), created_at: new Date(), updated_at: new Date(), }; diff --git a/src/environment/constants/guild/index.ts b/src/environment/constants/guild/index.ts index 8156f2d..9fd4972 100644 --- a/src/environment/constants/guild/index.ts +++ b/src/environment/constants/guild/index.ts @@ -1 +1,25 @@ +const guildNameRestrictions = { + length: { + min: 2, + max: 50, + }, + regex: /^[a-zA-Z0-9\s._-]+$/, // allow letters, numbers, spaces, and common punctuation +}; + +const reservedGuildNames = [ + "admin", + "administrator", + "mod", + "moderator", + "system", + "official", + "staff", + "support", + "help", + "null", + "undefined", + "anonymous", +]; + +export { guildNameRestrictions, reservedGuildNames }; export * from "./defaults"; diff --git a/src/environment/constants/validation.ts b/src/environment/constants/validation.ts index 840b79c..69ad2c2 100644 --- a/src/environment/constants/validation.ts +++ b/src/environment/constants/validation.ts @@ -70,6 +70,7 @@ const forbiddenDisplayNamePatterns = [ /[\r\n\t]/, /\s{3,}/, /^\s|\s$/, + /@everyone|@here/i, /\p{Cf}/u, /\p{Cc}/u, ]; diff --git a/src/environment/database/migrations/up/003_guilds_by_owner.sql b/src/environment/database/migrations/up/003_guilds_by_owner.sql index 9a4162b..0880306 100644 --- a/src/environment/database/migrations/up/003_guilds_by_owner.sql +++ b/src/environment/database/migrations/up/003_guilds_by_owner.sql @@ -1,7 +1,15 @@ CREATE TABLE IF NOT EXISTS guilds_by_owner ( owner_id TEXT, + name TEXT, + guild_id TEXT, + created_at TIMESTAMP, + PRIMARY KEY (owner_id, name) +); + +CREATE TABLE IF NOT EXISTS guilds_by_owner_chronological ( + owner_id TEXT, + created_at TIMESTAMP, guild_id TEXT, name TEXT, - created_at TIMESTAMP, PRIMARY KEY (owner_id, created_at, guild_id) ) WITH CLUSTERING ORDER BY (created_at DESC); diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index b776ff6..38ab423 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -158,7 +158,6 @@ class SessionManager { }); } - // Private helper methods private getSessionKey(userId: string, token: string): string { return `session:${userId}:${token}`; } diff --git a/src/lib/utils/idGenerator.ts b/src/lib/utils/idGenerator.ts index b8d9625..7452d44 100644 --- a/src/lib/utils/idGenerator.ts +++ b/src/lib/utils/idGenerator.ts @@ -11,6 +11,11 @@ const pika = new Pika([ prefix: "sess", description: "Session ID", }, + "guild", + { + prefix: "guild", + description: "Guild ID", + }, ]); export { pika }; diff --git a/src/lib/validation/guild/name.ts b/src/lib/validation/guild/name.ts new file mode 100644 index 0000000..d32c0a5 --- /dev/null +++ b/src/lib/validation/guild/name.ts @@ -0,0 +1,65 @@ +import { + forbiddenDisplayNamePatterns, + guildNameRestrictions, + reservedGuildNames, +} from "#environment/constants"; + +import type { validationResult } from "#types/lib"; + +function isValidGuildName(rawGuildName: string): validationResult { + if (typeof rawGuildName !== "string") { + return { valid: false, error: "Guild name must be a string" }; + } + + const guildName = rawGuildName.trim().normalize("NFC"); + + if (!guildName) { + return { valid: false, error: "Guild name is required" }; + } + + if (guildName.length < guildNameRestrictions.length.min) { + return { valid: false, error: "Guild name is too short" }; + } + + if (guildName.length > guildNameRestrictions.length.max) { + return { valid: false, error: "Guild name is too long" }; + } + + if (!guildNameRestrictions.regex.test(guildName)) { + return { valid: false, error: "Guild name contains invalid characters" }; + } + + if (/^[._\s-]|[._\s-]$/.test(guildName)) { + return { + valid: false, + error: "Guild name can't start or end with special characters or spaces", + }; + } + + if (/\s{2,}/.test(guildName)) { + return { + valid: false, + error: "Guild name cannot contain multiple consecutive spaces", + }; + } + + if (reservedGuildNames.includes(guildName.toLowerCase())) { + return { + valid: false, + error: "Guild name is reserved and cannot be used", + }; + } + + for (const pattern of forbiddenDisplayNamePatterns) { + if (pattern.test(guildName)) { + return { + valid: false, + error: "Guild name contains invalid characters or patterns", + }; + } + } + + return { valid: true, value: guildName }; +} + +export { isValidGuildName }; diff --git a/src/lib/validation/user/mailer.ts b/src/lib/validation/mailer.ts similarity index 91% rename from src/lib/validation/user/mailer.ts rename to src/lib/validation/mailer.ts index ddc46e3..062d720 100644 --- a/src/lib/validation/user/mailer.ts +++ b/src/lib/validation/mailer.ts @@ -1,5 +1,5 @@ -import { isValidHostname, isValidPort } from "../general"; -import { isValidEmail } from "./email"; +import { isValidHostname, isValidPort } from "./general"; +import { isValidEmail } from "./user/email"; import type { MailerConfig } from "#types/config"; import type { simpleConfigValidation } from "#types/lib"; diff --git a/src/lib/validation/user/index.ts b/src/lib/validation/user/index.ts index 7a479e1..5f126bd 100644 --- a/src/lib/validation/user/index.ts +++ b/src/lib/validation/user/index.ts @@ -1,4 +1,4 @@ export * from "./name"; export * from "./password"; export * from "./email"; -export * from "./mailer"; +export * from "../mailer"; diff --git a/src/lib/validation/user/name.ts b/src/lib/validation/user/name.ts index 926c8ef..00aa5dc 100644 --- a/src/lib/validation/user/name.ts +++ b/src/lib/validation/user/name.ts @@ -39,7 +39,7 @@ function isValidUsername(rawUsername: string): validationResult { }; } - return { valid: true, username }; + return { valid: true, value: username }; } function isValidDisplayName(rawDisplayName: string): validationResult { @@ -84,7 +84,7 @@ function isValidDisplayName(rawDisplayName: string): validationResult { }; } - return { valid: true, name: displayName }; + return { valid: true, value: displayName }; } export { isValidUsername, isValidDisplayName }; diff --git a/src/routes/guild/create.ts b/src/routes/guild/create.ts index e69de29..b048e20 100644 --- a/src/routes/guild/create.ts +++ b/src/routes/guild/create.ts @@ -0,0 +1,201 @@ +import { echo } from "@atums/echo"; +import { + createDefaultGuildData, + errorMessages, + httpStatus, +} from "#environment/constants"; +import { cassandra } from "#lib/database"; +import { pika } from "#lib/utils"; +import { isValidGuildName } from "#lib/validation/guild/name"; + +import type { UserSession } from "#types/config"; +import type { + CreateGuildRequest, + CreateGuildResponse, + ExtendedRequest, + GuildResponse, + GuildRow, + RouteDef, +} from "#types/server"; + +const routeDef: RouteDef = { + method: "POST", + accepts: "application/json", + returns: "application/json", + needsBody: "json", + needsAuth: true, +}; + +async function handler(request: ExtendedRequest): Promise { + try { + const session = request.session as UserSession; + const { name, description } = request.requestBody as CreateGuildRequest; + + if (!name) { + const response: CreateGuildResponse = { + code: httpStatus.BAD_REQUEST, + success: false, + error: "Guild name is required", + }; + return Response.json(response, { status: httpStatus.BAD_REQUEST }); + } + + const nameValidation = isValidGuildName(name); + if (!nameValidation.valid || !nameValidation.value) { + const response: CreateGuildResponse = { + code: httpStatus.BAD_REQUEST, + success: false, + error: nameValidation.error || "Invalid guild name", + }; + return Response.json(response, { status: httpStatus.BAD_REQUEST }); + } + + const existingGuildQuery = ` + SELECT guild_id FROM guilds_by_owner + WHERE owner_id = ? AND name = ? + LIMIT 1 + `; + const existingGuildResult = (await cassandra.execute(existingGuildQuery, [ + session.id, + nameValidation.value, + ])) as { rows: Array<{ guild_id: string }> }; + + if (existingGuildResult.rows.length > 0) { + const response: CreateGuildResponse = { + code: httpStatus.CONFLICT, + success: false, + error: "You already own a guild with this name", + }; + return Response.json(response, { status: httpStatus.CONFLICT }); + } + + const guildId = pika.gen("guild"); + const defaultData = createDefaultGuildData(); + const now = new Date(); + + const insertGuildQuery = ` + INSERT INTO guilds ( + id, name, description, icon_url, banner_url, splash_url, + owner_id, verification_level, default_message_notifications, + system_channel_id, rules_channel_id, public_updates_channel_id, + features, preferred_locale, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `; + + const insertParams = [ + guildId, + nameValidation.value, + description || null, + null, // icon_url + null, // banner_url + null, // splash_url + session.id, + Number(defaultData.verification_level), + Number(defaultData.default_message_notifications), + null, // system_channel_id + null, // rules_channel_id + null, // public_updates_channel_id + defaultData.features, + defaultData.preferred_locale, + now, + now, + ]; + + await cassandra.execute(insertGuildQuery, insertParams, { prepare: true }); + + const insertOwnerQuery = ` + INSERT INTO guilds_by_owner ( + owner_id, guild_id, name, created_at + ) VALUES (?, ?, ?, ?) + `; + + await cassandra.execute( + insertOwnerQuery, + [session.id, guildId, nameValidation.value, now], + { prepare: true }, + ); + + const insertChronologicalQuery = ` + INSERT INTO guilds_by_owner_chronological ( + owner_id, created_at, guild_id, name + ) VALUES (?, ?, ?, ?) + `; + + await cassandra.execute( + insertChronologicalQuery, + [session.id, now, guildId, nameValidation.value], + { prepare: true }, + ); + + const guildQuery = ` + SELECT id, name, description, icon_url, banner_url, splash_url, + owner_id, verification_level, default_message_notifications, + system_channel_id, rules_channel_id, public_updates_channel_id, + features, preferred_locale, created_at, updated_at + FROM guilds WHERE id = ? LIMIT 1 + `; + + const guildResult = (await cassandra.execute(guildQuery, [guildId], { + prepare: true, + })) as { + rows: Array; + }; + + const guild = guildResult.rows[0]; + if (!guild) { + const response: CreateGuildResponse = { + code: httpStatus.INTERNAL_SERVER_ERROR, + success: false, + error: "Failed to fetch created guild", + }; + return Response.json(response, { + status: httpStatus.INTERNAL_SERVER_ERROR, + }); + } + + const responseGuild: GuildResponse = { + id: guild.id, + name: guild.name, + description: guild.description, + iconUrl: guild.icon_url, + bannerUrl: guild.banner_url, + splashUrl: guild.splash_url, + ownerId: guild.owner_id, + verificationLevel: guild.verification_level, + defaultMessageNotifications: guild.default_message_notifications, + systemChannelId: guild.system_channel_id, + rulesChannelId: guild.rules_channel_id, + publicUpdatesChannelId: guild.public_updates_channel_id, + features: Array.from(guild.features || []), + preferredLocale: guild.preferred_locale, + createdAt: guild.created_at.toISOString(), + updatedAt: guild.updated_at.toISOString(), + }; + + const response: CreateGuildResponse = { + code: httpStatus.CREATED, + success: true, + message: "Guild created successfully", + guild: responseGuild, + }; + + return Response.json(response, { status: httpStatus.CREATED }); + } catch (error) { + echo.error({ + message: "Error creating guild", + error, + userId: request.session?.id, + }); + + const response: CreateGuildResponse = { + code: httpStatus.INTERNAL_SERVER_ERROR, + success: false, + error: errorMessages.INTERNAL_SERVER_ERROR, + }; + return Response.json(response, { + status: httpStatus.INTERNAL_SERVER_ERROR, + }); + } +} + +export { handler, routeDef }; diff --git a/src/routes/guild/delete.ts b/src/routes/guild/delete.ts new file mode 100644 index 0000000..d1da15f --- /dev/null +++ b/src/routes/guild/delete.ts @@ -0,0 +1,144 @@ +import { echo } from "@atums/echo"; +import { errorMessages, httpStatus } from "#environment/constants"; +import { cassandra } from "#lib/database"; + +import type { UserSession } from "#types/config"; +import type { + BaseResponse, + DeleteGuildRequest, + ExtendedRequest, + RouteDef, +} from "#types/server"; + +const routeDef: RouteDef = { + method: "DELETE", + accepts: "application/json", + returns: "application/json", + needsBody: "json", + needsAuth: true, +}; + +async function handler(request: ExtendedRequest): Promise { + try { + const session = request.session as UserSession; + const { id, name } = request.requestBody as DeleteGuildRequest; + + if (!id || !name) { + const response: BaseResponse = { + code: httpStatus.BAD_REQUEST, + success: false, + error: "Both guild ID and name are required for confirmation", + }; + return Response.json(response, { status: httpStatus.BAD_REQUEST }); + } + + if (!id.startsWith("guild_")) { + const response: BaseResponse = { + code: httpStatus.BAD_REQUEST, + success: false, + error: "Invalid guild ID format", + }; + return Response.json(response, { status: httpStatus.BAD_REQUEST }); + } + + const guildQuery = ` + SELECT id, name, owner_id, created_at + FROM guilds WHERE id = ? LIMIT 1 + `; + + const guildResult = (await cassandra.execute(guildQuery, [id], { + prepare: true, + })) as { + rows: Array<{ + id: string; + name: string; + owner_id: string; + created_at: Date; + }>; + }; + + if (!guildResult?.rows || guildResult.rows.length === 0) { + const response: BaseResponse = { + code: httpStatus.NOT_FOUND, + success: false, + error: "Guild not found", + }; + return Response.json(response, { status: httpStatus.NOT_FOUND }); + } + + const guild = guildResult.rows[0]; + if (!guild) { + const response: BaseResponse = { + code: httpStatus.NOT_FOUND, + success: false, + error: "Guild not found", + }; + return Response.json(response, { status: httpStatus.NOT_FOUND }); + } + + if (guild.owner_id !== session.id) { + const response: BaseResponse = { + code: httpStatus.FORBIDDEN, + success: false, + error: "You can only delete guilds that you own", + }; + return Response.json(response, { status: httpStatus.FORBIDDEN }); + } + + if (guild.name !== name) { + const response: BaseResponse = { + code: httpStatus.BAD_REQUEST, + success: false, + error: `Guild name confirmation failed. Expected "${guild.name}" but got "${name}". Please ensure the name matches exactly.`, + }; + return Response.json(response, { status: httpStatus.BAD_REQUEST }); + } + + const deleteGuildQuery = "DELETE FROM guilds WHERE id = ?"; + await cassandra.execute(deleteGuildQuery, [id], { prepare: true }); + + const deleteOwnerQuery = + "DELETE FROM guilds_by_owner WHERE owner_id = ? AND name = ?"; + await cassandra.execute(deleteOwnerQuery, [session.id, guild.name], { + prepare: true, + }); + + const deleteChronologicalQuery = ` + DELETE FROM guilds_by_owner_chronological + WHERE owner_id = ? AND created_at = ? AND guild_id = ? + `; + await cassandra.execute( + deleteChronologicalQuery, + [session.id, guild.created_at, id], + { prepare: true }, + ); + + // TODO: delete related data like channels, members, messages when those features are implemented + + const response: BaseResponse = { + code: httpStatus.OK, + success: true, + message: `Guild "${guild.name}" has been successfully deleted`, + }; + + return Response.json(response, { status: httpStatus.OK }); + } catch (error) { + echo.error({ + message: "Error deleting guild", + error, + userId: request.session?.id, + guildId: (request.requestBody as DeleteGuildRequest)?.id, + }); + + const response: BaseResponse = { + code: httpStatus.INTERNAL_SERVER_ERROR, + success: false, + error: errorMessages.INTERNAL_SERVER_ERROR, + }; + return Response.json(response, { + status: httpStatus.INTERNAL_SERVER_ERROR, + }); + } +} + +export { handler, routeDef }; diff --git a/src/routes/guild/info[id].ts b/src/routes/guild/info[id].ts new file mode 100644 index 0000000..f8ad0bc --- /dev/null +++ b/src/routes/guild/info[id].ts @@ -0,0 +1,120 @@ +import { echo } from "@atums/echo"; +import { errorMessages, httpStatus } from "#environment/constants"; +import { cassandra } from "#lib/database"; + +// import type { UserSession } from "#types/config"; +import type { + BaseResponse, + ExtendedRequest, + GuildResponse, + GuildRow, + RouteDef, +} from "#types/server"; + +interface GuildGetResponse extends BaseResponse { + guild?: GuildResponse; +} + +const routeDef: RouteDef = { + method: "GET", + accepts: "*/*", + returns: "application/json", +}; + +async function handler(request: ExtendedRequest): Promise { + try { + // const session = request.session as UserSession; + const { id: guildId } = request.params; + + if (!guildId) { + const response: GuildGetResponse = { + code: httpStatus.BAD_REQUEST, + success: false, + error: "Guild ID is required", + }; + return Response.json(response, { status: httpStatus.BAD_REQUEST }); + } + + const guildQuery = ` + SELECT id, name, description, icon_url, banner_url, splash_url, + owner_id, verification_level, default_message_notifications, + system_channel_id, rules_channel_id, public_updates_channel_id, + features, preferred_locale, created_at, updated_at + FROM guilds WHERE id = ? LIMIT 1 + `; + + const guildResult = (await cassandra.execute(guildQuery, [guildId], { + prepare: true, + })) as { + rows: Array; + }; + + if (!guildResult?.rows || guildResult.rows.length === 0) { + const response: GuildGetResponse = { + code: httpStatus.NOT_FOUND, + success: false, + error: "Guild not found", + }; + return Response.json(response, { status: httpStatus.NOT_FOUND }); + } + + const guild = guildResult.rows[0]; + if (!guild) { + const response: GuildGetResponse = { + code: httpStatus.NOT_FOUND, + success: false, + error: "Guild not found", + }; + return Response.json(response, { status: httpStatus.NOT_FOUND }); + } + + // TODO: In the future, check if user has permission to view this guild + // const isOwner = session?.id === guild.owner_id; + + const responseGuild: GuildResponse = { + id: guild.id, + name: guild.name, + description: guild.description, + iconUrl: guild.icon_url, + bannerUrl: guild.banner_url, + splashUrl: guild.splash_url, + ownerId: guild.owner_id, + verificationLevel: guild.verification_level, + defaultMessageNotifications: guild.default_message_notifications, + systemChannelId: guild.system_channel_id, + rulesChannelId: guild.rules_channel_id, + publicUpdatesChannelId: guild.public_updates_channel_id, + features: Array.from(guild.features || []), + preferredLocale: guild.preferred_locale, + createdAt: guild.created_at.toISOString(), + updatedAt: guild.updated_at.toISOString(), + }; + + const response: GuildGetResponse = { + code: httpStatus.OK, + success: true, + message: "Guild retrieved successfully", + guild: responseGuild, + }; + + return Response.json(response, { status: httpStatus.OK }); + } catch (error) { + echo.error({ + message: "Error fetching guild", + error, + guildId: request.params.id, + userId: request.session?.id, + }); + + const response: GuildGetResponse = { + code: httpStatus.INTERNAL_SERVER_ERROR, + success: false, + error: errorMessages.INTERNAL_SERVER_ERROR, + }; + return Response.json(response, { + status: httpStatus.INTERNAL_SERVER_ERROR, + }); + } +} + +export { handler, routeDef }; diff --git a/src/routes/guild/list.ts b/src/routes/guild/list.ts new file mode 100644 index 0000000..49332ed --- /dev/null +++ b/src/routes/guild/list.ts @@ -0,0 +1,153 @@ +import { echo } from "@atums/echo"; +import { errorMessages, httpStatus } from "#environment/constants"; +import { cassandra } from "#lib/database"; + +import type { UserSession } from "#types/config"; +import type { + ExtendedRequest, + GuildListResponse, + GuildResponse, + GuildRow, + RouteDef, +} from "#types/server"; + +const routeDef: RouteDef = { + method: "GET", + accepts: "*/*", + returns: "application/json", + needsAuth: true, +}; + +async function handler(request: ExtendedRequest): Promise { + try { + const session = request.session as UserSession; + const { limit = "10", offset = "0" } = request.query; + + const limitNum = Math.min( + Math.max(Number.parseInt(limit, 10) || 10, 1), + 50, + ); + const offsetNum = Math.max(Number.parseInt(offset, 10) || 0, 0); + + const guildIdsQuery = ` + SELECT guild_id, name, created_at + FROM guilds_by_owner_chronological + WHERE owner_id = ? + LIMIT ? + `; + + const guildIdsResult = (await cassandra.execute( + guildIdsQuery, + [session.id, limitNum + offsetNum], + { prepare: true }, + )) as { + rows: Array<{ + guild_id: string; + name: string; + created_at: Date; + }>; + }; + + if (!guildIdsResult?.rows || guildIdsResult.rows.length === 0) { + const response: GuildListResponse = { + code: httpStatus.OK, + success: true, + message: "No guilds found", + guilds: [], + count: 0, + }; + return Response.json(response, { status: httpStatus.OK }); + } + + const relevantGuildIds = guildIdsResult.rows + .slice(offsetNum, offsetNum + limitNum) + .map((row) => row.guild_id); + + if (relevantGuildIds.length === 0) { + const response: GuildListResponse = { + code: httpStatus.OK, + success: true, + message: "No more guilds found", + guilds: [], + count: 0, + }; + return Response.json(response, { status: httpStatus.OK }); + } + + const guildDetailsQuery = ` + SELECT id, name, description, icon_url, banner_url, splash_url, + owner_id, verification_level, default_message_notifications, + system_channel_id, rules_channel_id, public_updates_channel_id, + features, preferred_locale, created_at, updated_at + FROM guilds WHERE id IN (${relevantGuildIds.map(() => "?").join(", ")}) + `; + + const guildsResult = (await cassandra.execute( + guildDetailsQuery, + relevantGuildIds, + { + prepare: true, + }, + )) as { + rows: Array; + }; + + const guildsMap = new Map(); + + for (const guild of guildsResult.rows) { + guildsMap.set(guild.id, { + id: guild.id, + name: guild.name, + description: guild.description, + iconUrl: guild.icon_url, + bannerUrl: guild.banner_url, + splashUrl: guild.splash_url, + ownerId: guild.owner_id, + verificationLevel: guild.verification_level, + defaultMessageNotifications: guild.default_message_notifications, + systemChannelId: guild.system_channel_id, + rulesChannelId: guild.rules_channel_id, + publicUpdatesChannelId: guild.public_updates_channel_id, + features: Array.from(guild.features || []), + preferredLocale: guild.preferred_locale, + createdAt: guild.created_at.toISOString(), + updatedAt: guild.updated_at.toISOString(), + }); + } + + const orderedGuilds: GuildResponse[] = []; + for (const guildId of relevantGuildIds) { + const guild = guildsMap.get(guildId); + if (guild) { + orderedGuilds.push(guild); + } + } + + const response: GuildListResponse = { + code: httpStatus.OK, + success: true, + message: `Found ${orderedGuilds.length} guild(s)`, + guilds: orderedGuilds, + count: orderedGuilds.length, + }; + + return Response.json(response, { status: httpStatus.OK }); + } catch (error) { + echo.error({ + message: "Error fetching user guilds", + error, + userId: request.session?.id, + }); + + const response: GuildListResponse = { + code: httpStatus.INTERNAL_SERVER_ERROR, + success: false, + error: errorMessages.INTERNAL_SERVER_ERROR, + }; + return Response.json(response, { + status: httpStatus.INTERNAL_SERVER_ERROR, + }); + } +} + +export { handler, routeDef }; diff --git a/src/routes/user/[id].ts b/src/routes/user/[id].ts index ee7bdf3..e1a1197 100644 --- a/src/routes/user/[id].ts +++ b/src/routes/user/[id].ts @@ -2,6 +2,7 @@ import { echo } from "@atums/echo"; import { errorMessages, httpStatus } from "#environment/constants"; import { cassandra } from "#lib/database"; +import type { UserSession } from "#types/config"; import type { ExtendedRequest, RouteDef, @@ -19,7 +20,7 @@ const routeDef: RouteDef = { async function handler(request: ExtendedRequest): Promise { try { const { id: identifier } = request.params; - const { session } = request; + const session = request.session as UserSession; let userQuery: string; let queryParams: string[]; diff --git a/src/routes/user/logout.ts b/src/routes/user/logout.ts index 08d16e7..9395f65 100644 --- a/src/routes/user/logout.ts +++ b/src/routes/user/logout.ts @@ -16,17 +16,6 @@ const routeDef: RouteDef = { async function handler(request: ExtendedRequest): Promise { try { - const { session } = request; - - if (!session) { - const response: LogoutResponse = { - code: httpStatus.UNAUTHORIZED, - success: false, - error: errorMessages.NOT_AUTHENTICATED, - }; - return Response.json(response, { status: httpStatus.UNAUTHORIZED }); - } - await sessionManager.invalidateSession(request); const clearCookie = cookieService.clearCookie(); diff --git a/src/routes/user/register.ts b/src/routes/user/register.ts index 349fadf..b3cd15a 100644 --- a/src/routes/user/register.ts +++ b/src/routes/user/register.ts @@ -46,7 +46,7 @@ async function handler(request: ExtendedRequest): Promise { } const usernameValidation = isValidUsername(username); - if (!usernameValidation.valid || !usernameValidation.username) { + if (!usernameValidation.valid || !usernameValidation.value) { const response: RegisterResponse = { code: 400, success: false, @@ -66,7 +66,7 @@ async function handler(request: ExtendedRequest): Promise { }; return Response.json(response, { status: 400 }); } - validatedDisplayName = displayNameValidation.name || null; + validatedDisplayName = displayNameValidation.value || null; } const emailValidation = isValidEmail(email); @@ -93,7 +93,7 @@ async function handler(request: ExtendedRequest): Promise { "SELECT id FROM users WHERE username = ? LIMIT 1"; const existingUsernameResult = (await cassandra.execute( existingUsernameQuery, - [usernameValidation.username], + [usernameValidation.value], )) as { rows: Array<{ id: string }> }; if (existingUsernameResult.rows.length > 0) { @@ -134,7 +134,7 @@ async function handler(request: ExtendedRequest): Promise { await cassandra.execute(insertUserQuery, [ userId, - usernameValidation.username, + usernameValidation.value, validatedDisplayName, email.trim().toLowerCase(), hashedPassword, @@ -145,7 +145,7 @@ async function handler(request: ExtendedRequest): Promise { const responseUser: RegisterResponse["user"] = { id: userId, - username: usernameValidation.username, + username: usernameValidation.value, displayName: validatedDisplayName, email: email.trim().toLowerCase(), isVerified: false, diff --git a/src/routes/user/update/email.ts b/src/routes/user/update/email.ts index 2d7d13d..ead843f 100644 --- a/src/routes/user/update/email.ts +++ b/src/routes/user/update/email.ts @@ -30,20 +30,12 @@ const routeDef: RouteDef = { accepts: ["application/json", "*/*"], returns: "application/json", needsBody: "json", + needsAuth: true, }; async function handler(request: ExtendedRequest): Promise { try { - const { session } = request; - - if (!session) { - const response: EmailChangeResponse = { - code: httpStatus.UNAUTHORIZED, - success: false, - error: errorMessages.NOT_AUTHENTICATED, - }; - return Response.json(response, { status: httpStatus.UNAUTHORIZED }); - } + const session = request.session as UserSession; if (request.method === "GET") { return await handleEmailVerification(request, session); diff --git a/src/routes/user/update/info.ts b/src/routes/user/update/info.ts index a9d86e5..9b8aafb 100644 --- a/src/routes/user/update/info.ts +++ b/src/routes/user/update/info.ts @@ -8,6 +8,7 @@ import { sessionManager } from "#lib/auth"; import { cassandra } from "#lib/database"; import { isValidDisplayName, isValidUsername } from "#lib/validation"; +import type { UserSession } from "#types/config"; import type { ExtendedRequest, RouteDef, @@ -22,21 +23,12 @@ const routeDef: RouteDef = { accepts: "application/json", returns: "application/json", needsBody: "json", + needsAuth: true, }; async function handler(request: ExtendedRequest): Promise { try { - const { session } = request; - - if (!session) { - const response: UpdateInfoResponse = { - code: httpStatus.UNAUTHORIZED, - success: false, - error: errorMessages.NOT_AUTHENTICATED, - }; - return Response.json(response, { status: httpStatus.UNAUTHORIZED }); - } - + const session = request.session as UserSession; const { username, displayName } = request.requestBody as UpdateInfoRequest; if (username === undefined && displayName === undefined) { @@ -85,7 +77,7 @@ async function handler(request: ExtendedRequest): Promise { if (username !== undefined) { const usernameValidation = isValidUsername(username); - if (!usernameValidation.valid || !usernameValidation.username) { + if (!usernameValidation.valid || !usernameValidation.value) { const response: UpdateInfoResponse = { code: httpStatus.BAD_REQUEST, success: false, @@ -94,12 +86,12 @@ async function handler(request: ExtendedRequest): Promise { return Response.json(response, { status: httpStatus.BAD_REQUEST }); } - if (usernameValidation.username !== currentUser.username) { + if (usernameValidation.value !== currentUser.username) { const existingUsernameQuery = "SELECT id FROM users WHERE username = ? LIMIT 1"; const existingUsernameResult = (await cassandra.execute( existingUsernameQuery, - [usernameValidation.username], + [usernameValidation.value], )) as { rows: Array<{ id: string }> }; if ( @@ -114,7 +106,7 @@ async function handler(request: ExtendedRequest): Promise { return Response.json(response, { status: httpStatus.CONFLICT }); } - updates.username = usernameValidation.username; + updates.username = usernameValidation.value; } } @@ -131,7 +123,7 @@ async function handler(request: ExtendedRequest): Promise { }; return Response.json(response, { status: httpStatus.BAD_REQUEST }); } - updates.displayName = displayNameValidation.name || null; + updates.displayName = displayNameValidation.value || null; } } diff --git a/src/routes/user/update/password.ts b/src/routes/user/update/password.ts index 0ff7298..562bad2 100644 --- a/src/routes/user/update/password.ts +++ b/src/routes/user/update/password.ts @@ -9,6 +9,7 @@ import { sessionManager } from "#lib/auth"; import { cassandra } from "#lib/database"; import { isValidPassword } from "#lib/validation"; +import type { UserSession } from "#types/config"; import type { ExtendedRequest, RouteDef, @@ -22,21 +23,12 @@ const routeDef: RouteDef = { accepts: "application/json", returns: "application/json", needsBody: "json", + needsAuth: true, }; async function handler(request: ExtendedRequest): Promise { try { - const { session } = request; - - if (!session) { - const response: UpdatePasswordResponse = { - code: httpStatus.UNAUTHORIZED, - success: false, - error: errorMessages.NOT_AUTHENTICATED, - }; - return Response.json(response, { status: httpStatus.UNAUTHORIZED }); - } - + const session = request.session as UserSession; const { currentPassword, newPassword, logoutAllSessions } = request.requestBody as UpdatePasswordRequest; diff --git a/src/routes/user/verify.ts b/src/routes/user/verify.ts index 997e52a..5adc707 100644 --- a/src/routes/user/verify.ts +++ b/src/routes/user/verify.ts @@ -9,6 +9,7 @@ import { import { sessionManager } from "#lib/auth"; import { cassandra } from "#lib/database"; +import type { UserSession } from "#types/config"; import type { ExtendedRequest, RouteDef, @@ -183,7 +184,7 @@ async function handler(request: ExtendedRequest): Promise { }); } - const { session } = request; + const session = request.session as UserSession; let sessionCookie: string | undefined; if (session && session.id === user.id) { diff --git a/src/server.ts b/src/server.ts index 084bdb0..9e7ac30 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,18 +1,21 @@ import { resolve } from "node:path"; import { type Echo, echo } from "@atums/echo"; -import { environment } from "#environment/config"; -import { reqLoggerIgnores } from "#environment/constants"; -import { noFileLog } from "#index"; -import { webSocketHandler } from "#websocket"; - import { type BunFile, FileSystemRouter, type MatchedRoute, type Server, } from "bun"; - +import { environment } from "#environment/config"; +import { + errorMessages, + httpStatus, + reqLoggerIgnores, +} from "#environment/constants"; +import { noFileLog } from "#index"; import { sessionManager } from "#lib/auth"; +import { webSocketHandler } from "#websocket"; + import type { ExtendedRequest, RouteModule } from "#types/server"; class ServerHandler { @@ -84,14 +87,18 @@ class ServerHandler { }); } else { echo.warn(`File not found: ${filePath}`); - response = new Response("Not Found", { status: 404 }); + response = new Response(errorMessages.NOT_FOUND, { + status: httpStatus.NOT_FOUND, + }); } } catch (error) { echo.error({ message: `Error serving static file: ${pathname}`, error: error as Error, }); - response = new Response("Internal Server Error", { status: 500 }); + response = new Response(errorMessages.INTERNAL_SERVER_ERROR, { + status: httpStatus.INTERNAL_SERVER_ERROR, + }); } this.logRequest(request, response, ip); @@ -146,7 +153,7 @@ class ServerHandler { const customPath = resolve(baseDir, pathname.slice(1)); if (!customPath.startsWith(baseDir)) { - response = new Response("Forbidden", { status: 403 }); + response = new Response("Forbidden", { status: httpStatus.FORBIDDEN }); this.logRequest(extendedRequest, response, ip); return response; } @@ -247,14 +254,15 @@ class ServerHandler { response = Response.json( { success: false, - code: 405, - error: `Method ${request.method} Not Allowed, expected ${ + code: httpStatus.METHOD_NOT_ALLOWED, + error: errorMessages.METHOD_NOT_ALLOWED, + details: `Method ${request.method} Not Allowed, expected ${ Array.isArray(routeModule.routeDef.method) ? routeModule.routeDef.method.join(", ") : routeModule.routeDef.method }`, }, - { status: 405 }, + { status: httpStatus.METHOD_NOT_ALLOWED }, ); } else { const expectedContentType: string | string[] | null = @@ -276,14 +284,14 @@ class ServerHandler { response = Response.json( { success: false, - code: 406, + code: httpStatus.NOT_ACCEPTABLE, error: `Content-Type ${actualContentType} Not Acceptable, expected ${ Array.isArray(expectedContentType) ? expectedContentType.join(", ") : expectedContentType }`, }, - { status: 406 }, + { status: httpStatus.NOT_ACCEPTABLE }, ); } else { extendedRequest.params = params; @@ -291,12 +299,23 @@ class ServerHandler { extendedRequest.requestBody = requestBody; extendedRequest.session = await sessionManager.getSession(request); - response = await routeModule.handler(extendedRequest, server); - if (routeModule.routeDef.returns !== "*/*") { - response.headers.set( - "Content-Type", - routeModule.routeDef.returns, + if (routeModule.routeDef.needsAuth && !extendedRequest.session) { + response = Response.json( + { + code: httpStatus.UNAUTHORIZED, + success: false, + error: errorMessages.NOT_AUTHENTICATED, + }, + { status: httpStatus.UNAUTHORIZED }, ); + } else { + response = await routeModule.handler(extendedRequest, server); + if (routeModule.routeDef.returns !== "*/*") { + response.headers.set( + "Content-Type", + routeModule.routeDef.returns, + ); + } } } } @@ -309,20 +328,20 @@ class ServerHandler { response = Response.json( { success: false, - code: 500, - error: "Internal Server Error", + code: httpStatus.INTERNAL_SERVER_ERROR, + error: errorMessages.INTERNAL_SERVER_ERROR, }, - { status: 500 }, + { status: httpStatus.INTERNAL_SERVER_ERROR }, ); } } else { response = Response.json( { success: false, - code: 404, - error: "Not Found", + code: httpStatus.NOT_FOUND, + error: errorMessages.NOT_FOUND, }, - { status: 404 }, + { status: httpStatus.NOT_FOUND }, ); } diff --git a/types/lib/validation.ts b/types/lib/validation.ts index a5bb5d8..1d9651c 100644 --- a/types/lib/validation.ts +++ b/types/lib/validation.ts @@ -6,8 +6,7 @@ type genericValidation = { type validationResult = { valid: boolean; error?: string; - username?: string; - name?: string; + value?: string; }; interface UrlValidationOptions { diff --git a/types/server/requests/guild/base.ts b/types/server/requests/guild/base.ts new file mode 100644 index 0000000..c833abe --- /dev/null +++ b/types/server/requests/guild/base.ts @@ -0,0 +1,39 @@ +interface GuildResponse { + id: string; + name: string; + description: string | null; + iconUrl: string | null; + bannerUrl: string | null; + splashUrl: string | null; + ownerId: string; + verificationLevel: number; + defaultMessageNotifications: number; + systemChannelId: string | null; + rulesChannelId: string | null; + publicUpdatesChannelId: string | null; + features: string[]; + preferredLocale: string; + createdAt: string; + updatedAt: string; +} + +interface GuildRow { + id: string; + name: string; + description: string | null; + icon_url: string | null; + banner_url: string | null; + splash_url: string | null; + owner_id: string; + verification_level: number; + default_message_notifications: number; + system_channel_id: string | null; + rules_channel_id: string | null; + public_updates_channel_id: string | null; + features: Set; + preferred_locale: string; + created_at: Date; + updated_at: Date; +} + +export type { GuildResponse, GuildRow }; diff --git a/types/server/requests/guild/create.ts b/types/server/requests/guild/create.ts new file mode 100644 index 0000000..c3d5753 --- /dev/null +++ b/types/server/requests/guild/create.ts @@ -0,0 +1,15 @@ +import type { BaseResponse } from "../base"; +import type { GuildResponse } from "./base"; + +interface CreateGuildRequest { + name: string; + description?: string; + icon_url?: string; + banner_url?: string; +} + +interface CreateGuildResponse extends BaseResponse { + guild?: GuildResponse; +} + +export type { CreateGuildRequest, CreateGuildResponse }; diff --git a/types/server/requests/guild/delete.ts b/types/server/requests/guild/delete.ts new file mode 100644 index 0000000..d8c197e --- /dev/null +++ b/types/server/requests/guild/delete.ts @@ -0,0 +1,6 @@ +interface DeleteGuildRequest { + id: string; + name: string; +} + +export type { DeleteGuildRequest }; diff --git a/types/server/requests/guild/index.ts b/types/server/requests/guild/index.ts new file mode 100644 index 0000000..94e3075 --- /dev/null +++ b/types/server/requests/guild/index.ts @@ -0,0 +1,4 @@ +export * from "./base"; +export * from "./create"; +export * from "./delete"; +export * from "./list"; diff --git a/types/server/requests/guild/list.ts b/types/server/requests/guild/list.ts new file mode 100644 index 0000000..4710083 --- /dev/null +++ b/types/server/requests/guild/list.ts @@ -0,0 +1,9 @@ +import type { BaseResponse } from "../base"; +import type { GuildResponse } from "./base"; + +interface GuildListResponse extends BaseResponse { + guilds?: GuildResponse[]; + count?: number; +} + +export type { GuildListResponse }; diff --git a/types/server/requests/index.ts b/types/server/requests/index.ts index 89a792f..29a43c9 100644 --- a/types/server/requests/index.ts +++ b/types/server/requests/index.ts @@ -1,3 +1,4 @@ export * from "./user"; export * from "./health"; export * from "./base"; +export * from "./guild"; diff --git a/types/server/routes.ts b/types/server/routes.ts index 714b3db..4d82c27 100644 --- a/types/server/routes.ts +++ b/types/server/routes.ts @@ -13,6 +13,7 @@ type RouteDef = { | "raw" | "buffer" | "blob"; + needsAuth?: boolean; }; type RouteModule = {