diff --git a/README.md b/README.md index 1b56686..5a4b7c5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1 @@ -# bun frontend template - -a simle bun frontend starting point i made and use +# atums.world diff --git a/config/sql/invites.ts b/config/sql/invites.ts new file mode 100644 index 0000000..601cef6 --- /dev/null +++ b/config/sql/invites.ts @@ -0,0 +1,54 @@ +import { logger } from "@helpers/logger"; +import { type ReservedSQL, sql } from "bun"; + +export async function createTable(reservation?: ReservedSQL): Promise { + let selfReservation: boolean = false; + + if (!reservation) { + reservation = await sql.reserve(); + selfReservation = true; + } + + try { + await reservation` + CREATE TABLE IF NOT EXISTS invites ( + id TEXT PRIMARY KEY NOT NULL UNIQUE, + created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + expiration TIMESTAMPTZ DEFAULT NULL, + uses INTEGER DEFAULT 0, + max_uses INTEGER DEFAULT 1, + role TEXT NOT NULL DEFAULT 'user' + );`; + } catch (error) { + logger.error(["Could not create the invites table:", error as Error]); + throw error; + } finally { + if (selfReservation) { + reservation.release(); + } + } +} + +export async function drop( + cascade: boolean, + reservation?: ReservedSQL, +): Promise { + let selfReservation: boolean = false; + + if (!reservation) { + reservation = await sql.reserve(); + selfReservation = true; + } + + try { + await reservation`DROP TABLE IF EXISTS invites ${cascade ? "CASCADE" : ""};`; + } catch (error) { + logger.error(["Could not drop the invites table:", error as Error]); + throw error; + } finally { + if (selfReservation) { + reservation.release(); + } + } +} diff --git a/package.json b/package.json index d05b594..5e5ed9d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@eslint/js": "^9.21.0", "@types/bun": "^1.2.4", "@types/ejs": "^3.1.5", + "@types/luxon": "^3.4.2", "@typescript-eslint/eslint-plugin": "^8.25.0", "@typescript-eslint/parser": "^8.25.0", "eslint": "^9.21.0", @@ -30,6 +31,7 @@ "dependencies": { "ejs": "^3.1.10", "fast-jwt": "^5.0.5", + "luxon": "^3.5.0", "redis": "^4.7.0" } } diff --git a/src/helpers/auth.ts b/src/helpers/auth.ts index be59ff9..e1fccb8 100644 --- a/src/helpers/auth.ts +++ b/src/helpers/auth.ts @@ -24,7 +24,7 @@ export async function authByToken( try { const result: UserSession[] = - await reservation`SELECT id, username, email, roles avatar, timezone, authorization_token FROM users WHERE authorization_token = ${authorizationToken};`; + await reservation`SELECT id, username, email, roles, avatar, timezone, authorization_token FROM users WHERE authorization_token = ${authorizationToken};`; if (result.length === 0) return null; diff --git a/src/helpers/char.ts b/src/helpers/char.ts index 7ec0bff..2e239b4 100644 --- a/src/helpers/char.ts +++ b/src/helpers/char.ts @@ -1,3 +1,5 @@ +import { DateTime } from "luxon"; + export function timestampToReadable(timestamp?: number): string { const date: Date = timestamp && !isNaN(timestamp) ? new Date(timestamp) : new Date(); @@ -11,3 +13,90 @@ export function isUUID(uuid: string): boolean { return regex.test(uuid); } + +export function getNewTimeUTC( + durationStr: string, + from: DateTime = DateTime.utc(), +): string | null { + const duration: DurationObject = parseDuration(durationStr); + + const newTime: DateTime = from.plus({ + years: duration.years, + months: duration.months, + weeks: duration.weeks, + days: duration.days, + hours: duration.hours, + minutes: duration.minutes, + seconds: duration.seconds, + }); + + return newTime.toSQL({ includeOffset: false }); +} + +export function parseDuration(input: string): DurationObject { + const regex: RegExp = /(\d+)(y|mo|w|d|h|m|s)/g; + const matches: RegExpMatchArray[] = [...input.matchAll(regex)]; + + const duration: DurationObject = { + years: 0, + months: 0, + weeks: 0, + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + }; + + for (const match of matches) { + const value: number = parseInt(match[1], 10); + const unit: string = match[2]; + + switch (unit) { + case "y": + duration.years = value; + break; + case "mo": + duration.months = value; + break; + case "w": + duration.weeks = value; + break; + case "d": + duration.days = value; + break; + case "h": + duration.hours = value; + break; + case "m": + duration.minutes = value; + break; + case "s": + duration.seconds = value; + break; + } + } + + return duration; +} + +export function isValidTimezone(timezone: string): boolean { + return DateTime.local().setZone(timezone).isValid; +} + +export function generateRandomString(length?: number): string { + if (!length) { + length = length || Math.floor(Math.random() * 10) + 5; + } + + const characters: string = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let result: string = ""; + + for (let i: number = 0; i < length; i++) { + result += characters.charAt( + Math.floor(Math.random() * characters.length), + ); + } + + return result; +} diff --git a/src/routes/api/auth/register.ts b/src/routes/api/auth/register.ts index de6e16b..1f30fe5 100644 --- a/src/routes/api/auth/register.ts +++ b/src/routes/api/auth/register.ts @@ -6,7 +6,6 @@ import { isValidUsername, } from "@config/sql/users"; import { password as bunPassword, type ReservedSQL, sql } from "bun"; -import type { UUID } from "crypto"; import { logger } from "@/helpers/logger"; import { sessionManager } from "@/helpers/sessions"; @@ -56,14 +55,14 @@ async function handler( const reservation: ReservedSQL = await sql.reserve(); let firstUser: boolean = false; - let invitedBy: UUID | null = null; + let inviteData: Invite | null = null; let roles: string[] = []; try { const registrationEnabled: boolean = - (await getSetting("registrationEnabled", reservation)) === "true"; + (await getSetting("enable_registration", reservation)) === "true"; const invitationsEnabled: boolean = - (await getSetting("invitationsEnabled", reservation)) === "true"; + (await getSetting("enable_invitations", reservation)) === "true"; firstUser = Number( @@ -87,23 +86,30 @@ async function handler( } roles.push("user"); - if (firstUser) { - roles.push("admin"); + if (firstUser) roles.push("admin"); + + const result: { usernameExists: boolean; emailExists: boolean }[] = + await reservation` + SELECT + EXISTS(SELECT 1 FROM users WHERE LOWER(username) = LOWER(${username})) AS "usernameExists", + EXISTS(SELECT 1 FROM users WHERE LOWER(email) = LOWER(${email})) AS "emailExists"; + `; + + const { usernameExists, emailExists } = result[0] || {}; + + if (usernameExists || emailExists) { + errors.push("Username or email already exists"); } - const { usernameExists, emailExists } = await reservation` - SELECT - EXISTS(SELECT 1 FROM users WHERE LOWER(username) = LOWER(${username})) AS usernameExists, - EXISTS(SELECT 1 FROM users WHERE LOWER(email) = LOWER(${email})) AS emailExists; - `; - - if (usernameExists) errors.push("Username already exists"); - if (emailExists) errors.push("Email already exists"); if (invite) { - invitedBy = ( - await reservation`SELECT user_id FROM invites WHERE invite = ${invite};` - )[0]?.id; - if (!invitedBy) errors.push("Invalid invite code"); + const result: Invite[] = + await reservation`SELECT * FROM invites WHERE id = ${invite};`; + + if (!result || result.length === 0) { + errors.push("Invalid invite"); + } + + inviteData = result[0]; } } catch (error) { errors.push("An error occurred while checking for existing users"); @@ -129,13 +135,25 @@ async function handler( (await getSetting("default_timezone", reservation)) || "UTC"; try { - user = ( - await reservation` + const result: User[] = await reservation` INSERT INTO users (username, email, password, invited_by, roles, timezone) - VALUES (${username}, ${email}, ${hashedPassword}, ${invitedBy}, ARRAY[${roles.join(",")}]::TEXT[], ${defaultTimezone}) + VALUES (${username}, ${email}, ${hashedPassword}, ${inviteData?.created_by}, ARRAY[${roles.join(",")}]::TEXT[], ${defaultTimezone}) RETURNING *; - ` - )[0]; + `; + + if (result.length === 0) { + logger.error("User was not created"); + return Response.json( + { + success: false, + code: 500, + error: "An error occurred with the user registration", + }, + { status: 500 }, + ); + } + + user = result[0]; if (!user) { logger.error("User was not created"); @@ -149,8 +167,17 @@ async function handler( ); } - if (invitedBy) { - await reservation`DELETE FROM invites WHERE invite = ${invite};`; + if (invite) { + const maxUses: number = Number(inviteData?.max_uses) || 1; + const uses: number = Number(inviteData?.uses) || 0; + + if (uses + 1 >= maxUses) { + await reservation`DELETE FROM invites WHERE id = ${inviteData?.id};`; + } else { + await reservation`UPDATE invites SET uses = ${uses + 1} WHERE id = ${inviteData?.id};`; + } + + if (inviteData?.role) roles.push(inviteData.role); } } catch (error) { logger.error([ diff --git a/src/routes/api/invite/create.ts b/src/routes/api/invite/create.ts new file mode 100644 index 0000000..4226e58 --- /dev/null +++ b/src/routes/api/invite/create.ts @@ -0,0 +1,126 @@ +import { getSetting } from "@config/sql/settings"; +import { sql } from "bun"; + +import { generateRandomString, getNewTimeUTC } from "@/helpers/char"; +import { logger } from "@/helpers/logger"; + +const routeDef: RouteDef = { + method: "POST", + accepts: "application/json", + returns: "application/json", + needsBody: "json", +}; + +async function handler( + request: ExtendedRequest, + requestBody: unknown, +): Promise { + if (!request.session) { + return Response.json( + { + success: false, + code: 403, + error: "Unauthorized", + }, + { status: 403 }, + ); + } + + if (!getSetting("enable_invitations")) { + return Response.json( + { + success: false, + code: 403, + error: "Invitations are disabled", + }, + { status: 403 }, + ); + } + + const isAdmin: boolean = request.session.roles.includes("admin"); + + if (!isAdmin && !getSetting("allow_user_invites")) { + return Response.json( + { + success: false, + code: 403, + error: "User invitations are disabled", + }, + { status: 403 }, + ); + } + + const { expires, max_uses, role } = requestBody as { + expires?: string; + max_uses?: number; + role?: string; + }; + + if (role && !isAdmin) { + return Response.json( + { + success: false, + code: 403, + error: "You must be an admin to set the role", + }, + { status: 403 }, + ); + } + + const expirationDate: string | null = expires + ? getNewTimeUTC(expires) + : null; + const maxUses: number = Number(max_uses) || 1; + const inviteRole: string = role || "user"; + + let invite: Invite | null = null; + try { + const result: Invite[] = await sql` + INSERT INTO invites (created_by, expiration, max_uses, role, id) + VALUES (${request.session.id}, ${expirationDate}, ${maxUses}, ${inviteRole}, ${generateRandomString(15)}) + RETURNING *; + `; + + if (result.length === 0) { + logger.error("Invite failed to create"); + + return Response.json( + { + success: false, + code: 500, + error: "Invite was not created", + }, + { status: 500 }, + ); + } + + invite = result[0]; + } catch (error) { + logger.error(["Error creating invite:", error as Error]); + + return Response.json( + { + success: false, + code: 500, + error: "An error occurred while creating the invite", + }, + { status: 500 }, + ); + } + + return Response.json( + { + success: true, + code: 200, + invite: { + code: invite.id, + expiration: invite.expiration, + max_uses: invite.max_uses, + role: invite.role, + }, + }, + { status: 200 }, + ); +} + +export { handler, routeDef }; diff --git a/src/routes/api/invite/delete[invite].ts b/src/routes/api/invite/delete[invite].ts new file mode 100644 index 0000000..96cf1b9 --- /dev/null +++ b/src/routes/api/invite/delete[invite].ts @@ -0,0 +1,92 @@ +import { type ReservedSQL, sql } from "bun"; + +import { logger } from "@/helpers/logger"; + +const routeDef: RouteDef = { + method: "DELETE", + accepts: "*/*", + returns: "application/json", +}; + +async function handler(request: ExtendedRequest): Promise { + if (!request.session) { + return Response.json( + { + success: false, + code: 403, + error: "Unauthorized", + }, + { status: 403 }, + ); + } + + const isAdmin: boolean = request.session.roles.includes("admin"); + const { invite } = request.params as { invite: string }; + + if (!invite) { + return Response.json( + { + success: false, + code: 400, + error: "Expected invite", + }, + { status: 400 }, + ); + } + + const reservation: ReservedSQL = await sql.reserve(); + let inviteData: Invite | null = null; + + try { + const result: Invite[] = + await reservation`SELECT * FROM invites WHERE id = ${invite};`; + + if (result.length === 0) { + return Response.json( + { + success: false, + code: 400, + error: "Invalid invite", + }, + { status: 400 }, + ); + } + + inviteData = result[0]; + + if (!isAdmin && inviteData.created_by !== request.session.id) { + return Response.json( + { + success: false, + code: 403, + error: "Unauthorized", + }, + { status: 403 }, + ); + } + + await reservation`DELETE FROM invites WHERE id = ${inviteData.id};`; + } catch (error) { + logger.error(["Could not get the invite:", error as Error]); + + return Response.json( + { + success: false, + code: 500, + error: "Internal server error", + }, + { status: 500 }, + ); + } + + return Response.json( + { + success: true, + code: 200, + message: "Invite deleted", + }, + { status: 200 }, + ); +} + +export { handler, routeDef }; diff --git a/types/char.d.ts b/types/char.d.ts new file mode 100644 index 0000000..67ebf8c --- /dev/null +++ b/types/char.d.ts @@ -0,0 +1,9 @@ +type DurationObject = { + years: number; + months: number; + weeks: number; + days: number; + hours: number; + minutes: number; + seconds: number; +}; diff --git a/types/session.d.ts b/types/session.d.ts index 9f5d7b7..fa042a7 100644 --- a/types/session.d.ts +++ b/types/session.d.ts @@ -27,3 +27,13 @@ type User = { created_at: Date; last_seen: Date; }; + +type Invite = { + id: UUID; + created_by: UUID; + created_at: Date; + expiration: Date | null; + uses: number; + max_uses: number; + role: string; +};