From f14daf041a0f24e4925c586d9cb2f855ffab4ef5 Mon Sep 17 00:00:00 2001 From: creations Date: Mon, 17 Mar 2025 05:47:22 -0400 Subject: [PATCH] add user avatars, delete, set, view --- config/sql/avatars.ts | 44 +++++ config/sql/settings.ts | 2 + src/routes/api/user/avatar/delete.ts | 142 ++++++++++++++++ src/routes/api/user/avatar/set.ts | 233 +++++++++++++++++++++++++++ src/routes/user/avatar/[user].ts | 121 ++++++++++++++ types/file.d.ts | 18 +++ 6 files changed, 560 insertions(+) create mode 100644 config/sql/avatars.ts create mode 100644 src/routes/api/user/avatar/delete.ts create mode 100644 src/routes/api/user/avatar/set.ts create mode 100644 src/routes/user/avatar/[user].ts diff --git a/config/sql/avatars.ts b/config/sql/avatars.ts new file mode 100644 index 0000000..7047e24 --- /dev/null +++ b/config/sql/avatars.ts @@ -0,0 +1,44 @@ +import { logger } from "@helpers/logger"; +import { type ReservedSQL, sql } from "bun"; + +export const order: number = 6; + +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 avatars ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + mime_type VARCHAR(255) NOT NULL, + extension VARCHAR(255) NOT NULL, + size BIGINT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL + ); + `; + } catch (error) { + logger.error(["Could not create the avatars table:", error as Error]); + throw error; + } finally { + if (selfReservation) { + reservation.release(); + } + } +} + +export function isValidTypeOrExtension( + type: string, + extension: string, +): boolean { + return ( + ["image/jpeg", "image/png", "image/gif", "image/webp"].includes(type) && + ["jpeg", "jpg", "png", "gif", "webp"].includes(extension) + ); +} diff --git a/config/sql/settings.ts b/config/sql/settings.ts index 9e68174..0def8e8 100644 --- a/config/sql/settings.ts +++ b/config/sql/settings.ts @@ -15,6 +15,8 @@ const defaultSettings: Setting[] = [ { key: "random_name_length", value: "8" }, { key: "enable_thumbnails", value: "true" }, { key: "index_page_stats", value: "true" }, + { key: "instance_name", value: "Atums World" }, + { key: "max_avatar_size", value: "10000000" }, // 10 MB ]; export async function createTable(reservation?: ReservedSQL): Promise { diff --git a/src/routes/api/user/avatar/delete.ts b/src/routes/api/user/avatar/delete.ts new file mode 100644 index 0000000..29e0e35 --- /dev/null +++ b/src/routes/api/user/avatar/delete.ts @@ -0,0 +1,142 @@ +import { dataType } from "@config/environment"; +import { s3, sql } from "bun"; +import { resolve } from "path"; + +import { logger } from "@/helpers/logger"; +import { sessionManager } from "@/helpers/sessions"; + +async function deleteAvatar( + request: ExtendedRequest, + userID: UUID, +): Promise<[boolean, string]> { + try { + const [existingAvatar] = + await sql`SELECT * FROM avatars WHERE owner = ${userID}`; + + if (!existingAvatar) { + return [false, "No avatar found"]; + } + + const fileName: string = `${existingAvatar.owner}.${existingAvatar.extension}`; + + try { + if (dataType.type === "local" && dataType.path) { + await Bun.file( + resolve(dataType.path, "avatars", fileName), + ).unlink(); + } else { + await s3.delete(`/avatars/${fileName}`); + } + } catch (error) { + logger.error(["Error deleting avatar file:", error as Error]); + return [false, "Failed to delete avatar file"]; + } + + await sql`DELETE FROM avatars WHERE owner = ${userID}`; + await sql`UPDATE users SET avatar = false WHERE id = ${userID}`; + + return [true, "Avatar deleted successfully"]; + } catch (error) { + logger.error(["Error deleting avatar:", error as Error]); + return [false, "Failed to delete avatar"]; + } +} + +const routeDef: RouteDef = { + method: "DELETE", + accepts: "*/*", + returns: "application/json", +}; + +async function handler(request: ExtendedRequest): Promise { + if (!request.session) { + return Response.json( + { + success: false, + code: 401, + error: "Unauthorized", + }, + { status: 401 }, + ); + } + + const userID: UUID = (request.query.user as UUID) || request.session.id; + const isAdmin: boolean = request.session.roles.includes("admin"); + + if (request.session.id !== userID && !isAdmin) { + return Response.json( + { + success: false, + code: 403, + error: "Forbidden", + }, + { status: 403 }, + ); + } + + try { + const [success, message] = await deleteAvatar(request, userID); + + if (!success) { + return Response.json( + { + success: false, + code: 500, + error: message, + }, + { status: 500 }, + ); + } + + if ( + !(request.session as ApiUserSession).is_api && + request.session.id === userID + ) { + const userSession: UserSession = { + ...request.session, + avatar: false, + }; + + const sessionCookie: string = await sessionManager.createSession( + userSession, + request.headers.get("User-Agent") || "", + ); + + return Response.json( + { + success: true, + code: 200, + message: "Avatar deleted", + }, + { + status: 200, + headers: { + "Set-Cookie": sessionCookie, + }, + }, + ); + } else { + return Response.json( + { + success: true, + code: 200, + message: "Avatar deleted", + }, + { status: 200 }, + ); + } + } catch (error) { + logger.error(["Error processing delete request:", error as Error]); + + return Response.json( + { + success: false, + code: 500, + error: "Error deleting avatar", + }, + { status: 500 }, + ); + } +} + +export { handler, routeDef }; diff --git a/src/routes/api/user/avatar/set.ts b/src/routes/api/user/avatar/set.ts new file mode 100644 index 0000000..8dc5a7e --- /dev/null +++ b/src/routes/api/user/avatar/set.ts @@ -0,0 +1,233 @@ +import { dataType } from "@config/environment"; +import { isValidTypeOrExtension } from "@config/sql/avatars"; +import { getSetting } from "@config/sql/settings"; +import { s3, sql } from "bun"; +import { resolve } from "path"; + +import { getBaseUrl, getExtension } from "@/helpers/char"; +import { logger } from "@/helpers/logger"; +import { sessionManager } from "@/helpers/sessions"; + +async function processFile( + file: File, + request: ExtendedRequest, + userID: UUID, +): Promise<[boolean, string]> { + const extension: string | null = getExtension(file.name); + if (!extension) return [false, "Invalid file extension"]; + if (!isValidTypeOrExtension(file.type, extension)) + return [false, "Invalid file type"]; + + const maxSize: bigint = BigInt( + (await getSetting("max_avatar_size")) || "10000000", // Default 10MB + ); + if (file.size > maxSize) + return [false, `Avatar is too large (max ${maxSize} bytes)`]; + + const avatarEntry: AvatarUpload = { + owner: userID, + mime_type: file.type, + extension, + size: file.size, + }; + + const fileBuffer: ArrayBuffer = await file.arrayBuffer(); + const fileName: string = `${avatarEntry.owner}.${extension}`; + + try { + const [existingAvatar] = + await sql`SELECT * FROM avatars WHERE owner = ${userID}`; + + if (existingAvatar) { + const existingFileName: string = `${existingAvatar.owner}.${existingAvatar.extension}`; + + try { + if (dataType.type === "local" && dataType.path) { + await Bun.file( + resolve(dataType.path, "avatars", existingFileName), + ).unlink(); + } else { + await s3.delete(`/avatars/${existingFileName}`); + } + } catch (error) { + logger.error([ + "Error deleting existing avatar file:", + error as Error, + ]); + } + } + + await sql`DELETE FROM avatars WHERE owner = ${userID}`; + await sql`INSERT INTO avatars ${sql(avatarEntry)}`; + + const path: string = + dataType.type === "local" && dataType.path + ? resolve(dataType.path, "avatars", fileName) + : `/avatars/${fileName}`; + + try { + if (dataType.type === "local" && dataType.path) { + await Bun.write(path, fileBuffer, { createPath: true }); + } else { + await s3.write(path, fileBuffer); + } + } catch (error) { + logger.error(["Error writing avatar file:", error as Error]); + await sql`DELETE FROM avatars WHERE owner = ${userID}`; + return [false, "Failed to write file"]; + } + + await sql`UPDATE users SET avatar = true WHERE id = ${userID}`; + + return [true, `${getBaseUrl(request)}/user/avatar/${fileName}`]; + } catch (error) { + logger.error(["Error processing avatar:", error as Error]); + await sql`DELETE FROM avatars WHERE owner = ${userID}`; + return [false, "Failed to process avatar"]; + } +} + +const routeDef: RouteDef = { + method: "POST", + accepts: "multipart/form-data", + returns: "application/json", + needsBody: "multipart", +}; + +async function handler( + request: ExtendedRequest, + requestBody: unknown, +): Promise { + if (!request.session) { + return Response.json( + { + success: false, + code: 401, + error: "Unauthorized", + }, + { status: 401 }, + ); + } + + const formData: FormData | null = requestBody as FormData; + const userID: UUID = (request.query.user as UUID) || request.session.id; + const isAdmin: boolean = request.session + ? request.session.roles.includes("admin") + : false; + + if (request.session.id !== userID && !isAdmin) { + return Response.json( + { + success: false, + code: 403, + error: "Forbidden", + }, + { status: 403 }, + ); + } + + if (!formData || !(requestBody instanceof FormData)) { + return Response.json( + { + success: false, + code: 400, + error: "Missing form data", + }, + { status: 400 }, + ); + } + + const file: File | null = + (formData.get("file") as File) || + (formData.get("avatar") as File) || + null; + + if (!file.type || file.type === "") { + return Response.json( + { + success: false, + code: 400, + error: "Missing file type", + }, + { status: 400 }, + ); + } + + if (!file.size || file.size === 0) { + return Response.json( + { + success: false, + code: 400, + error: "Missing file size", + }, + { status: 400 }, + ); + } + + try { + const [success, message] = await processFile(file, request, userID); + + if (!success) { + return Response.json( + { + success: false, + code: 500, + error: message, + }, + { status: 500 }, + ); + } + + if ( + !(request.session as ApiUserSession).is_api && + request.session.id === userID + ) { + const userSession: UserSession = { + ...request.session, + avatar: true, + }; + + const sessionCookie: string = await sessionManager.createSession( + userSession, + request.headers.get("User-Agent") || "", + ); + + return Response.json( + { + success: true, + code: 200, + message: "Avatar uploaded", + url: message, + }, + { + status: 200, + headers: { + "Set-Cookie": sessionCookie, + }, + }, + ); + } else { + return Response.json( + { + success: true, + code: 200, + message: "Avatar uploaded", + url: message, + }, + { status: 200 }, + ); + } + } catch (error) { + logger.error(["Error processing file:", error as Error]); + + return Response.json( + { + success: false, + code: 500, + error: "Error processing file", + }, + { status: 500 }, + ); + } +} +export { handler, routeDef }; diff --git a/src/routes/user/avatar/[user].ts b/src/routes/user/avatar/[user].ts new file mode 100644 index 0000000..029c435 --- /dev/null +++ b/src/routes/user/avatar/[user].ts @@ -0,0 +1,121 @@ +import { dataType } from "@config/environment"; +import { isValidUsername } from "@config/sql/users"; +import { type BunFile, type ReservedSQL, sql } from "bun"; +import { resolve } from "path"; + +import { isUUID, nameWithoutExtension } from "@/helpers/char"; +import { logger } from "@/helpers/logger"; + +const routeDef: RouteDef = { + method: "GET", + accepts: "*/*", + returns: "*/*", +}; + +async function handler(request: ExtendedRequest): Promise { + const { user: query } = request.params as { user: string }; + const { json, download } = request.query as { + json?: string; + download?: string; + }; + + if (!query) { + return Response.json( + { + success: false, + code: 400, + error: "User username or ID is required", + }, + { status: 400 }, + ); + } + + const noEXT: string = nameWithoutExtension(query); + const isId: boolean = isUUID(noEXT); + const normalized: string = isId ? noEXT : noEXT.normalize("NFC"); + + if (!isId && !isValidUsername(normalized).valid) { + return Response.json( + { success: false, code: 400, error: "Invalid username" }, + { status: 400 }, + ); + } + + const reservation: ReservedSQL = await sql.reserve(); + + try { + const [user] = isId + ? await reservation`SELECT * FROM users WHERE id = ${normalized}` + : await reservation`SELECT * FROM users WHERE username = ${normalized}`; + + if (!user) + return Response.json( + { success: false, code: 404, error: "User not found" }, + { status: 404 }, + ); + if (!user.avatar) + return Response.json( + { success: false, code: 404, error: "User has no avatar" }, + { status: 404 }, + ); + + const [avatar] = + await reservation`SELECT * FROM avatars WHERE owner = ${user.id}`; + + if (!avatar) + return Response.json( + { success: false, code: 404, error: "Avatar not found" }, + { status: 404 }, + ); + + if (json === "true" || json === "1") { + return Response.json( + { success: true, code: 200, data: avatar }, + { status: 200 }, + ); + } + + let path: string; + if (dataType.type === "local" && dataType.path) { + path = resolve( + dataType.path, + "avatars", + `${avatar.id}.${avatar.extension}`, + ); + } else { + path = `/avatars/${avatar.id}.${avatar.extension}`; + } + + try { + const bunStream: BunFile = Bun.file(path); + + return new Response(bunStream, { + headers: { + "Content-Type": avatar.mime_type, + "Content-Length": avatar.size.toString(), + "Content-Disposition": + download === "true" || download === "1" + ? `attachment; filename="${avatar.id}.${avatar.extension}"` + : `inline; filename="${avatar.id}.${avatar.extension}"`, + }, + status: 200, + }); + } catch (error) { + logger.error(["Failed to fetch avatar", error as Error]); + return Response.json( + { success: false, code: 500, error: "Failed to fetch avatar" }, + { status: 500 }, + ); + } + } catch (error) { + logger.error(["Error fetching avatar:", error as Error]); + return Response.json( + { success: false, code: 500, error: "Failed to fetch avatar" }, + { status: 500 }, + ); + } finally { + reservation.release(); + } +} + +export { handler, routeDef }; diff --git a/types/file.d.ts b/types/file.d.ts index e620263..fc039e5 100644 --- a/types/file.d.ts +++ b/types/file.d.ts @@ -41,3 +41,21 @@ type Folder = { created_at: string; updated_at: string; }; + +// Avatars + +type AvatarEntry = { + id: UUID; + owner: UUID; + + mime_type: string; + extension: string; + size: number; + + created_at: string; + updated_at: string; +}; + +type AvatarUpload = Partial & { + url?: string; +};