Implement CDN functionality for image uploads and management in account routes
This commit is contained in:
19
backend/src/lib/CDN.ts
Normal file
19
backend/src/lib/CDN.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as Minio from 'minio';
|
||||
import { env } from 'node:process';
|
||||
|
||||
export const minioClient = new Minio.Client({
|
||||
endPoint: env.CDN_HOST!,
|
||||
port: parseInt(env.CDN_PORT || '443'),
|
||||
useSSL: env.CDN_USE_SSL === 'true',
|
||||
accessKey: env.CDN_KEY!,
|
||||
secretKey: env.CDN_SECRET!
|
||||
});
|
||||
|
||||
export const CDN_BUCKET = env.CDN_BUCKET!;
|
||||
|
||||
export function getPublicUrl(filename: string) {
|
||||
const protocol = env.CDN_USE_SSL === 'true' ? 'https' : 'http';
|
||||
const port = (env.CDN_PORT && env.CDN_PORT !== '80' && env.CDN_PORT !== '443') ? `:${env.CDN_PORT}` : '';
|
||||
|
||||
return `${protocol}://${env.CDN_HOST}${port}/${CDN_BUCKET}/${filename}`;
|
||||
}
|
||||
180
backend/src/routes/account/cdn.ts
Normal file
180
backend/src/routes/account/cdn.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { COOKIENAME, fileRouter } from "../../";
|
||||
import { authCheck } from "../../lib/Auth";
|
||||
import { minioClient, CDN_BUCKET, getPublicUrl } from "../../lib/CDN";
|
||||
import crypto from "crypto";
|
||||
|
||||
export = new fileRouter.Path("/").http(
|
||||
"POST",
|
||||
"/api/account/cdn/upload",
|
||||
(http) =>
|
||||
http.onRequest(async (ctr) => {
|
||||
const cookie = ctr.cookies.get(COOKIENAME) || null;
|
||||
const apiHeader = (ctr.headers && ctr.headers.get)
|
||||
? (ctr.headers.get("api-authentication") as string) || null
|
||||
: null;
|
||||
const auth = await authCheck(cookie, apiHeader);
|
||||
|
||||
if (!auth.state) {
|
||||
return ctr
|
||||
.status(ctr.$status.UNAUTHORIZED)
|
||||
.print({ error: auth.message });
|
||||
}
|
||||
|
||||
const [data, error] = await ctr.bindBody((z) =>
|
||||
z.object({
|
||||
images: z.array(z.object({
|
||||
image: z.string().min(1), // base64
|
||||
filename: z.string().min(1).max(200),
|
||||
contentType: z.string().min(1).max(100),
|
||||
})).min(1),
|
||||
})
|
||||
);
|
||||
|
||||
if (!data)
|
||||
return ctr.status(ctr.$status.BAD_REQUEST).print({ error: error.toString() });
|
||||
|
||||
try {
|
||||
const urls: string[] = [];
|
||||
const ALLOWED_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "webp", "pdf"]);
|
||||
|
||||
for (const item of data.images) {
|
||||
const buffer = Buffer.from(item.image, 'base64');
|
||||
const rawExtension = item.filename.split('.').pop() || "";
|
||||
const extension = rawExtension.toLowerCase();
|
||||
|
||||
if (!ALLOWED_EXTENSIONS.has(extension)) {
|
||||
return ctr
|
||||
.status(ctr.$status.BAD_REQUEST)
|
||||
.print({ error: "Unsupported file extension" });
|
||||
}
|
||||
const objectName = `${auth.userId}/${crypto.randomUUID()}.${extension}`;
|
||||
|
||||
await minioClient.putObject(CDN_BUCKET, objectName, buffer, buffer.length, {
|
||||
'Content-Type': item.contentType,
|
||||
});
|
||||
|
||||
urls.push(getPublicUrl(objectName));
|
||||
}
|
||||
|
||||
return ctr.print({
|
||||
success: true,
|
||||
urls: urls
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
|
||||
const details =
|
||||
error instanceof Error ? error.message : "Unknown error during image upload";
|
||||
|
||||
return ctr.status(ctr.$status.INTERNAL_SERVER_ERROR).print({
|
||||
error: "Failed to upload images to CDN",
|
||||
errorCode: "CDN_UPLOAD_FAILED",
|
||||
details
|
||||
});
|
||||
}
|
||||
})
|
||||
).http(
|
||||
"GET",
|
||||
"/api/account/cdn/list",
|
||||
(http) =>
|
||||
http.onRequest(async (ctr) => {
|
||||
const cookie = ctr.cookies.get(COOKIENAME) || null;
|
||||
const apiHeader = (ctr.headers && ctr.headers.get)
|
||||
? (ctr.headers.get("api-authentication") as string) || null
|
||||
: null;
|
||||
const auth = await authCheck(cookie, apiHeader);
|
||||
|
||||
if (!auth.state) {
|
||||
return ctr
|
||||
.status(ctr.$status.UNAUTHORIZED)
|
||||
.print({ error: auth.message });
|
||||
}
|
||||
|
||||
try {
|
||||
const objects: any[] = [];
|
||||
const stream = minioClient.listObjectsV2(CDN_BUCKET, `${auth.userId}/`, true);
|
||||
|
||||
for await (const obj of stream) {
|
||||
objects.push({
|
||||
name: obj.name,
|
||||
url: getPublicUrl(obj.name),
|
||||
size: obj.size,
|
||||
lastModified: obj.lastModified
|
||||
});
|
||||
}
|
||||
|
||||
return ctr.print({
|
||||
success: true,
|
||||
images: objects
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return ctr.status(ctr.$status.INTERNAL_SERVER_ERROR).print({
|
||||
error: "Failed to list images from CDN"
|
||||
});
|
||||
}
|
||||
})
|
||||
).http(
|
||||
"DELETE",
|
||||
"/api/account/cdn/delete",
|
||||
(http) =>
|
||||
http.onRequest(async (ctr) => {
|
||||
const cookie = ctr.cookies.get(COOKIENAME) || null;
|
||||
const apiHeader = (ctr.headers && ctr.headers.get)
|
||||
? (ctr.headers.get("api-authentication") as string) || null
|
||||
: null;
|
||||
const auth = await authCheck(cookie, apiHeader);
|
||||
|
||||
if (!auth.state) {
|
||||
return ctr
|
||||
.status(ctr.$status.UNAUTHORIZED)
|
||||
.print({ error: auth.message });
|
||||
}
|
||||
|
||||
const [data, error] = await ctr.bindBody((z) =>
|
||||
z.object({
|
||||
urls: z.array(z.string().url()).min(1),
|
||||
})
|
||||
);
|
||||
|
||||
if (!data)
|
||||
return ctr.status(ctr.$status.BAD_REQUEST).print({ error: error.toString() });
|
||||
|
||||
try {
|
||||
const objectsToDelete: string[] = [];
|
||||
|
||||
for (const url of data.urls) {
|
||||
const urlObj = new URL(url);
|
||||
const pathParts = urlObj.pathname.split('/').filter(Boolean);
|
||||
|
||||
// Expected: [bucket, userId, filename]
|
||||
if (pathParts.length < 3) continue;
|
||||
|
||||
const bucket = pathParts[0];
|
||||
const userId = pathParts[1];
|
||||
const filename = pathParts.slice(2).join('/');
|
||||
const objectName = `${userId}/${filename}`;
|
||||
|
||||
if (bucket === CDN_BUCKET && userId === auth.userId) {
|
||||
objectsToDelete.push(objectName);
|
||||
}
|
||||
}
|
||||
|
||||
if (objectsToDelete.length === 0) {
|
||||
return ctr.status(ctr.$status.BAD_REQUEST).print({ error: "No valid objects to delete" });
|
||||
}
|
||||
|
||||
await minioClient.removeObjects(CDN_BUCKET, objectsToDelete);
|
||||
|
||||
return ctr.print({
|
||||
success: true,
|
||||
message: `${objectsToDelete.length} image(s) deleted successfully`
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return ctr.status(ctr.$status.INTERNAL_SERVER_ERROR).print({
|
||||
error: "Failed to delete images"
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -1,5 +1,6 @@
|
||||
import { COOKIENAME, db, fileRouter } from "../../";
|
||||
import { authCheck } from "../../lib/Auth";
|
||||
import { minioClient, CDN_BUCKET } from "../../lib/CDN";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
export = new fileRouter.Path("/").http(
|
||||
@@ -15,6 +16,22 @@ export = new fileRouter.Path("/").http(
|
||||
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: auth.message });
|
||||
}
|
||||
|
||||
// Delete all CDN objects for the user
|
||||
try {
|
||||
const objectsToDelete: string[] = [];
|
||||
const stream = minioClient.listObjectsV2(CDN_BUCKET, `${auth.userId}/`, true);
|
||||
|
||||
for await (const obj of stream) {
|
||||
if (obj.name) objectsToDelete.push(obj.name);
|
||||
}
|
||||
|
||||
if (objectsToDelete.length > 0) {
|
||||
await minioClient.removeObjects(CDN_BUCKET, objectsToDelete);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to cleanup CDN during account deletion:", e);
|
||||
}
|
||||
|
||||
// Delete all exams for the user
|
||||
await db.collection("exams").deleteMany({ userId: auth.userId });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user