move validationResult to value instead, add create, delete, list, info route for guilds
All checks were successful
Code quality checks / biome (push) Successful in 11s

This commit is contained in:
creations 2025-06-18 18:43:47 -04:00
parent ca0410f7fb
commit 1e6003079b
Signed by: creations
GPG key ID: 8F553AA4320FC711
30 changed files with 870 additions and 90 deletions

View file

@ -3,7 +3,7 @@ function createDefaultGuildData() {
verification_level: 0, verification_level: 0,
default_message_notifications: 0, default_message_notifications: 0,
preferred_locale: "en-US", preferred_locale: "en-US",
features: new Set(), features: new Array<string>(),
created_at: new Date(), created_at: new Date(),
updated_at: new Date(), updated_at: new Date(),
}; };

View file

@ -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"; export * from "./defaults";

View file

@ -70,6 +70,7 @@ const forbiddenDisplayNamePatterns = [
/[\r\n\t]/, /[\r\n\t]/,
/\s{3,}/, /\s{3,}/,
/^\s|\s$/, /^\s|\s$/,
/@everyone|@here/i,
/\p{Cf}/u, /\p{Cf}/u,
/\p{Cc}/u, /\p{Cc}/u,
]; ];

View file

@ -1,7 +1,15 @@
CREATE TABLE IF NOT EXISTS guilds_by_owner ( CREATE TABLE IF NOT EXISTS guilds_by_owner (
owner_id TEXT, 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, guild_id TEXT,
name TEXT, name TEXT,
created_at TIMESTAMP,
PRIMARY KEY (owner_id, created_at, guild_id) PRIMARY KEY (owner_id, created_at, guild_id)
) WITH CLUSTERING ORDER BY (created_at DESC); ) WITH CLUSTERING ORDER BY (created_at DESC);

View file

@ -158,7 +158,6 @@ class SessionManager {
}); });
} }
// Private helper methods
private getSessionKey(userId: string, token: string): string { private getSessionKey(userId: string, token: string): string {
return `session:${userId}:${token}`; return `session:${userId}:${token}`;
} }

View file

@ -11,6 +11,11 @@ const pika = new Pika([
prefix: "sess", prefix: "sess",
description: "Session ID", description: "Session ID",
}, },
"guild",
{
prefix: "guild",
description: "Guild ID",
},
]); ]);
export { pika }; export { pika };

View file

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

View file

@ -1,5 +1,5 @@
import { isValidHostname, isValidPort } from "../general"; import { isValidHostname, isValidPort } from "./general";
import { isValidEmail } from "./email"; import { isValidEmail } from "./user/email";
import type { MailerConfig } from "#types/config"; import type { MailerConfig } from "#types/config";
import type { simpleConfigValidation } from "#types/lib"; import type { simpleConfigValidation } from "#types/lib";

View file

@ -1,4 +1,4 @@
export * from "./name"; export * from "./name";
export * from "./password"; export * from "./password";
export * from "./email"; export * from "./email";
export * from "./mailer"; export * from "../mailer";

View file

@ -39,7 +39,7 @@ function isValidUsername(rawUsername: string): validationResult {
}; };
} }
return { valid: true, username }; return { valid: true, value: username };
} }
function isValidDisplayName(rawDisplayName: string): validationResult { 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 }; export { isValidUsername, isValidDisplayName };

View file

@ -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<Response> {
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<GuildRow>;
};
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 };

144
src/routes/guild/delete.ts Normal file
View file

@ -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<Response> {
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 };

View file

@ -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<Response> {
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<GuildRow>;
};
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 };

153
src/routes/guild/list.ts Normal file
View file

@ -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<Response> {
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<GuildRow>;
};
const guildsMap = new Map<string, GuildResponse>();
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 };

View file

