hrms-edm/Services/server/src/controllers/storageController.ts

367 lines
12 KiB
TypeScript
Raw Normal View History

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<StorageFile & { attachment: Record<string, string> }>({
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<string, string> }
>({
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<void>((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<StorageFile & { attachment?: Record<string, unknown> }>({
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 };
}
}