import { Body, Controller, Delete, Example, Post, Put, Route, SuccessResponse } from "tsoa"; import minioClient from "../minio"; import esClient from "../elasticsearch"; import HttpError from "../interfaces/http-error"; import HttpStatusCode from "../interfaces/http-status"; import { StorageFile, StorageFolder } from "../interfaces/storage-fs"; import { copyCond } from "../utils/minio"; if (!process.env.MINIO_BUCKET) throw Error("Default MinIO bucket must be specified."); if (!process.env.ELASTICSEARCH_INDEX) throw Error("Default ElasticSearch index must be specified."); const DEFAULT_BUCKET = process.env.MINIO_BUCKET; const DEFAULT_INDEX = process.env.ELASTICSEARCH_INDEX; const MINIO_ERROR_MESSAGE = "เกิดข้อผิดพลาดกับระบบจัดการไฟล์"; const PATH_NOT_FOUND_MESSAGE = "ไม่พบตำแหน่งที่ต้องการนำข้อมูลเข้า"; const PATH_ALREADY_EXIST = "ตำแหน่งดังกล่าวมีในระบบแล้ว"; interface ListRequestBody { operation: "folder" | "file"; path: string[]; } interface CreateFolderBody { path: string[]; name: string; } interface PutFolderBody { from: { path: string[]; name: string; }; to: { path: string[]; name: string; }; } interface DeleteFolderBody { path: string[]; } interface CreateFileBody { path: string[]; file: string; title?: string; description?: string; category?: string[]; keyword?: string[]; } async function listFolder(path: string[]) { const list = await new Promise<{ pathname: string; name: string }[]>((resolve, reject) => { const item: { pathname: string; name: string }[] = []; const stream = minioClient.listObjectsV2(DEFAULT_BUCKET, path.join("/") + "/"); stream.on("data", (v) => { if (v && v.prefix) item.push({ pathname: v.prefix, name: v.prefix.split("/").filter(Boolean).at(-1)!, }); }); stream.on("end", () => resolve(item)); stream.on("error", () => reject(new Error(MINIO_ERROR_MESSAGE))); }); const folder = await Promise.all( list.map(async (v) => { // Get stat from hidden object that used to mark as folder as minio doesn't really have folder const stat = await minioClient .statObject(DEFAULT_BUCKET, `${v.pathname}.keep`) .catch((e) => console.error(`MinIO Error: ${e}`)); if (!stat) return undefined; const { createdat, createdby } = stat.metaData; return { ...v, createdAt: createdat ?? "n/a", createdBy: createdby ?? "n/a", } satisfies StorageFolder; }), ); return folder.filter((v: StorageFolder | undefined): v is StorageFolder => !!v); } async function listFile(path: string[]) { const result = await esClient .search }>({ index: DEFAULT_INDEX!, sort: [{ pathname: "asc" }], query: { match: { path: path.join("/") + "/", }, }, size: 10000, }) .then((r) => r.hits.hits); const records = result .map((v) => { if (v._source) { const { attachment, ...rest } = v._source; return rest satisfies StorageFile; } }) .filter((v: StorageFile | undefined): v is StorageFile => !!v); return records; } async function checkPathExist(bucket: string, path: string) { return Boolean( await minioClient.statObject(bucket, `${path}/.keep`).catch((e) => { if (e.code === "NotFound") return false; console.error(`Storage Error: ${e}`); throw new Error(MINIO_ERROR_MESSAGE); }), ); } @Route("storage") export class StorageController extends Controller { @Post("list") @Example([ { path: "ตู้เอกสาร 1/ลิ้นชัก 1/แฟ้ม 1", name: "แฟ้ม 1", createdAt: "2021-07-20T12:33:13.018Z", createdBy: "admin", }, { path: "ตู้เอกสาร 1/ลิ้นชัก 1/แฟ้ม 2", name: "แฟ้ม 2", createdAt: "2022-01-23T16:05:02.114Z", createdBy: "admin", }, ]) @Example([ { pathname: "ตู้เอกสาร 1/ลิ้นชัก 1/แฟ้ม 1/เอกสาร 1.pdf", path: "ตู้เอกสาร 1/ลิ้นชัก 1/แฟ้ม 1/", title: "เอกสาร", description: "เอกสารการเงิน", category: ["บัญชี"], keyword: ["เงิน", "บัญชี", "รายจ่าย", "รายรับ"], upload: false, fileName: "เอกสาร 1.pdf", fileSize: 10240, fileType: "application/pdf", createdAt: "2021-07-20T12:33:13.018Z", createdBy: "admin", updatedAt: "2021-07-20T12:33:13.018Z", updatedBy: "admin", }, ]) public async getList(@Body() body: ListRequestBody) { const path = body.path.filter(Boolean); if (body.operation === "folder") return await listFolder(path); if (body.operation === "file") return await listFile(path); } @Post("folder") @SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ") public async postFolder(@Body() body: CreateFolderBody) { const { path, name } = body; if (!(await checkPathExist(DEFAULT_BUCKET, path.join("/")))) { throw new HttpError(HttpStatusCode.NOT_FOUND, PATH_NOT_FOUND_MESSAGE); } const created = await minioClient .putObject( DEFAULT_BUCKET, `${path.join("/")}/${name.replace(/[/\\?%*:|"<>#]/g, "-").trim()}/.keep`, "", 0, { createdAt: new Date().toISOString(), createdBy: "n/a", }, ) .catch((e) => console.error(`MinIO Error: ${e}`)); if (!created) throw new Error(MINIO_ERROR_MESSAGE); return this.setStatus(HttpStatusCode.NO_CONTENT); } /** * ยา้ย Folder ภายใต้ Folder (Path) หนึ่ง ไปภายใน Folder (Path) หนึ่งและสามารถเปลี่ยนชื่อได้ */ @Put("folder") public async moveFolder(@Body() body: PutFolderBody) { const src = `${body.from.path.join("/")}/${body.from.name}`; const dst = `${body.to.path.join("/")}/${body.to.name}`; if (!(await checkPathExist(DEFAULT_BUCKET, src))) { throw new HttpError(HttpStatusCode.NOT_FOUND, PATH_NOT_FOUND_MESSAGE); } if (await checkPathExist(DEFAULT_BUCKET, dst)) { throw new HttpError(HttpStatusCode.CONFLICT, PATH_ALREADY_EXIST); } if (!(await checkPathExist(DEFAULT_BUCKET, body.to.path.join("/")))) { throw new HttpError(HttpStatusCode.PRECONDITION_FAILED, "ไม่พบตำแหน่งที่ต้องการย้าย"); } const list = await new Promise<{ pathname: string }[]>((resolve, reject) => { const stream = minioClient.listObjectsV2(DEFAULT_BUCKET, `${src}/`, true); const item: { pathname: string }[] = []; stream.on("data", (v) => { if (v && v.name) item.push({ pathname: v.name }); }); stream.on("end", () => resolve(item)); stream.on("error", () => reject(new Error(MINIO_ERROR_MESSAGE))); }); await Promise.all( list.map(async (v) => { const from = `/${DEFAULT_BUCKET}/${v.pathname}`; const to = `${dst}/${v.pathname.slice(`${src}/`.length)}`; const result = await minioClient .copyObject(DEFAULT_BUCKET, to, from, copyCond) .catch((e) => { console.error(`MinIO Error: ${e}`); throw new Error("เกิดข้อผิดพลาด ไม่สามารถย้ายไฟล์ได้"); }); if (result) { if (v.pathname.includes(".keep")) { return await minioClient .removeObject(DEFAULT_BUCKET, v.pathname) .catch((e) => console.error(`MinIO Error: ${e}`)); } const search = await esClient.search< StorageFile & { attachment: Record } >({ index: DEFAULT_INDEX!, query: { match: { pathname: v.pathname } }, }); if (search && search.hits.hits.length === 0) { await minioClient .removeObject(DEFAULT_BUCKET, v.pathname) .catch((e) => console.error(`MinIO Error: ${e}`)); return console.log( `Trying to update file in storage but not exist in database: ${from} > ${to}`, ); } const data = search.hits.hits[0]; await esClient .update({ index: DEFAULT_INDEX!, id: data._id, doc: { pathname: to, path: to.split("/").slice(0, -1).join("/") + "/", }, refresh: "wait_for", }) .catch((e) => console.error(`ElasticSearch Error: ${e}`)); await minioClient .removeObject(DEFAULT_BUCKET, v.pathname) .catch((e) => console.error(`MinIO Error: ${e}`)); } }), ); return this.setStatus(HttpStatusCode.NO_CONTENT); } /** * ลบ Folder หรือ File ออกจากระบบ */ @Delete("folder") @SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ") public async deleteStorage(@Body() body: DeleteFolderBody) { await new Promise((resolve, reject) => { const objects: string[] = []; const stream = minioClient.listObjectsV2(DEFAULT_BUCKET, body.path.join("/") + "/", true); stream.on("data", (v) => v && v.name && objects.push(v.name)); stream.on("close", async () => { resolve(await minioClient.removeObjects(DEFAULT_BUCKET, objects)); }); stream.on("error", () => reject(new Error(MINIO_ERROR_MESSAGE))); }); return this.setStatus(HttpStatusCode.NO_CONTENT); } /** * ร้องขอการอัพโหลดไฟล์ โดยเมื่อร้องขอจะได้ URL สำหรับอัพโหลดไฟล์มาพร้อมข้อมูลของไฟล์ที่จะทำการอัพโหลด */ @Post("file") @SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ") public async postFile(@Body() body: CreateFileBody) { const { path, file } = body; if (!(await checkPathExist(DEFAULT_BUCKET, path.join("/")))) { throw new HttpError(HttpStatusCode.NOT_FOUND, PATH_NOT_FOUND_MESSAGE); } const result = await esClient .search }>({ index: DEFAULT_INDEX!, query: { match: { pathname: path.join("/") + "/" } }, }) .catch((e) => console.error(`MinIO Error: ${e}`)); const metadata: StorageFile = { path: path.join("/") + "/", pathname: path.join("/") + "/" + file.replace(/[/\\?%*:|"<>#]/g, "-").trim(), fileName: file.replace(/[/\\?%*:|"<>#]/g, "-").trim(), fileSize: 0, // Will be get by minio object storage after file is uploaded fileType: "", // Will be determined by minio object storage after file is uploaded title: body.title ?? file.replace(/[/\\?%*:|"<>#]/g, "-").trim(), // default to same as filename description: body.description ?? "", category: body.category ?? [], keyword: body.keyword ?? [], upload: false, // flag createdAt: new Date().toISOString(), createdBy: "n/a", updatedAt: new Date().toISOString(), updatedBy: "n/a", }; // Pathname is unique and should not have multiple record with same path if (result && result.hits.hits.length > 0 && result.hits.hits[0]._source) { await esClient .delete({ index: DEFAULT_INDEX!, id: result.hits.hits[0]._id, }) .catch((e) => console.error(`MinIO Error: ${e}`)); const rec = result.hits.hits[0]._source; // File exist get created at and created by field but all other will be replaced // by provided infomation or blank if not provided metadata.createdAt = rec.createdAt; metadata.createdBy = rec.createdBy; } await esClient.index({ index: DEFAULT_INDEX!, document: metadata, refresh: "wait_for", // Must have or else it doesn't wait for updated index resulted in data not found on fetch }); const presignedUrl = await minioClient.presignedPutObject(DEFAULT_BUCKET, metadata.pathname); return { ...metadata, uploadUrl: presignedUrl }; } }