add user avatars, delete, set, view

This commit is contained in:
creations 2025-03-17 05:47:22 -04:00
parent cc4ebfbdd0
commit f14daf041a
Signed by: creations
GPG key ID: 8F553AA4320FC711
6 changed files with 560 additions and 0 deletions

44
config/sql/avatars.ts Normal file
View file

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

View file

@ -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<void> {

View file

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

View file

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

View file

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

18
types/file.d.ts vendored
View file

@ -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<AvatarEntry> & {
url?: string;
};