feat(storage): migrate file storage from local disk to MinIO
Replace local filesystem-based image storage with MinIO S3-compatible object storage. All upload, serve, delete, and list operations now use the MinIO bucket defined in MINIO_* env vars. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@
|
|||||||
"framer-motion": "^12.4.1",
|
"framer-motion": "^12.4.1",
|
||||||
"get-port": "^7.1.0",
|
"get-port": "^7.1.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
"minio": "^8.0.7",
|
||||||
"motion": "^12.4.1",
|
"motion": "^12.4.1",
|
||||||
"nanoid": "^5.1.0",
|
"nanoid": "^5.1.0",
|
||||||
"next": "15.1.6",
|
"next": "15.1.6",
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import fs from "fs/promises";
|
import minio, { MINIO_BUCKET } from "@/lib/minio";
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
async function imgDel({
|
async function imgDel({ name }: { name: string }) {
|
||||||
name,
|
|
||||||
UPLOAD_DIR_IMAGE,
|
|
||||||
}: {
|
|
||||||
name: string;
|
|
||||||
UPLOAD_DIR_IMAGE: string;
|
|
||||||
}) {
|
|
||||||
try {
|
try {
|
||||||
await fs.unlink(path.join(UPLOAD_DIR_IMAGE, name));
|
await minio.removeObject(MINIO_BUCKET, `image/${name}`);
|
||||||
return "ok";
|
return "ok";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
|
|||||||
@@ -1,27 +1,21 @@
|
|||||||
import fs from "fs/promises";
|
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import fs from "fs/promises";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
import minio, { MINIO_BUCKET } from "@/lib/minio";
|
||||||
|
|
||||||
async function img({
|
async function img({
|
||||||
name,
|
name,
|
||||||
UPLOAD_DIR_IMAGE,
|
|
||||||
ROOT,
|
ROOT,
|
||||||
size,
|
size,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
UPLOAD_DIR_IMAGE: string;
|
|
||||||
ROOT: string;
|
ROOT: string;
|
||||||
size?: number; // Ukuran opsional (tidak ada default)
|
size?: number;
|
||||||
}) {
|
}) {
|
||||||
const completeName = path.basename(name); // Nama file lengkap
|
|
||||||
const ext = path.extname(name).toLowerCase(); // Ekstensi file dalam huruf kecil
|
|
||||||
// const fileNameWithoutExt = path.basename(name, ext); // Nama file tanpa ekstensi
|
|
||||||
|
|
||||||
// Default image jika terjadi kesalahan
|
|
||||||
const noImage = path.join(ROOT, "public/no-image.jpg");
|
const noImage = path.join(ROOT, "public/no-image.jpg");
|
||||||
|
const ext = path.extname(name).toLowerCase();
|
||||||
|
|
||||||
// Validasi ekstensi file
|
if (![".jpg", ".jpeg", ".png", ".webp"].includes(ext)) {
|
||||||
if (![".jpg", ".jpeg", ".png"].includes(ext)) {
|
|
||||||
console.warn(`Ekstensi file tidak didukung: ${ext}`);
|
console.warn(`Ekstensi file tidak didukung: ${ext}`);
|
||||||
return new Response(await fs.readFile(noImage), {
|
return new Response(await fs.readFile(noImage), {
|
||||||
headers: { "Content-Type": "image/jpeg" },
|
headers: { "Content-Type": "image/jpeg" },
|
||||||
@@ -29,29 +23,26 @@ async function img({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Path ke file asli
|
const stream = await minio.getObject(MINIO_BUCKET, `image/${name}`);
|
||||||
const filePath = path.join(UPLOAD_DIR_IMAGE, completeName);
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
// Periksa apakah file ada
|
const metadata = await sharp(buffer).metadata();
|
||||||
await fs.stat(filePath);
|
const resized = await sharp(buffer)
|
||||||
|
.resize(size || metadata.width)
|
||||||
// Metadata gambar asli
|
|
||||||
const metadata = await sharp(filePath).metadata();
|
|
||||||
|
|
||||||
// Proses resize menggunakan sharp
|
|
||||||
const resizedImageBuffer = await sharp(filePath)
|
|
||||||
.resize(size || metadata.width) // Gunakan size jika diberikan, jika tidak gunakan width asli
|
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
return new Response(resizedImageBuffer, {
|
return new Response(resized, {
|
||||||
headers: {
|
headers: {
|
||||||
"Cache-Control": "public, max-age=3600, stale-while-revalidate=600",
|
"Cache-Control": "public, max-age=3600, stale-while-revalidate=600",
|
||||||
"Content-Type": "image/jpeg",
|
"Content-Type": "image/jpeg",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Gagal memproses file: ${name}`, error);
|
console.error(`Gagal mengambil file dari MinIO: ${name}`, error);
|
||||||
// Jika file tidak ditemukan atau gagal diproses, kembalikan default image
|
|
||||||
return new Response(await fs.readFile(noImage), {
|
return new Response(await fs.readFile(noImage), {
|
||||||
headers: { "Content-Type": "image/jpeg" },
|
headers: { "Content-Type": "image/jpeg" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
import fs from "fs/promises";
|
import minio, { MINIO_BUCKET } from "@/lib/minio";
|
||||||
|
|
||||||
async function imgs({
|
async function imgs({
|
||||||
search = "",
|
search = "",
|
||||||
page = 1,
|
page = 1,
|
||||||
count = 20,
|
count = 20,
|
||||||
UPLOAD_DIR_IMAGE,
|
|
||||||
}: {
|
}: {
|
||||||
search?: string;
|
search?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
count?: number;
|
count?: number;
|
||||||
UPLOAD_DIR_IMAGE: string;
|
|
||||||
}) {
|
}) {
|
||||||
const files = await fs.readdir(UPLOAD_DIR_IMAGE);
|
const objects: { name: string; url: string }[] = [];
|
||||||
|
|
||||||
return files
|
const stream = minio.listObjects(MINIO_BUCKET, "image/", true);
|
||||||
.filter(
|
|
||||||
(file) =>
|
for await (const obj of stream) {
|
||||||
file.endsWith(".jpg") || file.endsWith(".png") || file.endsWith(".jpeg")
|
if (!obj.name) continue;
|
||||||
)
|
const fileName = obj.name.replace("image/", "");
|
||||||
.filter((file) => file.includes(search))
|
if (!fileName) continue;
|
||||||
|
if (search && !fileName.includes(search)) continue;
|
||||||
|
objects.push({ name: fileName, url: `/api/img/${fileName}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = objects.length;
|
||||||
|
return objects
|
||||||
.slice((page - 1) * count, page * count)
|
.slice((page - 1) * count, page * count)
|
||||||
.map((file) => ({
|
.map((o) => ({ ...o, total }));
|
||||||
name: file,
|
|
||||||
url: `/api/img/${file}`,
|
|
||||||
total: files.length,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default imgs;
|
export default imgs;
|
||||||
|
|||||||
@@ -1,30 +1,31 @@
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import fs from "fs/promises";
|
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
|
import minio, { MINIO_BUCKET } from "@/lib/minio";
|
||||||
|
|
||||||
export async function uplImgSingle({
|
export async function uplImgSingle({
|
||||||
fileName,
|
fileName,
|
||||||
file,
|
file,
|
||||||
UPLOAD_DIR_IMAGE,
|
|
||||||
}: {
|
}: {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
file: File;
|
file: File;
|
||||||
UPLOAD_DIR_IMAGE: string;
|
|
||||||
}) {
|
}) {
|
||||||
if (!fileName || typeof fileName !== "string" || fileName.trim() === "") {
|
if (!fileName || typeof fileName !== "string" || fileName.trim() === "") {
|
||||||
console.warn(`Nama file tidak valid: ${fileName}`);
|
console.warn(`Nama file tidak valid: ${fileName}`);
|
||||||
fileName = nanoid() + ".jpg";
|
fileName = nanoid() + ".jpg";
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = path.extname(fileName).toLowerCase();
|
const ext = path.extname(fileName).toLowerCase();
|
||||||
const fileNameWithoutExt = path.basename(fileName, ext);
|
const fileNameWithoutExt = path.basename(fileName, ext);
|
||||||
const fileNameKebabCase = _.kebabCase(fileNameWithoutExt) + ext;
|
const fileNameKebabCase = _.kebabCase(fileNameWithoutExt) + ext;
|
||||||
|
const objectName = `image/${fileNameKebabCase}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
const filePath = path.join(UPLOAD_DIR_IMAGE, fileNameKebabCase);
|
await minio.putObject(MINIO_BUCKET, objectName, buffer, buffer.length, {
|
||||||
await fs.writeFile(filePath, buffer);
|
"Content-Type": file.type || "image/jpeg",
|
||||||
return filePath;
|
});
|
||||||
|
return objectName;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
return "error";
|
return "error";
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import path from "path";
|
|
||||||
import fs from "fs/promises";
|
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
import minio, { MINIO_BUCKET } from "@/lib/minio";
|
||||||
|
|
||||||
async function uplImg({
|
function sanitizeFileName(fileName: string): string {
|
||||||
files,
|
return fileName.replace(/[^a-zA-Z0-9._\-]/g, "_");
|
||||||
UPLOAD_DIR_IMAGE,
|
}
|
||||||
}: {
|
|
||||||
files: File[];
|
async function uplImg({ files }: { files: File[] }) {
|
||||||
UPLOAD_DIR_IMAGE: string;
|
|
||||||
}) {
|
|
||||||
// Validasi input
|
|
||||||
if (!Array.isArray(files) || files.length === 0) {
|
if (!Array.isArray(files) || files.length === 0) {
|
||||||
throw new Error("Tidak ada file yang diunggah");
|
throw new Error("Tidak ada file yang diunggah");
|
||||||
}
|
}
|
||||||
@@ -17,24 +13,20 @@ async function uplImg({
|
|||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
let fileName = file.name;
|
let fileName = file.name;
|
||||||
|
|
||||||
// Validasi nama file
|
|
||||||
if (!fileName || typeof fileName !== "string" || fileName.trim() === "") {
|
if (!fileName || typeof fileName !== "string" || fileName.trim() === "") {
|
||||||
console.warn(`Nama file tidak valid: ${fileName}`);
|
console.warn(`Nama file tidak valid: ${fileName}`);
|
||||||
fileName = nanoid() + ".jpg";
|
fileName = nanoid() + ".jpg";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitasi nama file untuk mencegah path traversal
|
const sanitized = sanitizeFileName(fileName);
|
||||||
const sanitizedFileName = sanitizeFileName(fileName);
|
const objectName = `image/${sanitized}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Konversi file ke buffer
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
await minio.putObject(MINIO_BUCKET, objectName, buffer, buffer.length, {
|
||||||
// Tulis file ke direktori uploads
|
"Content-Type": file.type || "image/jpeg",
|
||||||
const filePath = path.join(UPLOAD_DIR_IMAGE, sanitizedFileName);
|
});
|
||||||
await fs.writeFile(filePath, buffer);
|
console.log(`File berhasil diunggah ke MinIO: ${objectName}`);
|
||||||
|
|
||||||
console.log(`File berhasil diunggah: ${sanitizedFileName}`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Gagal mengunggah file ${fileName}:`, error);
|
console.error(`Gagal mengunggah file ${fileName}:`, error);
|
||||||
throw new Error(`Gagal mengunggah file: ${fileName}`);
|
throw new Error(`Gagal mengunggah file: ${fileName}`);
|
||||||
@@ -44,9 +36,4 @@ async function uplImg({
|
|||||||
return "ok";
|
return "ok";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fungsi untuk membersihkan nama file dari karakter yang tidak aman
|
|
||||||
function sanitizeFileName(fileName: string): string {
|
|
||||||
return fileName.replace(/[^a-zA-Z0-9._\-]/g, "_");
|
|
||||||
}
|
|
||||||
|
|
||||||
export default uplImg;
|
export default uplImg;
|
||||||
|
|||||||
@@ -4,32 +4,15 @@ import swagger from "@elysiajs/swagger";
|
|||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import getPotensi from "./_lib/get-potensi";
|
import getPotensi from "./_lib/get-potensi";
|
||||||
import img from "./_lib/img";
|
import img from "./_lib/img";
|
||||||
import fs from "fs/promises";
|
|
||||||
import path from "path";
|
|
||||||
import uplImg from "./_lib/upl-img";
|
import uplImg from "./_lib/upl-img";
|
||||||
import imgs from "./_lib/imgs";
|
import imgs from "./_lib/imgs";
|
||||||
import uplCsv from "./_lib/upl-csv";
|
import uplCsv from "./_lib/upl-csv";
|
||||||
import imgDel from "./_lib/img-del";
|
import imgDel from "./_lib/img-del";
|
||||||
import { uplImgSingle } from "./_lib/upl-img-single";
|
import { uplImgSingle } from "./_lib/upl-img-single";
|
||||||
import { uplCsvSingle } from "./_lib/upl-csv-single";
|
import { uplCsvSingle } from "./_lib/upl-csv-single";
|
||||||
|
|
||||||
const ROOT = process.cwd();
|
const ROOT = process.cwd();
|
||||||
|
|
||||||
if (!process.env.WIBU_UPLOAD_DIR)
|
|
||||||
throw new Error("WIBU_UPLOAD_DIR is not defined");
|
|
||||||
|
|
||||||
const UPLOAD_DIR = path.join(ROOT, process.env.WIBU_UPLOAD_DIR);
|
|
||||||
const UPLOAD_DIR_IMAGE = path.join(UPLOAD_DIR, "image");
|
|
||||||
|
|
||||||
// create uploads dir
|
|
||||||
fs.mkdir(UPLOAD_DIR, {
|
|
||||||
recursive: true,
|
|
||||||
}).catch(() => {});
|
|
||||||
|
|
||||||
// create image uploads dir
|
|
||||||
fs.mkdir(UPLOAD_DIR_IMAGE, {
|
|
||||||
recursive: true,
|
|
||||||
}).catch(() => {});
|
|
||||||
|
|
||||||
const corsConfig = {
|
const corsConfig = {
|
||||||
origin: "*",
|
origin: "*",
|
||||||
methods: ["GET", "POST", "PATCH", "DELETE", "PUT"] as HTTPMethod[],
|
methods: ["GET", "POST", "PATCH", "DELETE", "PUT"] as HTTPMethod[],
|
||||||
@@ -43,6 +26,7 @@ async function layanan() {
|
|||||||
const data = await prisma.layanan.findMany();
|
const data = await prisma.layanan.findMany();
|
||||||
return { data };
|
return { data };
|
||||||
}
|
}
|
||||||
|
|
||||||
const ApiServer = new Elysia()
|
const ApiServer = new Elysia()
|
||||||
.use(swagger({ path: "/api/docs" }))
|
.use(swagger({ path: "/api/docs" }))
|
||||||
.use(cors(corsConfig))
|
.use(cors(corsConfig))
|
||||||
@@ -63,7 +47,6 @@ const ApiServer = new Elysia()
|
|||||||
({ params, query }) => {
|
({ params, query }) => {
|
||||||
return img({
|
return img({
|
||||||
name: params.name,
|
name: params.name,
|
||||||
UPLOAD_DIR_IMAGE,
|
|
||||||
ROOT,
|
ROOT,
|
||||||
size: query.size,
|
size: query.size,
|
||||||
});
|
});
|
||||||
@@ -82,10 +65,7 @@ const ApiServer = new Elysia()
|
|||||||
.delete(
|
.delete(
|
||||||
"/img/:name",
|
"/img/:name",
|
||||||
({ params }) => {
|
({ params }) => {
|
||||||
return imgDel({
|
return imgDel({ name: params.name });
|
||||||
name: params.name,
|
|
||||||
UPLOAD_DIR_IMAGE,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
params: t.Object({
|
params: t.Object({
|
||||||
@@ -100,7 +80,6 @@ const ApiServer = new Elysia()
|
|||||||
search: query.search,
|
search: query.search,
|
||||||
page: query.page,
|
page: query.page,
|
||||||
count: query.count,
|
count: query.count,
|
||||||
UPLOAD_DIR_IMAGE,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -117,7 +96,7 @@ const ApiServer = new Elysia()
|
|||||||
"/upl-img",
|
"/upl-img",
|
||||||
({ body }) => {
|
({ body }) => {
|
||||||
console.log(body.title);
|
console.log(body.title);
|
||||||
return uplImg({ files: body.files, UPLOAD_DIR_IMAGE });
|
return uplImg({ files: body.files });
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
body: t.Object({
|
body: t.Object({
|
||||||
@@ -132,7 +111,6 @@ const ApiServer = new Elysia()
|
|||||||
return uplImgSingle({
|
return uplImgSingle({
|
||||||
fileName: body.name,
|
fileName: body.name,
|
||||||
file: body.file,
|
file: body.file,
|
||||||
UPLOAD_DIR_IMAGE,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
12
src/lib/minio.ts
Normal file
12
src/lib/minio.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Client } from "minio";
|
||||||
|
|
||||||
|
const minioClient = new Client({
|
||||||
|
endPoint: process.env.MINIO_ENDPOINT!,
|
||||||
|
accessKey: process.env.MINIO_ACCESS_KEY!,
|
||||||
|
secretKey: process.env.MINIO_SECRET_KEY!,
|
||||||
|
useSSL: process.env.MINIO_USE_SSL === "true",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MINIO_BUCKET = process.env.MINIO_BUCKET!;
|
||||||
|
|
||||||
|
export default minioClient;
|
||||||
Reference in New Issue
Block a user