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 diff --git a/Services/server/src/app.ts b/Services/server/src/app.ts index fdff76d..02c5ce0 100644 --- a/Services/server/src/app.ts +++ b/Services/server/src/app.ts @@ -42,14 +42,8 @@ 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}`), diff --git a/Services/server/src/controllers/storageController.ts b/Services/server/src/controllers/storageController.ts new file mode 100644 index 0000000..f1e8ecb --- /dev/null +++ b/Services/server/src/controllers/storageController.ts @@ -0,0 +1,614 @@ +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[]; +} + +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[]; +} + +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 }[] = []; + + 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 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); + 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); + } + + /** + * ลบ Folder หรือ File ออกจากระบบ + */ + @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.NO_CONTENT, "สำเร็จ") + 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 + 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.NO_CONTENT, "สำเร็จ") + 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 && !(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 { 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}`; + + 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, + ...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 }; + } + } + } + + 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); + } + + @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); + } +} 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 }; diff --git a/Services/server/src/routes.ts b/Services/server/src/routes.ts index 7b3897c..90cab3b 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,77 @@ 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 + "FolderBody": { + "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 + "FileBody": { + "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 + "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); @@ -656,6 +729,181 @@ 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":"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 + + 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, 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/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":"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 + + 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.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 7dd5811..75665c8 100644 --- a/Services/server/src/swagger.json +++ b/Services/server/src/swagger.json @@ -174,6 +174,299 @@ }, "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 + }, + "FolderBody": { + "properties": { + "path": { + "items": { + "type": "string" + }, + "type": "array", + "example": [ + "แฟ้ม 1", + "แฟ้ม 2" + ] + }, + "name": { + "type": "string", + "example": "แฟ้ม 3" + } + }, + "required": [ + "path", + "name" + ], + "type": "object", + "additionalProperties": false + }, + "PutFolderBody": { + "properties": { + "from": { + "properties": { + "name": { + "type": "string", + "example": "แฟ้ม 3" + }, + "path": { + "items": { + "type": "string" + }, + "type": "array", + "example": [ + "แฟ้ม 1", + "แฟ้ม 2" + ] + } + }, + "required": [ + "name", + "path" + ], + "type": "object" + }, + "to": { + "properties": { + "name": { + "type": "string", + "example": "แฟ้ม 3 แก้ไข" + }, + "path": { + "items": { + "type": "string" + }, + "type": "array", + "example": [ + "แฟ้ม 1", + "แฟ้ม 2" + ] + } + }, + "required": [ + "name", + "path" + ], + "type": "object" + } + }, + "required": [ + "from", + "to" + ], + "type": "object", + "additionalProperties": false + }, + "DeleteFolderBody": { + "properties": { + "path": { + "items": { + "type": "string" + }, + "type": "array", + "example": [ + "แฟ้ม 1", + "แฟ้ม 2", + "แฟ้ม 3 แก้ไข" + ] + } + }, + "required": [ + "path" + ], + "type": "object", + "additionalProperties": false + }, + "FileBody": { + "properties": { + "path": { + "items": { + "type": "string" + }, + "type": "array", + "example": [ + "แฟ้ม 1", + "แฟ้ม 2", + "แฟ้ม 3" + ] + }, + "file": { + "type": "string", + "example": "ไฟล์ 1.xlsx" + }, + "title": { + "type": "string", + "example": "การเงิน" + }, + "description": { + "type": "string", + "example": "การเงิน" + }, + "category": { + "items": { + "type": "string" + }, + "type": "array", + "example": [ + "การเงิน", + "รายงาน" + ] + }, + "keyword": { + "items": { + "type": "string" + }, + "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": [ + "path", + "file" + ], + "type": "object", + "additionalProperties": false } }, "securitySchemes": { @@ -1662,6 +1955,356 @@ } } }, + "/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" + } + ] + } + } + } + } + } + }, + "tags": [ + "Storage Folder", + "Storage File" + ], + "security": [], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListRequestBody" + } + } + } + } + } + }, + "/storage/folder": { + "post": { + "operationId": "PostFolder", + "responses": { + "204": { + "description": "สำเร็จ" + } + }, + "tags": [ + "Storage Folder" + ], + "security": [], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FolderBody" + } + } + } + } + }, + "put": { + "operationId": "MoveFolder", + "responses": { + "204": { + "description": "สำเร็จ" + } + }, + "description": "ยา้ย Folder ภายใต้ Folder (Path) หนึ่ง ไปภายใน Folder (Path) หนึ่งและสามารถเปลี่ยนชื่อได้", + "tags": [ + "Storage Folder" + ], + "security": [], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PutFolderBody" + } + } + } + } + }, + "delete": { + "operationId": "DeleteStorage", + "responses": { + "204": { + "description": "สำเร็จ" + } + }, + "description": "ลบ Folder หรือ File ออกจากระบบ", + "tags": [ + "Storage Folder" + ], + "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 สำหรับอัพโหลดไฟล์มาพร้อมข้อมูลของไฟล์ที่จะทำการอัพโหลด", + "tags": [ + "Storage File" + ], + "security": [], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$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" + } + } + } + } + } + }, "/cabinet/{cabinetName}/drawer/{drawerName}/folder/{folderName}/subfolder": { "get": { "operationId": "ListFolder", diff --git a/Services/server/src/utils/auth.ts b/Services/server/src/utils/auth.ts index b7ae6cd..0f56597 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 = {}; @@ -60,7 +62,7 @@ export async function expressAuthentication( .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 +70,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 +79,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); }