diff --git a/package.json b/package.json index d763d61..63fc7f9 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "author": "STS", "license": "ISC", "dependencies": { + "axios": "^1.17.0", "bcryptjs": "^2.4.3", "compression": "^1.7.5", "config": "^3.3.12", diff --git a/src/controllers/file/file.ts b/src/controllers/file/file.ts index 083c32d..0d7a196 100644 --- a/src/controllers/file/file.ts +++ b/src/controllers/file/file.ts @@ -1,223 +1,171 @@ -import { Response, NextFunction } from "express"; +import { NextFunction, Response } from "express"; import { Request } from "express-jwt"; -import { ReturnHelper } from "../../helpers/express/return"; +import { UploadedFile } from "express-fileupload"; +import fs from "fs"; // Import fs module to check if directory exists import Joi from "joi"; import { ILogObj, Logger } from "tslog"; +import CommonHelper from "../../helpers/common"; +import { ReturnHelper } from "../../helpers/express/return"; import { Language } from "../../langs/lang"; -import fileUpload from "express-fileupload"; -import { v4 as uuidv4 } from 'uuid'; -import fs from "fs"; -import path from "path"; +import config from "config"; +import Thirdparty from "../../helpers/thirdparty"; +import JwtHelper from "../../helpers/jwt"; -const log: Logger = new Logger({ name: '[FileController]', type: 'pretty' }); -const STORAGE_DIR = 'assets/saude/'; +const log: Logger = new Logger({ + name: "[FileController]", + type: "pretty", +}); export class FileController { - static async upload(req: Request, res: Response, next: NextFunction): Promise { - /* #swagger.tags = ['Handle File'] - #swagger.security = [{ "bearerAuth": [] }] - #swagger.consumes = ['multipart/form-data'] - #swagger.requestBody = { - required: true, - content: { - "multipart/form-data": { - schema: { - type: "object", - properties: { - file: { - type: "string", - format: "binary", - description: "Image file (max 1MB, jpg/png/webp)" - }, - }, - required: ["file"] - } - } - } - } - */ - try { - // Check if file exists - if (!req.files || !req.files.file) { - return ReturnHelper.errorResponse(res, 400, 404, Language.lang.failed_insert, "File not found"); - } - - const file = req.files.file as fileUpload.UploadedFile; - - // Validate file size (max 1MB) - const maxSize = 10 * 1024 * 1024; // 10MB - if (file.size > maxSize) { - return ReturnHelper.errorResponse( - res, - 400, - 400, - Language.lang.failed_insert, - "File size exceeds 10MB limit" - ); - } - - // Validate file type - const allowedMimes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/svg+xml', 'image/svg']; - if (!allowedMimes.includes(file.mimetype)) { - return ReturnHelper.errorResponse(res, 400, 400, Language.lang.failed_insert, "Invalid file type. Only JPG, PNG, WEBP, and SVG are allowed"); - } - const ext = file.name.split('.'); - const name = uuidv4() + '.' + ext[ext.length - 1]; - - const result: { file: string } = { - file: "", - }; - - const folder_path = STORAGE_DIR; - const file_path = path.join(folder_path, name); - - // Create directory if it doesn't exist - if (!fs.existsSync(folder_path)) { - fs.mkdirSync(folder_path, { recursive: true }); - } - - // Move file to destination - await file.mv(file_path); - - result.file = name; - - log.info(`File uploaded successfully: ${file_path}`); - - return ReturnHelper.successResponseAny(res, 200, Language.lang.success_insert, result); - - } catch (e: unknown) { - // Proper error logging to avoid tslog serialization issues - if (e instanceof Error) { - log.error("File upload failed:", { - message: e.message, - stack: e.stack, - name: e.name - }); - return ReturnHelper.errorResponse(res, 500, 500, Language.lang.failed_insert, e.message); - } else { - log.error("File upload failed with unknown error:", String(e)); - return ReturnHelper.errorResponse(res, 500, 500, Language.lang.failed_insert, "Unknown error occurred"); + static async upload(req: Request, res: Response, next: NextFunction): Promise { + /* + #swagger.tags = ['File'] + #swagger.security = [{ + "bearerAuth": [] + }] + #swagger.requestBody = { + required: true, + content: { + "multipart/form-data": { + schema: { + type: "object", + properties: { + file: { + type: "string", + format: "binary" + } + } + } + } + } } } - } + */ + try { + const uploadedFile: UploadedFile | any = req.files.file; - static async delete(req: Request, res: Response, next: NextFunction): Promise { - /* - #swagger.tags = ['Handle File'] - #swagger.security = [{ "bearerAuth": [] }] - #swagger.requestBody = { - required: true, - content: { - "application/json": { - schema: { - type: "object", - properties: { - file_name: { type: "string", description: "File name to delete" } - }, - required: ["file_name"] - } - } - } - } - */ + const schema = Joi.object({ + file: Joi.object({ + name: Joi.string().label("File Name"), + mimetype: Joi.string() + .valid( + "image/jpeg", + "image/png", + "application/pdf", + "application/vnd.ms-excel", // ✅ .xls + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // ✅ .xlsx + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "text/csv", // csv + ) + .optional() + .label("File Type"), + size: Joi.number() + .max(10 * 1024 * 1024) + .optional() + .label("File Size"), + }) + .optional() + .label("File"), + }); - try { - const schema = Joi.object().keys({ - file_name: Joi.string().required().label('File Name'), - }); + const fileData = { + name: uploadedFile.name, + mimetype: uploadedFile.mimetype, + size: uploadedFile.size, + }; - const param: { file_name: string } = await schema.validateAsync(req.body); + let body = await schema.validateAsync({ file: fileData }); - if (param.file_name.includes('..') || param.file_name.includes('/') || param.file_name.includes('\\')) { - return ReturnHelper.errorResponse(res, 400, 400, Language.lang.failed_delete, "Invalid file name"); - } + const field = { + file: uploadedFile, + application: "saude", + }; - const file_path = path.join(STORAGE_DIR, param.file_name); + let url = config.get("service.file") + "upload"; + let result = await Thirdparty.UploadFile(url, field, JwtHelper.token(req.auth.data)); + console.log("result :", result); - // Check if file exists - if (!fs.existsSync(file_path)) { - return ReturnHelper.errorResponse(res, 404, 404, Language.lang.failed_not_found, "File not found"); - } + let data = { file: result.data.file }; + return ReturnHelper.successResponseAny(res, 200, Language.lang.success, data); + } catch (e: unknown) { + log.error(e); + const err = e as Error; - // Delete file - fs.unlinkSync(file_path); + return ReturnHelper.errorResponse(res, 400, 401, Language.lang.failed_view, err.message); + } + } - return ReturnHelper.successResponseAny(res, 200, Language.lang.success_delete, {}); - } catch (e: unknown) { - log.error(e); - const err = e as Error; - - return ReturnHelper.errorResponse(res, 500, 402, Language.lang.failed_delete, err.message); + static async download(req: Request, res: Response, next: NextFunction): Promise { + /* + #swagger.tags = ['File'] + #swagger.security = [{ + "bearerAuth": [] + }] + #swagger.parameters['name'] = { + in: 'query', + type: 'string' } - } - - static async download(req: Request, res: Response, next: NextFunction): Promise { - /* - #swagger.tags = ['Handle File'] - #swagger.security = [{ "bearerAuth": [] }] - #swagger.parameters['name'] = { - in: 'query', - required: true, - type: 'string', - description: 'File name' - } - #swagger.parameters['token'] = { - in: 'query', - required: true, - type: 'string', - description: 'Token' - } - */ - try { - const schema = Joi.object({ - name: Joi.string().required().label("File Name"), - token: Joi.string().required().label("Token"), - }); - - const param: { name: string } = await schema.validateAsync(req.query); - - if (param.name.includes('..') || param.name.includes('/') || param.name.includes('\\')) { - return ReturnHelper.errorResponse(res, 400, 400, Language.lang.failed_not_found, "Invalid file name"); - } - - const file_path = path.join(STORAGE_DIR, param.name); - - if (!fs.existsSync(file_path)) { - return ReturnHelper.errorResponse(res, 404, 404, Language.lang.failed_not_found, "File not found"); - } - - const buffer = fs.readFileSync(file_path); - const fileName = param.name; - const ext = fileName.split(".").pop()?.toLowerCase(); - - const mimeMap: Record = { - jpg: "image/jpeg", - jpeg: "image/jpeg", - png: "image/png", - webp: "image/webp", - gif: "image/gif", - pdf: "application/pdf", - svg: "image/svg+xml", - }; - - const contentType = mimeMap[ext ?? ""] ?? "application/octet-stream"; - - res.setHeader("Content-Type", contentType); - res.setHeader("Content-Length", buffer.length); - - const isInline = contentType.startsWith("image/") || contentType === "application/pdf"; - - if (isInline) { - res.setHeader("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`); - } else { - res.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(fileName)}"`); - } - - return res.end(buffer); - } catch (e: unknown) { - log.error(e); - const err = e as Error; - return ReturnHelper.errorResponse(res, 400, 401, Language.lang.failed_not_found, err.message); + #swagger.parameters['token'] = { + in: 'query', + required: true, + type: 'string' } - } -} \ No newline at end of file + */ + try { + // 1️⃣ Validasi parameter + const schema = Joi.object({ + name: Joi.string().required().label("File Name"), + token: Joi.string().required().label("Token"), + }); + + const param = await schema.validateAsync(req.query); + + // 2️⃣ Ambil file dari service-file via SFTP (bukan static URL /file/ yang diarahkan ke frontend) + const url = config.get("service.file") + "download"; + const buffer = await Thirdparty.DownloadFile(url, { file: param.name, application: "saude" }, param.token); + + // 3️⃣ Deteksi ekstensi & MIME type + const fileName = param.name; + const ext = fileName.split(".").pop()?.toLowerCase(); + + const mimeMap: Record = { + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + xls: "application/vnd.ms-excel", + csv: "text/csv", + pdf: "application/pdf", + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + doc: "application/msword", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + zip: "application/zip", + txt: "text/plain", + }; + + const contentType = mimeMap[ext ?? ""] ?? "application/octet-stream"; + + // 4️⃣ Set header tipe file + res.setHeader("Content-Type", contentType); + res.setHeader("Content-Length", buffer.length); + + // 5️⃣ Tentukan apakah file perlu auto download + const isInline = contentType.startsWith("image/") || contentType === "application/pdf"; + + // 🟢 Kalau image/pdf → tampil di browser + // 🔵 Kalau file lain → download otomatis + if (isInline) { + res.setHeader("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`); + } else { + res.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(fileName)}"`); + } + + // 6️⃣ Kirim file ke browser + return res.end(buffer); + } catch (e: unknown) { + log.error(e); + const err = e as Error; + return ReturnHelper.errorResponse(res, 400, 401, Language.lang.failed_not_found, err.message); + } + } +} diff --git a/src/helpers/jwt.ts b/src/helpers/jwt.ts index 381b79f..3ce724c 100644 --- a/src/helpers/jwt.ts +++ b/src/helpers/jwt.ts @@ -1,51 +1,69 @@ -import { NextFunction, Response } from 'express'; -import { Logger, ILogObj } from 'tslog'; +import { NextFunction, Response } from "express"; +import { Logger, ILogObj } from "tslog"; import { Request, expressjwt } from "express-jwt"; -import fs from 'fs'; -import { Language } from '../langs/lang'; -import { ReturnHelper } from './express/return'; -import express from 'express'; +import fs from "fs"; +import { Language } from "../langs/lang"; +import { ReturnHelper } from "./express/return"; +import express from "express"; +import config from "config"; +import jwt from "jsonwebtoken"; -const log: Logger = new Logger({ name: '[JwtHelper]', type: 'pretty' }); +const log: Logger = new Logger({ name: "[JwtHelper]", type: "pretty" }); export default class JwtHelper { - static secure = (app: express.Application) => { + static secure = (app: express.Application) => { + var publicKey = fs.readFileSync("src/helpers/key/public.key"); - var publicKey = fs.readFileSync("src/helpers/key/public.key"); + app.use( + expressjwt({ + secret: publicKey, + algorithms: ["RS256"], + getToken: function fromHeaderOrQuerystring(req): any { + if (req.headers.authorization && req.headers.authorization.split(" ")[0] === "Bearer") { + return req.headers.authorization.split(" ")[1]; + } else if (req.query && req.query.token) { + return req.query.token; + } + return null; + }, + }).unless({ + path: [ + "/token", + /^\/uploads\/.*/, // ⚠️ TAMBAHKAN INI - exclude semua path /uploads/ + ], + }), + function (req: Request, res: Response, next: NextFunction) { + if (!req.auth?.data.id) { + return ReturnHelper.errorResponse(res, 403, 666, Language.lang.failed_access); + } - app.use( - expressjwt({ - secret: publicKey, algorithms: ["RS256"], - getToken: function fromHeaderOrQuerystring(req): any { - if (req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer') { - return req.headers.authorization.split(' ')[1]; - } else if (req.query && req.query.token) { - return req.query.token; - } - return null; - }, - }).unless({ - path: [ - "/token", - /^\/uploads\/.*/ // ⚠️ TAMBAHKAN INI - exclude semua path /uploads/ - ] - }), - function (req: Request, res: Response, next: NextFunction) { - if (!req.auth?.data.id) { - return ReturnHelper.errorResponse(res, 403, 666, Language.lang.failed_access); - } + return next(); + }, + ); - return next(); - } - ) + app.use(function (err: any, req: Request, res: Response, next: NextFunction) { + if (err.name === "UnauthorizedError") { + return ReturnHelper.errorResponse(res, 403, 666, Language.lang.failed_access); + } - app.use(function (err: any, req: Request, res: Response, next: NextFunction) { - if (err.name === 'UnauthorizedError') { - return ReturnHelper.errorResponse(res, 403, 666, Language.lang.failed_access); - } + return next(); + }); + }; - return next(); - }); - - } -} \ No newline at end of file + static token = (user: { id: string; username: string; name: string }) => { + var privateKey = fs.readFileSync("src/helpers/key/private.key"); + var token = jwt.sign( + { + exp: Math.floor(Date.now() / 1000) + Number(config.get("auth.access_token_lifetime")), + data: { + id: user.id, + username: user.username, + name: user.name, + }, + }, + privateKey, + { algorithm: "RS256" }, + ); + return token; + }; +} diff --git a/src/helpers/thirdparty.ts b/src/helpers/thirdparty.ts new file mode 100644 index 0000000..7ad4731 --- /dev/null +++ b/src/helpers/thirdparty.ts @@ -0,0 +1,108 @@ +import axios from "axios"; + +export default class Thirdparty { + static async GetData(url: string, field: any, token?: string) { + try { + const headers: Record = {}; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await axios.get(url, { params: field, headers }); + const result = response.data; + + if (result.status) { + return { status: true, message: "Success fetch data", data: result.data }; + } else { + // Add a default return for falsy result.status + return { status: false, message: "Unexpected response status", data: null }; + } + } catch (error: any) { + const { response } = error; + return { + status: false, + message: response?.data?.error ?? response?.data?.message ?? "Unknown error", + data: null, + }; + } + } + + static async PostData(url: string, field: any, token?: string) { + try { + const headers: Record = {}; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + const response = await axios.post(url, field, { headers }); + const result = response.data; + return result; + } catch (error: any) { + const { response } = error; + return { status: false, message: response?.data?.error ?? response?.data?.message ?? "Unknown error" }; + } + } + + static async UploadFile(url: string, field: any, token?: string) { + try { + const headers: Record = {}; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const formData = new FormData(); + + // Append the file and other fields to FormData + for (const key in field) { + if (field.hasOwnProperty(key)) { + const value = field[key]; + + if (value instanceof Object && value.data && value.mimetype) { + // Convert file-like object to a Blob + const blob = new Blob([value.data], { type: value.mimetype }); + formData.append(key, blob, value.name); // Use name as filename + } else { + formData.append(key, value); // Append non-file fields + } + } + } + + const response = await axios.post(url, formData, { + headers: { ...headers, "Content-Type": "multipart/form-data" }, + }); + + const result = response.data; + + if (!result.status) throw response; + return result; + } catch (error: any) { + const { response } = error; + + throw { + status: false, + message: response?.data?.error ?? response?.data?.message ?? "Unknown error", + data: null, + }; + } + } + + static async DownloadFile(url: string, field: any, token?: string) { + try { + const headers: Record = {}; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await axios.get(url, { params: field, headers, responseType: "arraybuffer" }); + const result = response.data; + + return Buffer.from(result); + } catch (error: any) { + const { response } = error; + throw { + status: false, + message: response?.data?.error ?? response?.data?.message ?? "Unknown error", + data: null, + }; + } + } +} diff --git a/src/routes/private.ts b/src/routes/private.ts index e30aa04..716cfba 100644 --- a/src/routes/private.ts +++ b/src/routes/private.ts @@ -101,9 +101,8 @@ export class RoutePrivate { app.delete("/api/menu/delete/:id/:hard", MenuController.delete); app.put("/api/menu/restore/:id", MenuController.restore); - app.post('/api/upload', FileController.upload) - app.delete('/api/delete', FileController.delete) - app.get('/api/download', FileController.download) + app.post("/api/upload", FileController.upload); + app.get("/api/download", FileController.download); app.get("/api/administrativu/list", AdministrativuController.list); app.get("/api/administrativu/export", AdministrativuController.export);