import { Body, Controller, Delete, Example, Post, Put, Request, Route, Security, SuccessResponse, Tags, } 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"; import * as io from "../lib/websocket"; 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[]; hidden?: boolean; } interface FolderBody { /** @example ["แฟ้ม 1", "แฟ้ม 2"] */ path: string[]; /** @example "แฟ้ม 3" */ name: string; } interface PutFolderBody { from: { /** @example ["แฟ้ม 1", "แฟ้ม 2"] */ path: string[]; /** @example "แฟ้ม 3" */ name: string; }; to: { /** @example ["แฟ้ม 1", "แฟ้ม 2"] */ path: string[]; /** @example "แฟ้ม 3 แก้ไข" */ name: string; }; } interface DeleteFolderBody { /** @example ["แฟ้ม 1", "แฟ้ม 2", "แฟ้ม 3 แก้ไข"] */ path: string[]; } interface FileBody { /** @example ["แฟ้ม 1", "แฟ้ม 2", "แฟ้ม 3"] */ path: string[]; /** @example "ไฟล์ 1.xlsx" */ file: string; /** @example "การเงิน" */ title?: string; /** @example "การเงิน" */ description?: string; /** @example ["การเงิน", "รายงาน"] */ category?: string[]; /** @example ["การเงิน", "รายรับ", "รายจ่าย"] */ keyword?: string[]; /** @example false */ hidden?: boolean; } interface PutFileBody extends Omit { /** หากต้องการอัพโหลดไฟล์ด้วยให้ส่งค่าเป็นจริง */ from: { /** @example ["แฟ้ม 1", "แฟ้ม 2", "แฟ้ม 3"] */ path: string[]; /** @example "ไฟล์ 1.xlsx" */ file: string; }; to?: { /** @example ["แฟ้ม 1", "แฟ้ม 2", "แฟ้ม 3"] */ path: string[]; /** @example "ไฟล์ 1 แก้ไข.xlsx" */ file: string; }; /** @example false */ upload?: boolean; } interface DeleteFileBody { /** @example ["แฟ้ม 1", "แฟ้ม 2", "แฟ้ม 3"] */ path: string[]; /** @example "ไฟล์ 1 แก้ไข.xlsx" */ file: string; } interface DownloadFileBody { /** @example ["แฟ้ม 1", "แฟ้ม 2", "แฟ้ม 3"] */ path: string[]; /** @example "ไฟล์ 1 แก้ไข.xlsx" */ file: string; } async function folderSize(path: string[]) { const size = await new Promise((resolve, reject) => { const stream = minioClient.listObjectsV2( DEFAULT_BUCKET, path.length === 0 ? "" : path.join("/") + "/", true, ); let total: number = 0; stream.on("data", (v) => { if (v && v.size) total += v.size; }); stream.on("end", () => resolve(total)); stream.on("error", () => reject(new Error("เกิดข้อผิดพลาดกับระบบจัดการไฟล์"))); }); return size; } 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.length === 0 ? "" : 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) => { if (v.name.startsWith(".") && !hidden) return undefined; // 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[], hidden: boolean = false) { const result = await esClient .search }>({ index: DEFAULT_INDEX, sort: [{ pathname: "asc" }], query: { bool: { must: { match: { path: path.join("/") + "/" } }, must_not: !hidden ? { match: { hidden: true } } : undefined, }, }, 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) { if (path.split("/").filter(Boolean).length === 0) return true; // root does not contain any mark return await checkFileExist(bucket, `${path}/.keep`); } async function checkFileExist(bucket: string, pathname: string) { return Boolean( await minioClient.statObject(bucket, pathname).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", }, ]) @Tags("Storage Folder", "Storage File") @Security("bearerAuth") public async getList(@Body() body: ListRequestBody) { const path = body.path.filter(Boolean); if (body.operation === "folder") return await listFolder(path, body.hidden); if (body.operation === "file") return await listFile(path); } @Post("folder") @Tags("Storage Folder") @Security("bearerAuth", ["management-role", "admin"]) @SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ") public async postFolder( @Request() request: { user: { preferred_username: string } }, @Body() body: FolderBody, ) { const { path, name } = body; if (!(await checkPathExist(DEFAULT_BUCKET, path.join("/")))) { throw new HttpError(HttpStatusCode.NOT_FOUND, PATH_NOT_FOUND_MESSAGE); } const meta = { createdAt: new Date().toISOString(), createdBy: request.user.preferred_username, }; const created = await minioClient .putObject( DEFAULT_BUCKET, `${path.join("/")}/${name.replace(/[/\\?%*:|"<>#]/g, "-").trim()}/.keep`, "", 0, meta, ) .catch((e) => console.error(`MinIO Error: ${e}`)); if (!created) throw new Error(MINIO_ERROR_MESSAGE); io.getInstance()?.emit("FolderCreate", { pathname: `${path.join("/")}/${name.replace(/[/\\?%*:|"<>#]/g, "-").trim()}/`, name: name.replace(/[/\\?%*:|"<>#]/g, "-").trim(), ...meta, }); return this.setStatus(HttpStatusCode.NO_CONTENT); } /** * ยา้ย Folder ภายใต้ Folder (Path) หนึ่ง ไปภายใน Folder (Path) หนึ่งและสามารถเปลี่ยนชื่อได้ */ @Put("folder") @Tags("Storage Folder") @Security("bearerAuth", ["management-role", "admin"]) @SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ") 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}`)); } }), ); io.getInstance()?.emit("FolderMove", { from: `${src}/`, to: `${dst}/`, }); return this.setStatus(HttpStatusCode.NO_CONTENT); } @Post("folder/size") @Tags("Storage Folder") @Security("bearerAuth", ["management-role", "admin"]) public async folderSize(@Body() body: FolderBody) { return { size: await folderSize([...body.path, body.name]) }; } /** * ลบ Folder ออกจากระบบ */ @Delete("folder") @Tags("Storage Folder") @Security("bearerAuth", ["management-role", "admin"]) @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))); }); io.getInstance()?.emit("FolderDelete", { pathname: body.path.join("/") + "/" }); return this.setStatus(HttpStatusCode.NO_CONTENT); } /** * ร้องขอการอัพโหลดไฟล์ โดยเมื่อร้องขอจะได้ URL สำหรับอัพโหลดไฟล์มาพร้อมข้อมูลของไฟล์ที่จะทำการอัพโหลด */ @Post("file") @Tags("Storage File") @Security("bearerAuth", ["management-role", "admin"]) @SuccessResponse(HttpStatusCode.OK, "สำเร็จ") public async postFile( @Request() request: { user: { preferred_username: string } }, @Body() body: FileBody, ) { 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 hidden: body.hidden ?? false, createdAt: new Date().toISOString(), createdBy: request.user.preferred_username, updatedAt: new Date().toISOString(), updatedBy: request.user.preferred_username, }; // 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 }); io.getInstance()?.emit("FileUploadRequest", metadata); const presignedUrl = await minioClient.presignedPutObject(DEFAULT_BUCKET, metadata.pathname); return { ...metadata, uploadUrl: presignedUrl }; } @Put("file") @Tags("Storage File") @Security("bearerAuth", ["management-role", "admin"]) @SuccessResponse(HttpStatusCode.OK, "สำเร็จ") public async moveFile( @Request() request: { user: { preferred_username: string } }, @Body() body: PutFileBody, ) { const search = await esClient .search }>({ index: DEFAULT_INDEX, query: { match: { pathname: body.from.path.join("/") + `/${body.from.file}` }, }, }) .catch((e) => console.error(`ElasticSearch Error: ${e}`)); if (!search) { throw new Error("เกิดข้อผิดพลาดกับระบบฐานข้อมูล กรุณาลองใหม่ในภายหลัง"); } if (search && search.hits.hits.length === 0) { throw new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบไฟล์ดังกล่าว"); } if (!(await checkFileExist(DEFAULT_BUCKET, body.from.path.join("/") + `/${body.from.file}`))) { await esClient.delete({ index: DEFAULT_INDEX, id: search.hits.hits[0]._id, }); throw new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบไฟล์ดังกล่าว"); } if (body.to && JSON.stringify(body.from) !== JSON.stringify(body.to)) { if (!(await checkPathExist(DEFAULT_BUCKET, body.to.path.join("/")))) { throw new HttpError(HttpStatusCode.PRECONDITION_FAILED, "ไม่พบตำแหน่งที่ต้องการย้าย"); } if (await checkFileExist(DEFAULT_BUCKET, body.to.path.join("/") + `/${body.to.file}`)) { throw new HttpError( HttpStatusCode.PRECONDITION_FAILED, "พบไฟล์ในต้ำแหน่งปลายทาง ไม่สามารถย้ายได้", ); } } if (!search.hits.hits[0]._source) { // This should not possible. // Just in case the result found with no associated data. await esClient.delete({ index: DEFAULT_INDEX, id: search.hits.hits[0]._id, }); throw new Error("ไม่พบข้อมูลในฐานข้อมูล"); } const id = search.hits.hits[0]._id; const { attachment: _, ...source } = search.hits.hits[0]._source; const { to, from, upload, ...metadata } = body; const dateMeta = { updatedAt: new Date().toISOString(), updatedBy: request.user.preferred_username, }; if (from && to && JSON.stringify(from) !== JSON.stringify(to)) { const src = [DEFAULT_BUCKET, ...from.path, ""].join("/") + from.file; const dst = to.path.join("/") + `/${to.file}`; const result = await minioClient.copyObject(DEFAULT_BUCKET, dst, src, copyCond).catch((e) => { console.error(`MinIO Error: ${e}`); throw new Error("เกิดข้อผิดพลาด ไม่สามารถย้ายไฟล์ได้"); }); if (result) { await esClient .update({ index: DEFAULT_INDEX, id: id, doc: { ...metadata, path: to.path.join("/") + "/", pathname: dst, fileName: to.file, ...dateMeta, }, refresh: "wait_for", }) .then( async () => await minioClient.removeObject(DEFAULT_BUCKET, from.path.join("/") + `/${from.file}`), ) .catch((e) => console.error(`ElasticSearch Error: ${e}`)); io.getInstance()?.emit("FileMove", { from: source, to: { ...source, ...metadata, path: to.path.join("/") + "/", pathname: dst, fileName: to.file, ...dateMeta, }, }); if (upload) { const presignedUrl = await minioClient.presignedPutObject(DEFAULT_BUCKET, dst); return { uploadUrl: presignedUrl }; } return this.setStatus(HttpStatusCode.NO_CONTENT); } } if (from) { await esClient .update({ index: DEFAULT_INDEX, id: id, doc: { ...metadata, ...dateMeta, }, refresh: "wait_for", }) .catch((e) => console.error(`ElasticSearch Error: ${e}`)); io.getInstance()?.emit("FileMove", { from: source, to: { ...source, ...metadata, ...dateMeta, }, }); if (upload) { const src = from.path.join("/") + `/${from.file}`; const presignedUrl = await minioClient.presignedPutObject(DEFAULT_BUCKET, src); return { uploadUrl: presignedUrl }; } } return this.setStatus(HttpStatusCode.NO_CONTENT); } /** * ลบ File ออกจากระบบ */ @Delete("file") @Tags("Storage File") @Security("bearerAuth", ["management-role", "admin"]) @SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ") public async deleteFile(@Body() body: DeleteFileBody) { const pathname = body.path.join("/") + `/${body.file}`; await minioClient .removeObject(DEFAULT_BUCKET, pathname) .catch((e) => console.error(`MinIO Error: ${e}`)); await esClient .deleteByQuery({ index: DEFAULT_INDEX, query: { match: { pathname } }, }) .catch((e) => console.error(`ElasticSearch Error: ${e}`)); io.getInstance()?.emit("FileDelete", { pathname }); return this.setStatus(HttpStatusCode.NO_CONTENT); } @Post("file/download") @Tags("Download") @Security("bearerAuth", ["management-role", "admin"]) @SuccessResponse(HttpStatusCode.OK, "สำเร็จ") public async downloadFile(@Body() body: DownloadFileBody) { const pathname = body.path.join("/") + `/${body.file}`; const search = await esClient.search }>({ index: DEFAULT_INDEX, query: { match: { pathname }, }, }); if (search && search.hits.hits.length === 0) { throw new HttpError(HttpStatusCode.NOT_FOUND, "ไม่พบไฟล์"); } const { attachment, ...rest } = search.hits.hits[0]._source!; return { ...rest, downloadUrl: await minioClient.presignedGetObject(DEFAULT_BUCKET, pathname), }; } }