forked from atums.world/atums.world
add user avatars, delete, set, view
This commit is contained in:
parent
cc4ebfbdd0
commit
f14daf041a
6 changed files with 560 additions and 0 deletions
44
config/sql/avatars.ts
Normal file
44
config/sql/avatars.ts
Normal 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)
|
||||
);
|
||||
}
|
|
@ -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> {
|
||||
|
|
142
src/routes/api/user/avatar/delete.ts
Normal file
142
src/routes/api/user/avatar/delete.ts
Normal 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 };
|
233
src/routes/api/user/avatar/set.ts
Normal file
233
src/routes/api/user/avatar/set.ts
Normal 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 };
|
121
src/routes/user/avatar/[user].ts
Normal file
121
src/routes/user/avatar/[user].ts
Normal 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
18
types/file.d.ts
vendored
|
@ -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;
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue