From 54799c5479676707306880401bf8b8e766c5fddc Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Fri, 8 Dec 2023 17:45:55 +0700 Subject: [PATCH 1/7] feat: storage endpoint Work like normal storage system no restrict like cabinet / drawer / folder / subfolder endpoint. Can support as many subfolder as minio support. --- .../src/controllers/storageController.ts | 366 ++++++++++++++++ Services/server/src/routes.ts | 175 ++++++++ Services/server/src/swagger.json | 408 ++++++++++++++++++ 3 files changed, 949 insertions(+) create mode 100644 Services/server/src/controllers/storageController.ts diff --git a/Services/server/src/controllers/storageController.ts b/Services/server/src/controllers/storageController.ts new file mode 100644 index 0000000..1b15615 --- /dev/null +++ b/Services/server/src/controllers/storageController.ts @@ -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 }>({ + 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 }; + } +} diff --git a/Services/server/src/routes.ts b/Services/server/src/routes.ts index 7b3897c..ba2ba9e 100644 --- a/Services/server/src/routes.ts +++ b/Services/server/src/routes.ts @@ -13,6 +13,8 @@ import { FolderController } from './controllers/folderController'; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import { SearchController } from './controllers/searchController'; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa +import { StorageController } from './controllers/storageController'; +// WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import { SubFolderController } from './controllers/subFolderController'; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa import { SubFolderFileController } from './controllers/subFolderFileController'; @@ -65,6 +67,54 @@ const models: TsoaRoute.Models = { "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "ListRequestBody": { + "dataType": "refObject", + "properties": { + "operation": {"dataType":"union","subSchemas":[{"dataType":"enum","enums":["folder"]},{"dataType":"enum","enums":["file"]}],"required":true}, + "path": {"dataType":"array","array":{"dataType":"string"},"required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "CreateFolderBody": { + "dataType": "refObject", + "properties": { + "path": {"dataType":"array","array":{"dataType":"string"},"required":true}, + "name": {"dataType":"string","required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "PutFolderBody": { + "dataType": "refObject", + "properties": { + "from": {"dataType":"nestedObjectLiteral","nestedProperties":{"name":{"dataType":"string","required":true},"path":{"dataType":"array","array":{"dataType":"string"},"required":true}},"required":true}, + "to": {"dataType":"nestedObjectLiteral","nestedProperties":{"name":{"dataType":"string","required":true},"path":{"dataType":"array","array":{"dataType":"string"},"required":true}},"required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "DeleteFolderBody": { + "dataType": "refObject", + "properties": { + "path": {"dataType":"array","array":{"dataType":"string"},"required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "CreateFileBody": { + "dataType": "refObject", + "properties": { + "path": {"dataType":"array","array":{"dataType":"string"},"required":true}, + "file": {"dataType":"string","required":true}, + "title": {"dataType":"string"}, + "description": {"dataType":"string"}, + "category": {"dataType":"array","array":{"dataType":"string"}}, + "keyword": {"dataType":"array","array":{"dataType":"string"}}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa }; const validationService = new ValidationService(models); @@ -656,6 +706,131 @@ export function RegisterRoutes(app: Router) { } }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.post('/storage/list', + ...(fetchMiddlewares(StorageController)), + ...(fetchMiddlewares(StorageController.prototype.getList)), + + function StorageController_getList(request: any, response: any, next: any) { + const args = { + body: {"in":"body","name":"body","required":true,"ref":"ListRequestBody"}, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = getValidatedArgs(args, request, response); + + const controller = new StorageController(); + + + const promise = controller.getList.apply(controller, validatedArgs as any); + promiseHandler(controller, promise, response, undefined, next); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.post('/storage/folder', + ...(fetchMiddlewares(StorageController)), + ...(fetchMiddlewares(StorageController.prototype.postFolder)), + + function StorageController_postFolder(request: any, response: any, next: any) { + const args = { + body: {"in":"body","name":"body","required":true,"ref":"CreateFolderBody"}, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = getValidatedArgs(args, request, response); + + const controller = new StorageController(); + + + const promise = controller.postFolder.apply(controller, validatedArgs as any); + promiseHandler(controller, promise, response, 204, next); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.put('/storage/folder', + ...(fetchMiddlewares(StorageController)), + ...(fetchMiddlewares(StorageController.prototype.moveFolder)), + + function StorageController_moveFolder(request: any, response: any, next: any) { + const args = { + body: {"in":"body","name":"body","required":true,"ref":"PutFolderBody"}, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = getValidatedArgs(args, request, response); + + const controller = new StorageController(); + + + const promise = controller.moveFolder.apply(controller, validatedArgs as any); + promiseHandler(controller, promise, response, undefined, next); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.delete('/storage/folder', + ...(fetchMiddlewares(StorageController)), + ...(fetchMiddlewares(StorageController.prototype.deleteStorage)), + + function StorageController_deleteStorage(request: any, response: any, next: any) { + const args = { + body: {"in":"body","name":"body","required":true,"ref":"DeleteFolderBody"}, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = getValidatedArgs(args, request, response); + + const controller = new StorageController(); + + + const promise = controller.deleteStorage.apply(controller, validatedArgs as any); + promiseHandler(controller, promise, response, 204, next); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.post('/storage/file', + ...(fetchMiddlewares(StorageController)), + ...(fetchMiddlewares(StorageController.prototype.postFile)), + + function StorageController_postFile(request: any, response: any, next: any) { + const args = { + body: {"in":"body","name":"body","required":true,"ref":"CreateFileBody"}, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = getValidatedArgs(args, request, response); + + const controller = new StorageController(); + + + const promise = controller.postFile.apply(controller, validatedArgs as any); + promiseHandler(controller, promise, response, 204, next); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.get('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/subfolder', authenticateMiddleware([{"bearerAuth":[]}]), ...(fetchMiddlewares(SubFolderController)), diff --git a/Services/server/src/swagger.json b/Services/server/src/swagger.json index 7dd5811..404bc9e 100644 --- a/Services/server/src/swagger.json +++ b/Services/server/src/swagger.json @@ -174,6 +174,146 @@ }, "type": "object", "additionalProperties": false + }, + "ListRequestBody": { + "properties": { + "operation": { + "type": "string", + "enum": [ + "folder", + "file" + ] + }, + "path": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "operation", + "path" + ], + "type": "object", + "additionalProperties": false + }, + "CreateFolderBody": { + "properties": { + "path": { + "items": { + "type": "string" + }, + "type": "array" + }, + "name": { + "type": "string" + } + }, + "required": [ + "path", + "name" + ], + "type": "object", + "additionalProperties": false + }, + "PutFolderBody": { + "properties": { + "from": { + "properties": { + "name": { + "type": "string" + }, + "path": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "name", + "path" + ], + "type": "object" + }, + "to": { + "properties": { + "name": { + "type": "string" + }, + "path": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "name", + "path" + ], + "type": "object" + } + }, + "required": [ + "from", + "to" + ], + "type": "object", + "additionalProperties": false + }, + "DeleteFolderBody": { + "properties": { + "path": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "path" + ], + "type": "object", + "additionalProperties": false + }, + "CreateFileBody": { + "properties": { + "path": { + "items": { + "type": "string" + }, + "type": "array" + }, + "file": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "category": { + "items": { + "type": "string" + }, + "type": "array" + }, + "keyword": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "path", + "file" + ], + "type": "object", + "additionalProperties": false } }, "securitySchemes": { @@ -1662,6 +1802,274 @@ } } }, + "/storage/list": { + "post": { + "operationId": "GetList", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/StorageFolder" + }, + "type": "array" + }, + { + "items": { + "$ref": "#/components/schemas/StorageFile" + }, + "type": "array" + } + ] + }, + "examples": { + "Example 1": { + "value": [ + { + "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 2": { + "value": [ + { + "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" + } + ] + } + } + } + } + } + }, + "security": [], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListRequestBody" + } + } + } + } + } + }, + "/storage/folder": { + "post": { + "operationId": "PostFolder", + "responses": { + "204": { + "description": "สำเร็จ" + } + }, + "security": [], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateFolderBody" + } + } + } + } + }, + "put": { + "operationId": "MoveFolder", + "responses": { + "204": { + "description": "No content" + } + }, + "description": "ยา้ย Folder ภายใต้ Folder (Path) หนึ่ง ไปภายใน Folder (Path) หนึ่งและสามารถเปลี่ยนชื่อได้", + "security": [], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PutFolderBody" + } + } + } + } + }, + "delete": { + "operationId": "DeleteStorage", + "responses": { + "204": { + "description": "สำเร็จ" + } + }, + "description": "ลบ Folder หรือ File ออกจากระบบ", + "security": [], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteFolderBody" + } + } + } + } + } + }, + "/storage/file": { + "post": { + "operationId": "PostFile", + "responses": { + "204": { + "description": "สำเร็จ", + "content": { + "application/json": { + "schema": { + "properties": { + "createdBy": { + "type": "string" + }, + "createdAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "updatedBy": { + "type": "string" + }, + "updatedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "upload": { + "type": "boolean" + }, + "path": { + "type": "string" + }, + "keyword": { + "items": { + "type": "string" + }, + "type": "array" + }, + "category": { + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "fileType": { + "type": "string" + }, + "fileSize": { + "type": "number", + "format": "double" + }, + "fileName": { + "type": "string" + }, + "pathname": { + "type": "string" + }, + "uploadUrl": { + "type": "string" + } + }, + "required": [ + "createdBy", + "createdAt", + "updatedBy", + "updatedAt", + "upload", + "path", + "keyword", + "category", + "description", + "title", + "fileType", + "fileSize", + "fileName", + "pathname", + "uploadUrl" + ], + "type": "object" + } + } + } + } + }, + "description": "ร้องขอการอัพโหลดไฟล์ โดยเมื่อร้องขอจะได้ URL สำหรับอัพโหลดไฟล์มาพร้อมข้อมูลของไฟล์ที่จะทำการอัพโหลด", + "security": [], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateFileBody" + } + } + } + } + } + }, "/cabinet/{cabinetName}/drawer/{drawerName}/folder/{folderName}/subfolder": { "get": { "operationId": "ListFolder", From a401428fa395956e7b7747629189dd1682c2907a Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Mon, 11 Dec 2023 20:34:41 +0700 Subject: [PATCH 2/7] chore: prevent accident auth bypass in production --- Services/server/src/utils/auth.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Services/server/src/utils/auth.ts b/Services/server/src/utils/auth.ts index b7ae6cd..0c0835a 100644 --- a/Services/server/src/utils/auth.ts +++ b/Services/server/src/utils/auth.ts @@ -28,7 +28,9 @@ export async function expressAuthentication( securityName: string, scopes?: string[], ) { - if (process.env.AUTH_BYPASS) return { preferred_username: "bypassed" }; + if (process.env.NODE_ENV !== "production" && process.env.AUTH_BYPASS) { + return { preferred_username: "bypassed" }; + } if (securityName !== "bearerAuth") throw new Error("Unknown authentication method."); @@ -36,7 +38,7 @@ export async function expressAuthentication( ? request.headers["authorization"].split(" ")[1] : null; - if (!token) throw new HttpError(HttpStatusCode.UNAUTHORIZED, "No token provided."); + if (!token) throw new HttpError(HttpStatusCode.UNAUTHORIZED, "ไม่พบข้อมูลสำหัรบบืนบันตัวตน"); let payload: JwtPayload = {}; @@ -55,12 +57,11 @@ export async function expressAuthentication( if ( scopes && - scopes.length > 0 && scopes .map((v) => (v === "management-role" ? process.env.MANAGEMENT_ROLE : v)) .every((v) => !payload.resource_access[payload.azp].roles.includes(v)) ) { - throw new HttpError(HttpStatusCode.FORBIDDEN, "You are not allowed to perform this action."); + throw new HttpError(HttpStatusCode.FORBIDDEN, "คุณไม่มีสิทธิในเข้าถึงข้อมูลนี้"); } return payload; @@ -68,7 +69,7 @@ export async function expressAuthentication( async function verifyOffline(token: string) { const payload = await jwtVerify(token).catch((_) => null); - if (!payload) throw new HttpError(HttpStatusCode.UNAUTHORIZED, "Invalid token provided."); + if (!payload) throw new HttpError(HttpStatusCode.UNAUTHORIZED, "ไม่สามารถยืนยันตัวตนได้"); return payload; } @@ -77,8 +78,8 @@ async function verifyOnline(token: string) { headers: { authorization: `Bearer ${token}` }, }).catch((e) => console.error(e)); - if (!res) throw new Error("Cannot connect to auth service."); - if (!res.ok) throw new HttpError(HttpStatusCode.UNAUTHORIZED, "Invalid token provided."); + if (!res) throw new Error("ไม่สามารถเข้าถึงระบบยืนยันตัวตน"); + if (!res.ok) throw new HttpError(HttpStatusCode.UNAUTHORIZED, "ไม่สามารถยืนยันตัวตนได้"); return await jwtDecode(token); } From 23e072986d65e50b466fc2ec28bcf97f19cd7178 Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Mon, 11 Dec 2023 20:47:31 +0700 Subject: [PATCH 3/7] update: .env example --- Services/server/.env.example | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/Services/server/.env.example b/Services/server/.env.example index 7dc2098..4fdcee0 100644 --- a/Services/server/.env.example +++ b/Services/server/.env.example @@ -1,15 +1,25 @@ -PUBLIC_KEY= - -MINIO_HOST=localhost -MINIO_PORT=9000 +# Keycloak public key +PUBLIC_KEY=keycloak.public.key +REALM_URL=https://keycloak.local/realms/EDM +PREFERRED_AUTH=online +MANAGEMENT_ROLE=doc-management +# App port +PORT=25570 +# Real host name must be used. +# Sdk will generate presigned url based on host name. +MINIO_HOST=minio.local +MINIO_PORT=443 +MINIO_SSL=true MINIO_ACCESS_KEY= MINIO_SECRET_KEY= - +# Bucket notification event needed to be configured +# Can use prepare script to create bucket +MINIO_BUCKET=dev ELASTICSEARCH_PROTOCOL=http ELASTICSEARCH_HOST=localhost -ELASTICSEARCH_PORT=9200 +ELASTICSEARCH_PORT=3001 +# Can use prepare script +ELASTICSEARCH_INDEX=dev-index +AMQ_URL=amqp://admin:1234@localhost:3002 +AMQ_QUEUE=queue-name -AMQ_URL=amqp://admin:1234@localhost:9999 -AMQ_QUEUE=queue - -AUTH_BYPASS=false # MUST NOT TURN THIS ON IN PRODUCTION From 8fbeda892bf42041252c06f322a7eaac48306119 Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Tue, 12 Dec 2023 09:39:39 +0700 Subject: [PATCH 4/7] fix: must have length or will fail --- Services/server/src/utils/auth.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/Services/server/src/utils/auth.ts b/Services/server/src/utils/auth.ts index 0c0835a..0f56597 100644 --- a/Services/server/src/utils/auth.ts +++ b/Services/server/src/utils/auth.ts @@ -57,6 +57,7 @@ export async function expressAuthentication( if ( scopes && + scopes.length > 0 && scopes .map((v) => (v === "management-role" ? process.env.MANAGEMENT_ROLE : v)) .every((v) => !payload.resource_access[payload.azp].roles.includes(v)) From 07d97a9091028d1b1bcbce15f85e32130bc0c1fd Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Tue, 12 Dec 2023 12:01:56 +0700 Subject: [PATCH 5/7] feat: file move and delete endpoint --- Services/server/src/app.ts | 12 +- .../src/controllers/storageController.ts | 198 ++++++++++++- Services/server/src/routes.ts | 83 +++++- Services/server/src/swagger.json | 271 ++++++++++++++++-- 4 files changed, 520 insertions(+), 44 deletions(-) diff --git a/Services/server/src/app.ts b/Services/server/src/app.ts index fdff76d..d735caa 100644 --- a/Services/server/src/app.ts +++ b/Services/server/src/app.ts @@ -42,17 +42,11 @@ const io = new Server(server, { }); setInstance(io); - -io.on("connection", (socket) => { - console.log("User Connected"); - - socket.on("disconnected", () => { - console.log("User Disconnected"); - }); -}); +io.on("connection", () => console.log("[Socket.IO] User connected.")); +io.on("disconnected", () => console.log("[Socket.IO] User disconnected.")); server.listen(PORT, "0.0.0.0", () => console.log(`[APP] Application is running on http://localhost:${PORT}`), ); -rabbitmq.init(amqHandler).catch((e) => console.error(e)); +// rabbitmq.init(amqHandler).catch((e) => console.error(e)); diff --git a/Services/server/src/controllers/storageController.ts b/Services/server/src/controllers/storageController.ts index 1b15615..cb6866e 100644 --- a/Services/server/src/controllers/storageController.ts +++ b/Services/server/src/controllers/storageController.ts @@ -1,9 +1,12 @@ -import { Body, Controller, Delete, Example, Post, Put, Route, SuccessResponse } from "tsoa"; +import { Body, Controller, Delete, Example, Post, Put, Route, 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"; if (!process.env.MINIO_BUCKET) throw Error("Default MinIO bucket must be specified."); @@ -20,35 +23,73 @@ interface ListRequestBody { path: string[]; } -interface CreateFolderBody { +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 CreateFileBody { +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[]; } +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; +} + async function listFolder(path: string[]) { const list = await new Promise<{ pathname: string; name: string }[]>((resolve, reject) => { const item: { pathname: string; name: string }[] = []; @@ -90,13 +131,9 @@ async function listFolder(path: string[]) { async function listFile(path: string[]) { const result = await esClient .search }>({ - index: DEFAULT_INDEX!, + index: DEFAULT_INDEX, sort: [{ pathname: "asc" }], - query: { - match: { - path: path.join("/") + "/", - }, - }, + query: { match: { path: path.join("/") + "/" } }, size: 10000, }) .then((r) => r.hits.hits); @@ -114,8 +151,12 @@ async function listFile(path: string[]) { } async function checkPathExist(bucket: string, path: string) { + return await checkFileExist(bucket, `${path}/.keep`); +} + +async function checkFileExist(bucket: string, pathname: string) { return Boolean( - await minioClient.statObject(bucket, `${path}/.keep`).catch((e) => { + await minioClient.statObject(bucket, pathname).catch((e) => { if (e.code === "NotFound") return false; console.error(`Storage Error: ${e}`); throw new Error(MINIO_ERROR_MESSAGE); @@ -158,6 +199,7 @@ export class StorageController extends Controller { updatedBy: "admin", }, ]) + @Tags("Storage Folder", "Storage File") public async getList(@Body() body: ListRequestBody) { const path = body.path.filter(Boolean); @@ -166,8 +208,9 @@ export class StorageController extends Controller { } @Post("folder") + @Tags("Storage Folder") @SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ") - public async postFolder(@Body() body: CreateFolderBody) { + public async postFolder(@Body() body: FolderBody) { const { path, name } = body; if (!(await checkPathExist(DEFAULT_BUCKET, path.join("/")))) { @@ -196,6 +239,8 @@ export class StorageController extends Controller { * ยา้ย Folder ภายใต้ Folder (Path) หนึ่ง ไปภายใน Folder (Path) หนึ่งและสามารถเปลี่ยนชื่อได้ */ @Put("folder") + @Tags("Storage Folder") + @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}`; @@ -284,6 +329,7 @@ export class StorageController extends Controller { * ลบ Folder หรือ File ออกจากระบบ */ @Delete("folder") + @Tags("Storage Folder") @SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ") public async deleteStorage(@Body() body: DeleteFolderBody) { await new Promise((resolve, reject) => { @@ -304,8 +350,9 @@ export class StorageController extends Controller { * ร้องขอการอัพโหลดไฟล์ โดยเมื่อร้องขอจะได้ URL สำหรับอัพโหลดไฟล์มาพร้อมข้อมูลของไฟล์ที่จะทำการอัพโหลด */ @Post("file") + @Tags("Storage File") @SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ") - public async postFile(@Body() body: CreateFileBody) { + public async postFile(@Body() body: FileBody) { const { path, file } = body; if (!(await checkPathExist(DEFAULT_BUCKET, path.join("/")))) { @@ -363,4 +410,131 @@ export class StorageController extends Controller { return { ...metadata, uploadUrl: presignedUrl }; } + + @Put("file") + @Tags("Storage File") + @SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ") + public async moveFile(@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 && !(await checkPathExist(DEFAULT_BUCKET, body.to.path.join("/")))) { + throw new HttpError(HttpStatusCode.PRECONDITION_FAILED, "ไม่พบตำแหน่งที่ต้องการย้าย"); + } + if ( + body.to && + (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 { to, from, upload, ...metadata } = body; + + if (from && 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, + updatedAt: new Date().toISOString(), + updatedBy: "n/a", + }, + refresh: "wait_for", + }) + .then(async () => await minioClient.removeObject(DEFAULT_INDEX, src)) + .catch((e) => console.error(`ElasticSearch Error: ${e}`)); + + if (upload) { + const presignedUrl = await minioClient.presignedPutObject(DEFAULT_BUCKET, dst); + return { uploadUrl: presignedUrl }; + } + } + } + + if (from) { + await esClient + .update({ + index: DEFAULT_INDEX, + id: id, + doc: { + ...metadata, + updatedAt: new Date().toISOString(), + updatedBy: "n/a", + }, + refresh: "wait_for", + }) + .catch((e) => console.error(`ElasticSearch Error: ${e}`)); + 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); + } + + @Delete("file") + @Tags("Storage File") + @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}`)); + + return this.setStatus(HttpStatusCode.NO_CONTENT); + } } diff --git a/Services/server/src/routes.ts b/Services/server/src/routes.ts index ba2ba9e..90cab3b 100644 --- a/Services/server/src/routes.ts +++ b/Services/server/src/routes.ts @@ -76,7 +76,7 @@ const models: TsoaRoute.Models = { "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "CreateFolderBody": { + "FolderBody": { "dataType": "refObject", "properties": { "path": {"dataType":"array","array":{"dataType":"string"},"required":true}, @@ -102,7 +102,7 @@ const models: TsoaRoute.Models = { "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "CreateFileBody": { + "FileBody": { "dataType": "refObject", "properties": { "path": {"dataType":"array","array":{"dataType":"string"},"required":true}, @@ -115,6 +115,29 @@ const models: TsoaRoute.Models = { "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "PutFileBody": { + "dataType": "refObject", + "properties": { + "title": {"dataType":"string"}, + "description": {"dataType":"string"}, + "category": {"dataType":"array","array":{"dataType":"string"}}, + "keyword": {"dataType":"array","array":{"dataType":"string"}}, + "from": {"dataType":"nestedObjectLiteral","nestedProperties":{"file":{"dataType":"string","required":true},"path":{"dataType":"array","array":{"dataType":"string"},"required":true}},"required":true}, + "to": {"dataType":"nestedObjectLiteral","nestedProperties":{"file":{"dataType":"string","required":true},"path":{"dataType":"array","array":{"dataType":"string"},"required":true}}}, + "upload": {"dataType":"boolean"}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "DeleteFileBody": { + "dataType": "refObject", + "properties": { + "path": {"dataType":"array","array":{"dataType":"string"},"required":true}, + "file": {"dataType":"string","required":true}, + }, + "additionalProperties": false, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa }; const validationService = new ValidationService(models); @@ -737,7 +760,7 @@ export function RegisterRoutes(app: Router) { function StorageController_postFolder(request: any, response: any, next: any) { const args = { - body: {"in":"body","name":"body","required":true,"ref":"CreateFolderBody"}, + body: {"in":"body","name":"body","required":true,"ref":"FolderBody"}, }; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa @@ -775,7 +798,7 @@ export function RegisterRoutes(app: Router) { const promise = controller.moveFolder.apply(controller, validatedArgs as any); - promiseHandler(controller, promise, response, undefined, next); + promiseHandler(controller, promise, response, 204, next); } catch (err) { return next(err); } @@ -812,7 +835,7 @@ export function RegisterRoutes(app: Router) { function StorageController_postFile(request: any, response: any, next: any) { const args = { - body: {"in":"body","name":"body","required":true,"ref":"CreateFileBody"}, + body: {"in":"body","name":"body","required":true,"ref":"FileBody"}, }; // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa @@ -831,6 +854,56 @@ export function RegisterRoutes(app: Router) { } }); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.put('/storage/file', + ...(fetchMiddlewares(StorageController)), + ...(fetchMiddlewares(StorageController.prototype.moveFile)), + + function StorageController_moveFile(request: any, response: any, next: any) { + const args = { + body: {"in":"body","name":"body","required":true,"ref":"PutFileBody"}, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = getValidatedArgs(args, request, response); + + const controller = new StorageController(); + + + const promise = controller.moveFile.apply(controller, validatedArgs as any); + promiseHandler(controller, promise, response, 204, next); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + app.delete('/storage/file', + ...(fetchMiddlewares(StorageController)), + ...(fetchMiddlewares(StorageController.prototype.deleteFile)), + + function StorageController_deleteFile(request: any, response: any, next: any) { + const args = { + body: {"in":"body","name":"body","required":true,"ref":"DeleteFileBody"}, + }; + + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + + let validatedArgs: any[] = []; + try { + validatedArgs = getValidatedArgs(args, request, response); + + const controller = new StorageController(); + + + const promise = controller.deleteFile.apply(controller, validatedArgs as any); + promiseHandler(controller, promise, response, 204, next); + } catch (err) { + return next(err); + } + }); + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa app.get('/cabinet/:cabinetName/drawer/:drawerName/folder/:folderName/subfolder', authenticateMiddleware([{"bearerAuth":[]}]), ...(fetchMiddlewares(SubFolderController)), diff --git a/Services/server/src/swagger.json b/Services/server/src/swagger.json index 404bc9e..75665c8 100644 --- a/Services/server/src/swagger.json +++ b/Services/server/src/swagger.json @@ -198,16 +198,21 @@ "type": "object", "additionalProperties": false }, - "CreateFolderBody": { + "FolderBody": { "properties": { "path": { "items": { "type": "string" }, - "type": "array" + "type": "array", + "example": [ + "แฟ้ม 1", + "แฟ้ม 2" + ] }, "name": { - "type": "string" + "type": "string", + "example": "แฟ้ม 3" } }, "required": [ @@ -222,13 +227,18 @@ "from": { "properties": { "name": { - "type": "string" + "type": "string", + "example": "แฟ้ม 3" }, "path": { "items": { "type": "string" }, - "type": "array" + "type": "array", + "example": [ + "แฟ้ม 1", + "แฟ้ม 2" + ] } }, "required": [ @@ -240,13 +250,18 @@ "to": { "properties": { "name": { - "type": "string" + "type": "string", + "example": "แฟ้ม 3 แก้ไข" }, "path": { "items": { "type": "string" }, - "type": "array" + "type": "array", + "example": [ + "แฟ้ม 1", + "แฟ้ม 2" + ] } }, "required": [ @@ -269,7 +284,12 @@ "items": { "type": "string" }, - "type": "array" + "type": "array", + "example": [ + "แฟ้ม 1", + "แฟ้ม 2", + "แฟ้ม 3 แก้ไข" + ] } }, "required": [ @@ -278,34 +298,167 @@ "type": "object", "additionalProperties": false }, - "CreateFileBody": { + "FileBody": { "properties": { "path": { "items": { "type": "string" }, - "type": "array" + "type": "array", + "example": [ + "แฟ้ม 1", + "แฟ้ม 2", + "แฟ้ม 3" + ] }, "file": { - "type": "string" + "type": "string", + "example": "ไฟล์ 1.xlsx" }, "title": { - "type": "string" + "type": "string", + "example": "การเงิน" }, "description": { - "type": "string" + "type": "string", + "example": "การเงิน" }, "category": { "items": { "type": "string" }, - "type": "array" + "type": "array", + "example": [ + "การเงิน", + "รายงาน" + ] }, "keyword": { "items": { "type": "string" }, - "type": "array" + "type": "array", + "example": [ + "การเงิน", + "รายรับ", + "รายจ่าย" + ] + } + }, + "required": [ + "path", + "file" + ], + "type": "object", + "additionalProperties": false + }, + "PutFileBody": { + "properties": { + "title": { + "type": "string", + "example": "การเงิน" + }, + "description": { + "type": "string", + "example": "การเงิน" + }, + "category": { + "items": { + "type": "string" + }, + "type": "array", + "example": [ + "การเงิน", + "รายงาน" + ] + }, + "keyword": { + "items": { + "type": "string" + }, + "type": "array", + "example": [ + "การเงิน", + "รายรับ", + "รายจ่าย" + ] + }, + "from": { + "properties": { + "file": { + "type": "string", + "example": "ไฟล์ 1.xlsx" + }, + "path": { + "items": { + "type": "string" + }, + "type": "array", + "example": [ + "แฟ้ม 1", + "แฟ้ม 2", + "แฟ้ม 3" + ] + } + }, + "required": [ + "file", + "path" + ], + "type": "object", + "description": "หากต้องการอัพโหลดไฟล์ด้วยให้ส่งค่าเป็นจริง" + }, + "to": { + "properties": { + "file": { + "type": "string", + "example": "ไฟล์ 1 แก้ไข.xlsx" + }, + "path": { + "items": { + "type": "string" + }, + "type": "array", + "example": [ + "แฟ้ม 1", + "แฟ้ม 2", + "แฟ้ม 3" + ] + } + }, + "required": [ + "file", + "path" + ], + "type": "object" + }, + "upload": { + "type": "boolean", + "example": false + } + }, + "required": [ + "from" + ], + "type": "object", + "additionalProperties": false + }, + "DeleteFileBody": { + "properties": { + "path": { + "items": { + "type": "string" + }, + "type": "array", + "example": [ + "แฟ้ม 1", + "แฟ้ม 2", + "แฟ้ม 3" + ] + }, + "file": { + "type": "string", + "example": "ไฟล์ 1 แก้ไข.xlsx" } }, "required": [ @@ -1875,6 +2028,10 @@ } } }, + "tags": [ + "Storage Folder", + "Storage File" + ], "security": [], "parameters": [], "requestBody": { @@ -1897,6 +2054,9 @@ "description": "สำเร็จ" } }, + "tags": [ + "Storage Folder" + ], "security": [], "parameters": [], "requestBody": { @@ -1904,7 +2064,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateFolderBody" + "$ref": "#/components/schemas/FolderBody" } } } @@ -1914,10 +2074,13 @@ "operationId": "MoveFolder", "responses": { "204": { - "description": "No content" + "description": "สำเร็จ" } }, "description": "ยา้ย Folder ภายใต้ Folder (Path) หนึ่ง ไปภายใน Folder (Path) หนึ่งและสามารถเปลี่ยนชื่อได้", + "tags": [ + "Storage Folder" + ], "security": [], "parameters": [], "requestBody": { @@ -1939,6 +2102,9 @@ } }, "description": "ลบ Folder หรือ File ออกจากระบบ", + "tags": [ + "Storage Folder" + ], "security": [], "parameters": [], "requestBody": { @@ -2056,6 +2222,9 @@ } }, "description": "ร้องขอการอัพโหลดไฟล์ โดยเมื่อร้องขอจะได้ URL สำหรับอัพโหลดไฟล์มาพร้อมข้อมูลของไฟล์ที่จะทำการอัพโหลด", + "tags": [ + "Storage File" + ], "security": [], "parameters": [], "requestBody": { @@ -2063,7 +2232,73 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateFileBody" + "$ref": "#/components/schemas/FileBody" + } + } + } + } + }, + "put": { + "operationId": "MoveFile", + "responses": { + "204": { + "description": "สำเร็จ", + "content": { + "application/json": { + "schema": { + "anyOf": [ + {}, + { + "properties": { + "uploadUrl": { + "type": "string" + } + }, + "required": [ + "uploadUrl" + ], + "type": "object" + } + ] + } + } + } + } + }, + "tags": [ + "Storage File" + ], + "security": [], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PutFileBody" + } + } + } + } + }, + "delete": { + "operationId": "DeleteFile", + "responses": { + "204": { + "description": "สำเร็จ" + } + }, + "tags": [ + "Storage File" + ], + "security": [], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteFileBody" } } } From cd43bb53c35167a26d7759bad22745c0ca263b66 Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Tue, 12 Dec 2023 12:36:39 +0700 Subject: [PATCH 6/7] fix: unintended comment --- Services/server/src/app.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Services/server/src/app.ts b/Services/server/src/app.ts index d735caa..02c5ce0 100644 --- a/Services/server/src/app.ts +++ b/Services/server/src/app.ts @@ -49,4 +49,4 @@ server.listen(PORT, "0.0.0.0", () => console.log(`[APP] Application is running on http://localhost:${PORT}`), ); -// rabbitmq.init(amqHandler).catch((e) => console.error(e)); +rabbitmq.init(amqHandler).catch((e) => console.error(e)); From 185b1f540a2ae0e93004c584a0783063d9fe5dc8 Mon Sep 17 00:00:00 2001 From: Methapon2001 <61303214+Methapon2001@users.noreply.github.com> Date: Tue, 12 Dec 2023 13:02:22 +0700 Subject: [PATCH 7/7] feat: socketio and user identity --- .../src/controllers/storageController.ts | 102 +++++++++++++++--- Services/server/src/lib/websocket.ts | 2 + 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/Services/server/src/controllers/storageController.ts b/Services/server/src/controllers/storageController.ts index cb6866e..f1e8ecb 100644 --- a/Services/server/src/controllers/storageController.ts +++ b/Services/server/src/controllers/storageController.ts @@ -1,4 +1,16 @@ -import { Body, Controller, Delete, Example, Post, Put, Route, SuccessResponse, Tags } from "tsoa"; +import { + Body, + Controller, + Delete, + Example, + Post, + Put, + Request, + Route, + Security, + SuccessResponse, + Tags, +} from "tsoa"; import minioClient from "../minio"; import esClient from "../elasticsearch"; @@ -9,6 +21,8 @@ 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."); @@ -200,6 +214,7 @@ export class StorageController extends Controller { }, ]) @Tags("Storage Folder", "Storage File") + @Security("bearerAuth") public async getList(@Body() body: ListRequestBody) { const path = body.path.filter(Boolean); @@ -209,29 +224,41 @@ export class StorageController extends Controller { @Post("folder") @Tags("Storage Folder") + @Security("bearerAuth", ["management-role", "admin"]) @SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ") - public async postFolder(@Body() body: FolderBody) { + 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, - { - createdAt: new Date().toISOString(), - createdBy: "n/a", - }, + 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); } @@ -240,6 +267,7 @@ export class StorageController extends Controller { */ @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}`; @@ -322,6 +350,11 @@ export class StorageController extends Controller { }), ); + io.getInstance()?.emit("FolderMove", { + from: `${src}/`, + to: `${dst}/`, + }); + return this.setStatus(HttpStatusCode.NO_CONTENT); } @@ -330,6 +363,7 @@ export class StorageController extends Controller { */ @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) => { @@ -343,6 +377,8 @@ export class StorageController extends Controller { stream.on("error", () => reject(new Error(MINIO_ERROR_MESSAGE))); }); + io.getInstance()?.emit("FolderDelete", { pathname: body.path.join("/") + "/" }); + return this.setStatus(HttpStatusCode.NO_CONTENT); } @@ -351,8 +387,12 @@ export class StorageController extends Controller { */ @Post("file") @Tags("Storage File") + @Security("bearerAuth", ["management-role", "admin"]) @SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ") - public async postFile(@Body() body: FileBody) { + public async postFile( + @Request() request: { user: { preferred_username: string } }, + @Body() body: FileBody, + ) { const { path, file } = body; if (!(await checkPathExist(DEFAULT_BUCKET, path.join("/")))) { @@ -378,9 +418,9 @@ export class StorageController extends Controller { keyword: body.keyword ?? [], upload: false, // flag createdAt: new Date().toISOString(), - createdBy: "n/a", + createdBy: request.user.preferred_username, updatedAt: new Date().toISOString(), - updatedBy: "n/a", + updatedBy: request.user.preferred_username, }; // Pathname is unique and should not have multiple record with same path @@ -406,6 +446,8 @@ export class StorageController extends Controller { 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 }; @@ -413,8 +455,12 @@ export class StorageController extends Controller { @Put("file") @Tags("Storage File") + @Security("bearerAuth", ["management-role", "admin"]) @SuccessResponse(HttpStatusCode.NO_CONTENT, "สำเร็จ") - public async moveFile(@Body() body: PutFileBody) { + public async moveFile( + @Request() request: { user: { preferred_username: string } }, + @Body() body: PutFileBody, + ) { const search = await esClient .search }>({ index: DEFAULT_INDEX, @@ -460,9 +506,15 @@ export class StorageController extends Controller { } 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) { const src = [DEFAULT_BUCKET, ...from.path, ""].join("/") + from.file; const dst = to.path.join("/") + `/${to.file}`; @@ -481,14 +533,24 @@ export class StorageController extends Controller { ...metadata, path: to.path.join("/") + "/", pathname: dst, - updatedAt: new Date().toISOString(), - updatedBy: "n/a", + ...dateMeta, }, refresh: "wait_for", }) .then(async () => await minioClient.removeObject(DEFAULT_INDEX, src)) .catch((e) => console.error(`ElasticSearch Error: ${e}`)); + io.getInstance()?.emit("FileMove", { + from: source, + to: { + ...source, + ...metadata, + path: to.path.join("/") + "/", + pathname: dst, + ...dateMeta, + }, + }); + if (upload) { const presignedUrl = await minioClient.presignedPutObject(DEFAULT_BUCKET, dst); return { uploadUrl: presignedUrl }; @@ -503,12 +565,21 @@ export class StorageController extends Controller { id: id, doc: { ...metadata, - updatedAt: new Date().toISOString(), - updatedBy: "n/a", + ...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); @@ -521,6 +592,7 @@ export class StorageController extends Controller { @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; @@ -535,6 +607,8 @@ export class StorageController extends Controller { }) .catch((e) => console.error(`ElasticSearch Error: ${e}`)); + io.getInstance()?.emit("FileDelete", { pathname }); + return this.setStatus(HttpStatusCode.NO_CONTENT); } } diff --git a/Services/server/src/lib/websocket.ts b/Services/server/src/lib/websocket.ts index 6783db2..e8f4dea 100644 --- a/Services/server/src/lib/websocket.ts +++ b/Services/server/src/lib/websocket.ts @@ -9,3 +9,5 @@ export function setInstance(server: Server) { export function getInstance() { return io; } + +export default { getInstance, setInstance };