@ -2,6 +2,7 @@ import { echo } from "@atums/echo";
import { errorMessages, httpStatus } from "#environment/constants"; import { errorMessages, httpStatus } from "#environment/constants";
import { cassandra } from "#lib/database"; import { cassandra } from "#lib/database";
import type { UserSession } from "#types/config";
import type { import type {
ExtendedRequest, ExtendedRequest,
RouteDef, RouteDef,
@ -19,7 +20,7 @@ const routeDef: RouteDef = {
async function handler(request: ExtendedRequest): Promise<Response> { async function handler(request: ExtendedRequest): Promise<Response> {
try { try {
const { id: identifier } = request.params; const { id: identifier } = request.params;
const { session } = request; const session = request.session as UserSession;
let userQuery: string; let userQuery: string;
let queryParams: string[]; let queryParams: string[];

View file

@ -16,17 +16,6 @@ const routeDef: RouteDef = {
async function handler(request: ExtendedRequest): Promise<Response> { async function handler(request: ExtendedRequest): Promise<Response> {
try { 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); await sessionManager.invalidateSession(request);
const clearCookie = cookieService.clearCookie(); const clearCookie = cookieService.clearCookie();

View file

@ -46,7 +46,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
} }
const usernameValidation = isValidUsername(username); const usernameValidation = isValidUsername(username);
if (!usernameValidation.valid || !usernameValidation.username) { if (!usernameValidation.valid || !usernameValidation.value) {
const response: RegisterResponse = { const response: RegisterResponse = {
code: 400, code: 400,
success: false, success: false,
@ -66,7 +66,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
}; };
return Response.json(response, { status: 400 }); return Response.json(response, { status: 400 });
} }
validatedDisplayName = displayNameValidation.name || null; validatedDisplayName = displayNameValidation.value || null;
} }
const emailValidation = isValidEmail(email); const emailValidation = isValidEmail(email);
@ -93,7 +93,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
"SELECT id FROM users WHERE username = ? LIMIT 1"; "SELECT id FROM users WHERE username = ? LIMIT 1";
const existingUsernameResult = (await cassandra.execute( const existingUsernameResult = (await cassandra.execute(
existingUsernameQuery, existingUsernameQuery,
[usernameValidation.username], [usernameValidation.value],
)) as { rows: Array<{ id: string }> }; )) as { rows: Array<{ id: string }> };
if (existingUsernameResult.rows.length > 0) { if (existingUsernameResult.rows.length > 0) {
@ -134,7 +134,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
await cassandra.execute(insertUserQuery, [ await cassandra.execute(insertUserQuery, [
userId, userId,
usernameValidation.username, usernameValidation.value,
validatedDisplayName, validatedDisplayName,
email.trim().toLowerCase(), email.trim().toLowerCase(),
hashedPassword, hashedPassword,
@ -145,7 +145,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
const responseUser: RegisterResponse["user"] = { const responseUser: RegisterResponse["user"] = {
id: userId, id: userId,
username: usernameValidation.username, username: usernameValidation.value,
displayName: validatedDisplayName, displayName: validatedDisplayName,
email: email.trim().toLowerCase(), email: email.trim().toLowerCase(),
isVerified: false, isVerified: false,

View file

@ -30,20 +30,12 @@ const routeDef: RouteDef = {
accepts: ["application/json", "*/*"], accepts: ["application/json", "*/*"],
returns: "application/json", returns: "application/json",
needsBody: "json", needsBody: "json",
needsAuth: true,
}; };
async function handler(request: ExtendedRequest): Promise<Response> { async function handler(request: ExtendedRequest): Promise<Response> {
try { try {
const { session } = request; const session = request.session as UserSession;
if (!session) {
const response: EmailChangeResponse = {
code: httpStatus.UNAUTHORIZED,
success: false,
error: errorMessages.NOT_AUTHENTICATED,
};
return Response.json(response, { status: httpStatus.UNAUTHORIZED });
}
if (request.method === "GET") { if (request.method === "GET") {
return await handleEmailVerification(request, session); return await handleEmailVerification(request, session);

View file

@ -8,6 +8,7 @@ import { sessionManager } from "#lib/auth";
import { cassandra } from "#lib/database"; import { cassandra } from "#lib/database";
import { isValidDisplayName, isValidUsername } from "#lib/validation"; import { isValidDisplayName, isValidUsername } from "#lib/validation";
import type { UserSession } from "#types/config";
import type { import type {
ExtendedRequest, ExtendedRequest,
RouteDef, RouteDef,
@ -22,21 +23,12 @@ const routeDef: RouteDef = {
accepts: "application/json", accepts: "application/json",
returns: "application/json", returns: "application/json",
needsBody: "json", needsBody: "json",
needsAuth: true,
}; };
async function handler(request: ExtendedRequest): Promise<Response> { async function handler(request: ExtendedRequest): Promise<Response> {
try { try {
const { session } = request; const session = request.session as UserSession;
if (!session) {
const response: UpdateInfoResponse = {
code: httpStatus.UNAUTHORIZED,
success: false,
error: errorMessages.NOT_AUTHENTICATED,
};
return Response.json(response, { status: httpStatus.UNAUTHORIZED });
}
const { username, displayName } = request.requestBody as UpdateInfoRequest; const { username, displayName } = request.requestBody as UpdateInfoRequest;
if (username === undefined && displayName === undefined) { if (username === undefined && displayName === undefined) {
@ -85,7 +77,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
if (username !== undefined) { if (username !== undefined) {
const usernameValidation = isValidUsername(username); const usernameValidation = isValidUsername(username);
if (!usernameValidation.valid || !usernameValidation.username) { if (!usernameValidation.valid || !usernameValidation.value) {
const response: UpdateInfoResponse = { const response: UpdateInfoResponse = {
code: httpStatus.BAD_REQUEST, code: httpStatus.BAD_REQUEST,
success: false, success: false,
@ -94,12 +86,12 @@ async function handler(request: ExtendedRequest): Promise<Response> {
return Response.json(response, { status: httpStatus.BAD_REQUEST }); return Response.json(response, { status: httpStatus.BAD_REQUEST });
} }
if (usernameValidation.username !== currentUser.username) { if (usernameValidation.value !== currentUser.username) {
const existingUsernameQuery = const existingUsernameQuery =
"SELECT id FROM users WHERE username = ? LIMIT 1"; "SELECT id FROM users WHERE username = ? LIMIT 1";
const existingUsernameResult = (await cassandra.execute( const existingUsernameResult = (await cassandra.execute(
existingUsernameQuery, existingUsernameQuery,
[usernameValidation.username], [usernameValidation.value],
)) as { rows: Array<{ id: string }> }; )) as { rows: Array<{ id: string }> };
if ( if (
@ -114,7 +106,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
return Response.json(response, { status: httpStatus.CONFLICT }); 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<Response> {
}; };
return Response.json(response, { status: httpStatus.BAD_REQUEST }); return Response.json(response, { status: httpStatus.BAD_REQUEST });
} }
updates.displayName = displayNameValidation.name || null; updates.displayName = displayNameValidation.value || null;
} }
} }

View file

@ -9,6 +9,7 @@ import { sessionManager } from "#lib/auth";
import { cassandra } from "#lib/database"; import { cassandra } from "#lib/database";
import { isValidPassword } from "#lib/validation"; import { isValidPassword } from "#lib/validation";
import type { UserSession } from "#types/config";
import type { import type {
ExtendedRequest, ExtendedRequest,
RouteDef, RouteDef,
@ -22,21 +23,12 @@ const routeDef: RouteDef = {
accepts: "application/json", accepts: "application/json",
returns: "application/json", returns: "application/json",
needsBody: "json", needsBody: "json",
needsAuth: true,
}; };
async function handler(request: ExtendedRequest): Promise<Response> { async function handler(request: ExtendedRequest): Promise<Response> {
try { try {
const { session } = request; const session = request.session as UserSession;
if (!session) {
const response: UpdatePasswordResponse = {
code: httpStatus.UNAUTHORIZED,
success: false,
error: errorMessages.NOT_AUTHENTICATED,
};
return Response.json(response, { status: httpStatus.UNAUTHORIZED });
}
const { currentPassword, newPassword, logoutAllSessions } = const { currentPassword, newPassword, logoutAllSessions } =
request.requestBody as UpdatePasswordRequest; request.requestBody as UpdatePasswordRequest;

View file

@ -9,6 +9,7 @@ import {
import { sessionManager } from "#lib/auth"; import { sessionManager } from "#lib/auth";
import { cassandra } from "#lib/database"; import { cassandra } from "#lib/database";
import type { UserSession } from "#types/config";
import type { import type {
ExtendedRequest, ExtendedRequest,
RouteDef, RouteDef,
@ -183,7 +184,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
}); });
} }
const { session } = request; const session = request.session as UserSession;
let sessionCookie: string | undefined; let sessionCookie: string | undefined;
if (session && session.id === user.id) { if (session && session.id === user.id) {

View file

@ -1,18 +1,21 @@
import { resolve } from "node:path"; import { resolve } from "node:path";
import { type Echo, echo } from "@atums/echo"; 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 { import {
type BunFile, type BunFile,
FileSystemRouter, FileSystemRouter,
type MatchedRoute, type MatchedRoute,
type Server, type Server,
} from "bun"; } from "bun";
import { environment } from "#environment/config";
import {
errorMessages,
httpStatus,
reqLoggerIgnores,
} from "#environment/constants";
import { noFileLog } from "#index";
import { sessionManager } from "#lib/auth"; import { sessionManager } from "#lib/auth";
import { webSocketHandler } from "#websocket";
import type { ExtendedRequest, RouteModule } from "#types/server"; import type { ExtendedRequest, RouteModule } from "#types/server";
class ServerHandler { class ServerHandler {
@ -84,14 +87,18 @@ class ServerHandler {
}); });
} else { } else {
echo.warn(`File not found: ${filePath}`); 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) { } catch (error) {
echo.error({ echo.error({
message: `Error serving static file: ${pathname}`, message: `Error serving static file: ${pathname}`,
error: error as Error, 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); this.logRequest(request, response, ip);
@ -146,7 +153,7 @@ class ServerHandler {
const customPath = resolve(baseDir, pathname.slice(1)); const customPath = resolve(baseDir, pathname.slice(1));
if (!customPath.startsWith(baseDir)) { if (!customPath.startsWith(baseDir)) {
response = new Response("Forbidden", { status: 403 }); response = new Response("Forbidden", { status: httpStatus.FORBIDDEN });
this.logRequest(extendedRequest, response, ip); this.logRequest(extendedRequest, response, ip);
return response; return response;
} }
@ -247,14 +254,15 @@ class ServerHandler {
response = Response.json( response = Response.json(
{ {
success: false, success: false,
code: 405, code: httpStatus.METHOD_NOT_ALLOWED,
error: `Method ${request.method} Not Allowed, expected ${ error: errorMessages.METHOD_NOT_ALLOWED,
details: `Method ${request.method} Not Allowed, expected ${
Array.isArray(routeModule.routeDef.method) Array.isArray(routeModule.routeDef.method)
? routeModule.routeDef.method.join(", ") ? routeModule.routeDef.method.join(", ")
: routeModule.routeDef.method : routeModule.routeDef.method
}`, }`,
}, },
{ status: 405 }, { status: httpStatus.METHOD_NOT_ALLOWED },
); );
} else { } else {
const expectedContentType: string | string[] | null = const expectedContentType: string | string[] | null =
@ -276,14 +284,14 @@ class ServerHandler {
response = Response.json( response = Response.json(
{ {
success: false, success: false,
code: 406, code: httpStatus.NOT_ACCEPTABLE,
error: `Content-Type ${actualContentType} Not Acceptable, expected ${ error: `Content-Type ${actualContentType} Not Acceptable, expected ${
Array.isArray(expectedContentType) Array.isArray(expectedContentType)
? expectedContentType.join(", ") ? expectedContentType.join(", ")
: expectedContentType : expectedContentType
}`, }`,
}, },
{ status: 406 }, { status: httpStatus.NOT_ACCEPTABLE },
); );
} else { } else {
extendedRequest.params = params; extendedRequest.params = params;
@ -291,6 +299,16 @@ class ServerHandler {
extendedRequest.requestBody = requestBody; extendedRequest.requestBody = requestBody;
extendedRequest.session = await sessionManager.getSession(request); extendedRequest.session = await sessionManager.getSession(request);
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); response = await routeModule.handler(extendedRequest, server);
if (routeModule.routeDef.returns !== "*/*") { if (routeModule.routeDef.returns !== "*/*") {
response.headers.set( response.headers.set(
@ -300,6 +318,7 @@ class ServerHandler {
} }
} }
} }
}
} catch (error: unknown) { } catch (error: unknown) {
echo.error({ echo.error({
message: `Error handling route ${request.url}`, message: `Error handling route ${request.url}`,
@ -309,20 +328,20 @@ class ServerHandler {
response = Response.json( response = Response.json(
{ {
success: false, success: false,
code: 500, code: httpStatus.INTERNAL_SERVER_ERROR,
error: "Internal Server Error", error: errorMessages.INTERNAL_SERVER_ERROR,
}, },
{ status: 500 }, { status: httpStatus.INTERNAL_SERVER_ERROR },
); );
} }
} else { } else {
response = Response.json( response = Response.json(
{ {
success: false, success: false,
code: 404, code: httpStatus.NOT_FOUND,
error: "Not Found", error: errorMessages.NOT_FOUND,
}, },
{ status: 404 }, { status: httpStatus.NOT_FOUND },
); );
} }

View file

@ -6,8 +6,7 @@ type genericValidation = {
type validationResult = { type validationResult = {
valid: boolean; valid: boolean;
error?: string; error?: string;
username?: string; value?: string;
name?: string;
}; };
interface UrlValidationOptions { interface UrlValidationOptions {

View file

@ -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<string>;
preferred_locale: string;
created_at: Date;
updated_at: Date;
}
export type { GuildResponse, GuildRow };

View file

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

View file

@ -0,0 +1,6 @@
interface DeleteGuildRequest {
id: string;
name: string;
}
export type { DeleteGuildRequest };

View file

@ -0,0 +1,4 @@
export * from "./base";
export * from "./create";
export * from "./delete";
export * from "./list";

View file

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

View file

@ -1,3 +1,4 @@
export * from "./user"; export * from "./user";
export * from "./health"; export * from "./health";
export * from "./base"; export * from "./base";
export * from "./guild";

View file

@ -13,6 +13,7 @@ type RouteDef = {
| "raw" | "raw"
| "buffer" | "buffer"
| "blob"; | "blob";
needsAuth?: boolean;
}; };
type RouteModule = { type RouteModule = {