feat: storage endpoint
Work like normal storage system no restrict like cabinet / drawer / folder / subfolder endpoint. Can support as many subfolder as minio support.
This commit is contained in:
parent
156ec2ecd3
commit
54799c5479
3 changed files with 949 additions and 0 deletions
366
Services/server/src/controllers/storageController.ts
Normal file
366
Services/server/src/controllers/storageController.ts
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
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 };
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue