This commit is contained in:
2026-06-10 14:17:06 +07:00
parent 8d516fb515
commit 04a4e2cf98
5 changed files with 323 additions and 249 deletions

View File

@ -22,6 +22,7 @@
"author": "STS", "author": "STS",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"axios": "^1.17.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"compression": "^1.7.5", "compression": "^1.7.5",
"config": "^3.3.12", "config": "^3.3.12",

View File

@ -1,22 +1,28 @@
import { Response, NextFunction } from "express"; import { NextFunction, Response } from "express";
import { Request } from "express-jwt"; 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 Joi from "joi";
import { ILogObj, Logger } from "tslog"; import { ILogObj, Logger } from "tslog";
import CommonHelper from "../../helpers/common";
import { ReturnHelper } from "../../helpers/express/return";
import { Language } from "../../langs/lang"; import { Language } from "../../langs/lang";
import fileUpload from "express-fileupload"; import config from "config";
import { v4 as uuidv4 } from 'uuid'; import Thirdparty from "../../helpers/thirdparty";
import fs from "fs"; import JwtHelper from "../../helpers/jwt";
import path from "path";
const log: Logger<ILogObj> = new Logger({ name: '[FileController]', type: 'pretty' }); const log: Logger<ILogObj> = new Logger({
const STORAGE_DIR = 'assets/saude/'; name: "[FileController]",
type: "pretty",
});
export class FileController { export class FileController {
static async upload(req: Request, res: Response, next: NextFunction): Promise<Response> { static async upload(req: Request, res: Response, next: NextFunction): Promise<void | Response> {
/* #swagger.tags = ['Handle File'] /*
#swagger.security = [{ "bearerAuth": [] }] #swagger.tags = ['File']
#swagger.consumes = ['multipart/form-data'] #swagger.security = [{
"bearerAuth": []
}]
#swagger.requestBody = { #swagger.requestBody = {
required: true, required: true,
content: { content: {
@ -26,193 +32,135 @@ export class FileController {
properties: { properties: {
file: { file: {
type: "string", type: "string",
format: "binary", format: "binary"
description: "Image file (max 1MB, jpg/png/webp)" }
}, }
}, }
required: ["file"]
} }
} }
} }
} }
*/ */
try { try {
// Check if file exists const uploadedFile: UploadedFile | any = req.files.file;
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; 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"),
});
// Validate file size (max 1MB) const fileData = {
const maxSize = 10 * 1024 * 1024; // 10MB name: uploadedFile.name,
if (file.size > maxSize) { mimetype: uploadedFile.mimetype,
return ReturnHelper.errorResponse( size: uploadedFile.size,
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; let body = await schema.validateAsync({ file: fileData });
const file_path = path.join(folder_path, name);
// Create directory if it doesn't exist const field = {
if (!fs.existsSync(folder_path)) { file: uploadedFile,
fs.mkdirSync(folder_path, { recursive: true }); application: "saude",
} };
// Move file to destination let url = config.get("service.file") + "upload";
await file.mv(file_path); let result = await Thirdparty.UploadFile(url, field, JwtHelper.token(req.auth.data));
console.log("result :", result);
result.file = name; let data = { file: result.data.file };
return ReturnHelper.successResponseAny(res, 200, Language.lang.success, data);
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 delete(req: Request, res: Response, next: NextFunction): Promise<Response> {
/*
#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"]
}
}
}
}
*/
try {
const schema = Joi.object().keys({
file_name: Joi.string().required().label('File Name'),
});
const param: { file_name: string } = await schema.validateAsync(req.body);
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 file_path = path.join(STORAGE_DIR, param.file_name);
// Check if file exists
if (!fs.existsSync(file_path)) {
return ReturnHelper.errorResponse(res, 404, 404, Language.lang.failed_not_found, "File not found");
}
// Delete file
fs.unlinkSync(file_path);
return ReturnHelper.successResponseAny(res, 200, Language.lang.success_delete, {});
} catch (e: unknown) { } catch (e: unknown) {
log.error(e); log.error(e);
const err = e as Error; const err = e as Error;
return ReturnHelper.errorResponse(res, 500, 402, Language.lang.failed_delete, err.message); return ReturnHelper.errorResponse(res, 400, 401, Language.lang.failed_view, err.message);
} }
} }
static async download(req: Request, res: Response, next: NextFunction): Promise<void | Response> { static async download(req: Request, res: Response, next: NextFunction): Promise<void | Response> {
/* /*
#swagger.tags = ['Handle File'] #swagger.tags = ['File']
#swagger.security = [{ "bearerAuth": [] }] #swagger.security = [{
"bearerAuth": []
}]
#swagger.parameters['name'] = { #swagger.parameters['name'] = {
in: 'query', in: 'query',
required: true, type: 'string'
type: 'string',
description: 'File name'
} }
#swagger.parameters['token'] = { #swagger.parameters['token'] = {
in: 'query', in: 'query',
required: true, required: true,
type: 'string', type: 'string'
description: 'Token'
} }
*/ */
try { try {
// 1⃣ Validasi parameter
const schema = Joi.object({ const schema = Joi.object({
name: Joi.string().required().label("File Name"), name: Joi.string().required().label("File Name"),
token: Joi.string().required().label("Token"), token: Joi.string().required().label("Token"),
}); });
const param: { name: string } = await schema.validateAsync(req.query); const param = await schema.validateAsync(req.query);
if (param.name.includes('..') || param.name.includes('/') || param.name.includes('\\')) { // 2⃣ Ambil file dari service-file via SFTP (bukan static URL /file/ yang diarahkan ke frontend)
return ReturnHelper.errorResponse(res, 400, 400, Language.lang.failed_not_found, "Invalid file name"); const url = config.get("service.file") + "download";
} const buffer = await Thirdparty.DownloadFile(url, { file: param.name, application: "saude" }, param.token);
const file_path = path.join(STORAGE_DIR, param.name); // 3⃣ Deteksi ekstensi & MIME type
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 fileName = param.name;
const ext = fileName.split(".").pop()?.toLowerCase(); const ext = fileName.split(".").pop()?.toLowerCase();
const mimeMap: Record<string, string> = { const mimeMap: Record<string, string> = {
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
xls: "application/vnd.ms-excel",
csv: "text/csv",
pdf: "application/pdf",
jpg: "image/jpeg", jpg: "image/jpeg",
jpeg: "image/jpeg", jpeg: "image/jpeg",
png: "image/png", png: "image/png",
webp: "image/webp",
gif: "image/gif", gif: "image/gif",
pdf: "application/pdf", doc: "application/msword",
svg: "image/svg+xml", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
zip: "application/zip",
txt: "text/plain",
}; };
const contentType = mimeMap[ext ?? ""] ?? "application/octet-stream"; const contentType = mimeMap[ext ?? ""] ?? "application/octet-stream";
// 4⃣ Set header tipe file
res.setHeader("Content-Type", contentType); res.setHeader("Content-Type", contentType);
res.setHeader("Content-Length", buffer.length); res.setHeader("Content-Length", buffer.length);
// 5⃣ Tentukan apakah file perlu auto download
const isInline = contentType.startsWith("image/") || contentType === "application/pdf"; const isInline = contentType.startsWith("image/") || contentType === "application/pdf";
// 🟢 Kalau image/pdf → tampil di browser
// 🔵 Kalau file lain → download otomatis
if (isInline) { if (isInline) {
res.setHeader("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`); res.setHeader("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`);
} else { } else {
res.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(fileName)}"`); res.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(fileName)}"`);
} }
// 6⃣ Kirim file ke browser
return res.end(buffer); return res.end(buffer);
} catch (e: unknown) { } catch (e: unknown) {
log.error(e); log.error(e);

View File

@ -1,24 +1,26 @@
import { NextFunction, Response } from 'express'; import { NextFunction, Response } from "express";
import { Logger, ILogObj } from 'tslog'; import { Logger, ILogObj } from "tslog";
import { Request, expressjwt } from "express-jwt"; import { Request, expressjwt } from "express-jwt";
import fs from 'fs'; import fs from "fs";
import { Language } from '../langs/lang'; import { Language } from "../langs/lang";
import { ReturnHelper } from './express/return'; import { ReturnHelper } from "./express/return";
import express from 'express'; import express from "express";
import config from "config";
import jwt from "jsonwebtoken";
const log: Logger<ILogObj> = new Logger({ name: '[JwtHelper]', type: 'pretty' }); const log: Logger<ILogObj> = new Logger({ name: "[JwtHelper]", type: "pretty" });
export default class JwtHelper { 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( app.use(
expressjwt({ expressjwt({
secret: publicKey, algorithms: ["RS256"], secret: publicKey,
algorithms: ["RS256"],
getToken: function fromHeaderOrQuerystring(req): any { getToken: function fromHeaderOrQuerystring(req): any {
if (req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer') { if (req.headers.authorization && req.headers.authorization.split(" ")[0] === "Bearer") {
return req.headers.authorization.split(' ')[1]; return req.headers.authorization.split(" ")[1];
} else if (req.query && req.query.token) { } else if (req.query && req.query.token) {
return req.query.token; return req.query.token;
} }
@ -27,8 +29,8 @@ export default class JwtHelper {
}).unless({ }).unless({
path: [ path: [
"/token", "/token",
/^\/uploads\/.*/ // ⚠️ TAMBAHKAN INI - exclude semua path /uploads/ /^\/uploads\/.*/, // ⚠️ TAMBAHKAN INI - exclude semua path /uploads/
] ],
}), }),
function (req: Request, res: Response, next: NextFunction) { function (req: Request, res: Response, next: NextFunction) {
if (!req.auth?.data.id) { if (!req.auth?.data.id) {
@ -36,16 +38,32 @@ export default class JwtHelper {
} }
return next(); return next();
} },
) );
app.use(function (err: any, req: Request, res: Response, next: NextFunction) { app.use(function (err: any, req: Request, res: Response, next: NextFunction) {
if (err.name === 'UnauthorizedError') { if (err.name === "UnauthorizedError") {
return ReturnHelper.errorResponse(res, 403, 666, Language.lang.failed_access); return ReturnHelper.errorResponse(res, 403, 666, Language.lang.failed_access);
} }
return next(); return next();
}); });
};
} 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;
};
} }

108
src/helpers/thirdparty.ts Normal file
View File

@ -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<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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,
};
}
}
}

View File

@ -101,9 +101,8 @@ export class RoutePrivate {
app.delete("/api/menu/delete/:id/:hard", MenuController.delete); app.delete("/api/menu/delete/:id/:hard", MenuController.delete);
app.put("/api/menu/restore/:id", MenuController.restore); app.put("/api/menu/restore/:id", MenuController.restore);
app.post('/api/upload', FileController.upload) app.post("/api/upload", FileController.upload);
app.delete('/api/delete', FileController.delete) app.get("/api/download", FileController.download);
app.get('/api/download', FileController.download)
app.get("/api/administrativu/list", AdministrativuController.list); app.get("/api/administrativu/list", AdministrativuController.list);
app.get("/api/administrativu/export", AdministrativuController.export); app.get("/api/administrativu/export", AdministrativuController.export);