diff --git a/bun.lockb b/bun.lockb index e30c0976..14f3d9e9 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index e4b3bc0b..cfab3f92 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@types/bun": "^1.2.2", "@types/leaflet": "^1.9.20", "@types/lodash": "^4.17.16", + "@types/mime-types": "^3.0.1", "@types/nodemailer": "^7.0.2", "add": "^2.0.6", "adm-zip": "^0.5.16", @@ -72,6 +73,7 @@ "leaflet": "^1.9.4", "list": "^2.0.19", "lodash": "^4.17.21", + "mime-types": "^3.0.2", "motion": "^12.4.1", "nanoid": "^5.1.5", "next": "^15.5.2", diff --git a/prisma/seed_assets.ts b/prisma/seed_assets.ts index 5b086254..79e72337 100644 --- a/prisma/seed_assets.ts +++ b/prisma/seed_assets.ts @@ -1,27 +1,222 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ +// /* eslint-disable @typescript-eslint/no-unused-vars */ +// // prisma/seedAssets.ts +// import prisma from "@/lib/prisma"; +// import AdmZip from "adm-zip"; +// import fs from "fs/promises"; +// import path from "path"; +// import sharp from "sharp"; +// import fetchWithRetry from "./data/fetchWithRetry"; + +// const UPLOADS_DIR = path.resolve( +// process.env.WIBU_UPLOAD_DIR || "uploads" +// ); + +// // --- Helper: deteksi kategori file --- +// function detectCategory(filename: string): "image" | "document" | "other" { +// const ext = path.extname(filename).toLowerCase(); +// if ([".jpg", ".jpeg", ".png", ".webp"].includes(ext)) return "image"; +// if ([".pdf", ".doc", ".docx"].includes(ext)) return "document"; +// return "other"; +// } + +// // --- Helper: recursive walk dir --- +// async function walkDir( +// dir: string, +// fileList: string[] = [] +// ): Promise { +// const entries = await fs.readdir(dir, { withFileTypes: true }); + +// for (const entry of entries) { +// const fullPath = path.join(dir, entry.name); + +// if (entry.isDirectory()) { +// if (entry.name === "__MACOSX") continue; // skip folder sampah +// await walkDir(fullPath, fileList); +// } else { +// if (entry.name.startsWith(".") || entry.name === ".DS_Store") continue; // skip file sampah +// fileList.push(fullPath); +// } +// } + +// return fileList; +// } + +// export default async function seedAssets() { +// console.log("🚀 Seeding assets..."); +// console.log("📁 Upload dir:", UPLOADS_DIR); + +// await fs.mkdir(UPLOADS_DIR, { recursive: true }); + +// // 1. Download zip +// const url = +// "https://cld-dkr-makuro-seafile.wibudev.com/f/eadd52c5bd654ec789a3/?dl=1"; +// const res = await fetchWithRetry(url, 3, 20000); + +// // Validasi content-type +// const contentType = res.headers.get("content-type"); +// if (!contentType?.includes("zip")) { +// throw new Error(`Invalid content-type (${contentType}). Expected ZIP file`); +// } + +// const buffer = Buffer.from(await res.arrayBuffer()); + +// // Validasi ukuran file +// if (buffer.length < 100) { +// throw new Error("Downloaded ZIP is empty or corrupted"); +// } + +// // Validasi signature ZIP ("PK") +// if (buffer.toString("utf8", 0, 2) !== "PK") { +// throw new Error("Invalid ZIP signature (PK not found)"); +// } + +// // 2. Extract zip ke folder tmp +// const extractDir = path.join(process.cwd(), "tmp_assets"); +// await fs.rm(extractDir, { recursive: true, force: true }); +// await fs.mkdir(extractDir, { recursive: true }); + +// let zip: AdmZip; + +// try { +// zip = new AdmZip(buffer); +// } catch (err) { +// throw new Error("Failed to parse ZIP file (corrupted or invalid)"); +// } + +// try { +// zip.extractAllTo(extractDir, true); +// } catch (err) { +// throw new Error("Failed to extract ZIP contents"); +// } + +// // 3. Cari semua file valid (recursive) +// const files = await walkDir(extractDir); + +// // 4. Loop tiap file & simpan +// for (const filePath of files) { +// const entryName = path.basename(filePath); +// const category = detectCategory(entryName); + +// let finalName = entryName; +// let mimeType = "application/octet-stream"; +// let targetPath = ""; + +// if (category === "image") { +// const fileBaseName = path.parse(entryName).name; +// finalName = `${fileBaseName}.webp`; +// targetPath = path.join(UPLOADS_DIR, "images", finalName); +// await fs.mkdir(path.dirname(targetPath), { recursive: true }); +// await sharp(filePath).webp({ quality: 80 }).toFile(targetPath); +// mimeType = "image/webp"; +// } else if (category === "document") { +// targetPath = path.join(UPLOADS_DIR, "documents", entryName); +// await fs.mkdir(path.dirname(targetPath), { recursive: true }); +// await fs.copyFile(filePath, targetPath); +// mimeType = "application/pdf"; +// } else { +// targetPath = path.join(UPLOADS_DIR, "other", entryName); +// await fs.mkdir(path.dirname(targetPath), { recursive: true }); +// await fs.copyFile(filePath, targetPath); +// } + +// const existing = await prisma.fileStorage.findUnique({ +// where: { name: finalName }, +// }); + +// if (existing) { +// // Restore kalau soft deleted +// await prisma.fileStorage.update({ +// where: { name: finalName }, +// data: { +// path: targetPath, +// realName: entryName, +// mimeType, +// link: `/uploads/${category}/${finalName}`, +// category, +// deletedAt: null, +// isActive: true, +// }, +// }); + +// console.log(`♻️ restored: ${category}/${finalName}`); +// } else { +// await prisma.fileStorage.create({ +// data: { +// name: finalName, +// realName: entryName, +// path: targetPath, +// mimeType, +// link: `/uploads/${category}/${finalName}`, +// category, +// }, +// }); + +// console.log(`📂 created: ${category}/${finalName}`); +// } + +// console.log(`📂 saved: ${category}/${finalName}`); +// } + +// // 6. Cleanup +// await fs.rm(extractDir, { recursive: true, force: true }); + +// console.log("✅ Selesai seed assets!"); +// console.log("DB URL (asset):", process.env.DATABASE_URL); + +// } + +// // --- Auto run kalau dipanggil langsung --- +// if (import.meta.main) { +// seedAssets() +// .catch((err) => { +// console.error("❌ Error seeding assets:", err); +// process.exit(1); +// }) +// .finally(async () => { +// await prisma.$disconnect(); +// }); +// } + + // prisma/seedAssets.ts import prisma from "@/lib/prisma"; import AdmZip from "adm-zip"; import fs from "fs/promises"; import path from "path"; import sharp from "sharp"; +import mime from "mime-types"; import fetchWithRetry from "./data/fetchWithRetry"; -const UPLOADS_DIR = - process.env.WIBU_UPLOAD_DIR || path.join(process.cwd(), "uploads"); +/* ========================= + * CONFIG + * ========================= */ +const UPLOADS_DIR = path.resolve( + process.env.WIBU_UPLOAD_DIR || "uploads" +); -// --- Helper: deteksi kategori file --- -function detectCategory(filename: string): "image" | "document" | "other" { +const TMP_DIR = path.join(process.cwd(), "tmp_assets"); + +const CATEGORY_DIR: Record = { + image: "images", + document: "documents", + other: "other", +}; + +type FileCategory = "image" | "document" | "other"; + +/* ========================= + * HELPERS + * ========================= */ +function detectCategory(filename: string): FileCategory { const ext = path.extname(filename).toLowerCase(); if ([".jpg", ".jpeg", ".png", ".webp"].includes(ext)) return "image"; - if ([".pdf", ".doc", ".docx"].includes(ext)) return "document"; + if ([".pdf", ".doc", ".docx", ".txt"].includes(ext)) return "document"; return "other"; } -// --- Helper: recursive walk dir --- async function walkDir( dir: string, - fileList: string[] = [] + result: string[] = [] ): Promise { const entries = await fs.readdir(dir, { withFileTypes: true }); @@ -29,139 +224,139 @@ async function walkDir( const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { - if (entry.name === "__MACOSX") continue; // skip folder sampah - await walkDir(fullPath, fileList); + if (entry.name === "__MACOSX") continue; + await walkDir(fullPath, result); } else { - if (entry.name.startsWith(".") || entry.name === ".DS_Store") continue; // skip file sampah - fileList.push(fullPath); + if (entry.name.startsWith(".") || entry.name === ".DS_Store") continue; + result.push(fullPath); } } - return fileList; + return result; } +async function ensureDir(dir: string) { + await fs.mkdir(dir, { recursive: true }); +} + +/* ========================= + * FILE PROCESSORS + * ========================= */ +async function processImage(filePath: string, entryName: string) { + const baseName = path.parse(entryName).name; + const finalName = `${baseName}.webp`; + const targetDir = path.join(UPLOADS_DIR, CATEGORY_DIR.image); + const targetPath = path.join(targetDir, finalName); + + await ensureDir(targetDir); + await sharp(filePath).webp({ quality: 80 }).toFile(targetPath); + + return { + finalName, + targetPath, + mimeType: "image/webp", + }; +} + +async function processNonImage( + filePath: string, + entryName: string, + category: FileCategory +) { + const targetDir = path.join(UPLOADS_DIR, CATEGORY_DIR[category]); + const targetPath = path.join(targetDir, entryName); + + await ensureDir(targetDir); + await fs.copyFile(filePath, targetPath); + + return { + finalName: entryName, + targetPath, + mimeType: mime.lookup(entryName) || "application/octet-stream", + }; +} + +/* ========================= + * MAIN + * ========================= */ export default async function seedAssets() { console.log("🚀 Seeding assets..."); + console.log("📁 Upload dir:", UPLOADS_DIR); - // 1. Download zip + await ensureDir(UPLOADS_DIR); + + /* ===== Download ZIP ===== */ const url = "https://cld-dkr-makuro-seafile.wibudev.com/f/eadd52c5bd654ec789a3/?dl=1"; const res = await fetchWithRetry(url, 3, 20000); - // Validasi content-type - const contentType = res.headers.get("content-type"); - if (!contentType?.includes("zip")) { - throw new Error(`Invalid content-type (${contentType}). Expected ZIP file`); + if (!res.headers.get("content-type")?.includes("zip")) { + throw new Error("Invalid ZIP content-type"); } const buffer = Buffer.from(await res.arrayBuffer()); - - // Validasi ukuran file - if (buffer.length < 100) { - throw new Error("Downloaded ZIP is empty or corrupted"); + if (buffer.length < 100 || buffer.toString("utf8", 0, 2) !== "PK") { + throw new Error("Corrupted ZIP file"); } - // Validasi signature ZIP ("PK") - if (buffer.toString("utf8", 0, 2) !== "PK") { - throw new Error("Invalid ZIP signature (PK not found)"); - } + /* ===== Extract ===== */ + await fs.rm(TMP_DIR, { recursive: true, force: true }); + await ensureDir(TMP_DIR); - // 2. Extract zip ke folder tmp - const extractDir = path.join(process.cwd(), "tmp_assets"); - await fs.rm(extractDir, { recursive: true, force: true }); - await fs.mkdir(extractDir, { recursive: true }); + const zip = new AdmZip(buffer); + zip.extractAllTo(TMP_DIR, true); - let zip: AdmZip; + /* ===== Process Files ===== */ + const files = await walkDir(TMP_DIR); - try { - zip = new AdmZip(buffer); - } catch (err) { - throw new Error("Failed to parse ZIP file (corrupted or invalid)"); - } - - try { - zip.extractAllTo(extractDir, true); - } catch (err) { - throw new Error("Failed to extract ZIP contents"); - } - - // 3. Cari semua file valid (recursive) - const files = await walkDir(extractDir); - - // 4. Loop tiap file & simpan for (const filePath of files) { const entryName = path.basename(filePath); const category = detectCategory(entryName); - let finalName = entryName; - let mimeType = "application/octet-stream"; - let targetPath = ""; + let result; if (category === "image") { - const fileBaseName = path.parse(entryName).name; - finalName = `${fileBaseName}.webp`; - targetPath = path.join(UPLOADS_DIR, "images", finalName); - await fs.mkdir(path.dirname(targetPath), { recursive: true }); - await sharp(filePath).webp({ quality: 80 }).toFile(targetPath); - mimeType = "image/webp"; - } else if (category === "document") { - targetPath = path.join(UPLOADS_DIR, "documents", entryName); - await fs.mkdir(path.dirname(targetPath), { recursive: true }); - await fs.copyFile(filePath, targetPath); - mimeType = "application/pdf"; + result = await processImage(filePath, entryName); } else { - targetPath = path.join(UPLOADS_DIR, "other", entryName); - await fs.mkdir(path.dirname(targetPath), { recursive: true }); - await fs.copyFile(filePath, targetPath); + result = await processNonImage(filePath, entryName, category); } + const { finalName, targetPath, mimeType } = result; + const existing = await prisma.fileStorage.findUnique({ where: { name: finalName }, }); + const data = { + name: finalName, + realName: entryName, + path: targetPath, + mimeType, + link: `/uploads/${CATEGORY_DIR[category]}/${finalName}`, + category, + deletedAt: null, + isActive: true, + }; + if (existing) { - // Restore kalau soft deleted await prisma.fileStorage.update({ where: { name: finalName }, - data: { - path: targetPath, - realName: entryName, - mimeType, - link: `/uploads/${category}/${finalName}`, - category, - deletedAt: null, - isActive: true, - }, + data, }); - console.log(`♻️ restored: ${category}/${finalName}`); } else { - await prisma.fileStorage.create({ - data: { - name: finalName, - realName: entryName, - path: targetPath, - mimeType, - link: `/uploads/${category}/${finalName}`, - category, - }, - }); - + await prisma.fileStorage.create({ data }); console.log(`📂 created: ${category}/${finalName}`); } - - console.log(`📂 saved: ${category}/${finalName}`); } - // 6. Cleanup - await fs.rm(extractDir, { recursive: true, force: true }); + /* ===== Cleanup ===== */ + await fs.rm(TMP_DIR, { recursive: true, force: true }); console.log("✅ Selesai seed assets!"); - console.log("DB URL (asset):", process.env.DATABASE_URL); - } -// --- Auto run kalau dipanggil langsung --- +/* ===== Auto Run ===== */ if (import.meta.main) { seedAssets() .catch((err) => { diff --git a/src/app/api/[[...slugs]]/route.ts b/src/app/api/[[...slugs]]/route.ts index ed4e2f1b..22580aec 100644 --- a/src/app/api/[[...slugs]]/route.ts +++ b/src/app/api/[[...slugs]]/route.ts @@ -65,7 +65,7 @@ const Utils = new Elysia({ }).get("/version", async () => { const packageJson = await fs.readFile( path.join(ROOT, "package.json"), - "utf-8" + "utf-8", ); const version = JSON.parse(packageJson).version; return { version }; @@ -78,9 +78,9 @@ const ApiServer = new Elysia() .use(swagger({ path: "/api/docs" })) .use( staticPlugin({ - assets: process.env.WIBU_UPLOAD_DIR, + assets: UPLOAD_DIR, prefix: "/uploads", - }) + }), ) .use(cors(corsConfig)) .use(Utils) @@ -127,9 +127,9 @@ const ApiServer = new Elysia() query: t.Optional( t.Object({ size: t.Optional(t.Number()), - }) + }), ), - } + }, ) .delete( "/img/:name", @@ -143,7 +143,7 @@ const ApiServer = new Elysia() params: t.Object({ name: t.String(), }), - } + }, ) .get( "/imgs", @@ -161,9 +161,9 @@ const ApiServer = new Elysia() page: t.Number({ default: 1 }), count: t.Number({ default: 10 }), search: t.String({ default: "" }), - }) + }), ), - } + }, ) .post( "/upl-img", @@ -176,7 +176,7 @@ const ApiServer = new Elysia() title: t.String(), files: t.Files({ multiple: true }), }), - } + }, ) .post( "/upl-img-single", @@ -192,7 +192,7 @@ const ApiServer = new Elysia() name: t.String(), file: t.File(), }), - } + }, ) .post( "/upl-csv-single", @@ -204,7 +204,7 @@ const ApiServer = new Elysia() name: t.String(), file: t.File(), }), - } + }, ) .post( "/upl-csv", @@ -215,8 +215,8 @@ const ApiServer = new Elysia() body: t.Object({ files: t.Files(), }), - } - ) + }, + ), ); export const GET = ApiServer.handle;