Merge pull request 'Fix uploads -1' (#52) from nico/21-jan-26 into staggingweb

Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/52
This commit is contained in:
2026-01-21 14:10:42 +08:00
4 changed files with 303 additions and 106 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -45,6 +45,7 @@
"@types/bun": "^1.2.2", "@types/bun": "^1.2.2",
"@types/leaflet": "^1.9.20", "@types/leaflet": "^1.9.20",
"@types/lodash": "^4.17.16", "@types/lodash": "^4.17.16",
"@types/mime-types": "^3.0.1",
"@types/nodemailer": "^7.0.2", "@types/nodemailer": "^7.0.2",
"add": "^2.0.6", "add": "^2.0.6",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
@@ -72,6 +73,7 @@
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"list": "^2.0.19", "list": "^2.0.19",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mime-types": "^3.0.2",
"motion": "^12.4.1", "motion": "^12.4.1",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"next": "^15.5.2", "next": "^15.5.2",

View File

@@ -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<string[]> {
// 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 // prisma/seedAssets.ts
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import AdmZip from "adm-zip"; import AdmZip from "adm-zip";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import sharp from "sharp"; import sharp from "sharp";
import mime from "mime-types";
import fetchWithRetry from "./data/fetchWithRetry"; 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 --- const TMP_DIR = path.join(process.cwd(), "tmp_assets");
function detectCategory(filename: string): "image" | "document" | "other" {
const CATEGORY_DIR: Record<FileCategory, string> = {
image: "images",
document: "documents",
other: "other",
};
type FileCategory = "image" | "document" | "other";
/* =========================
* HELPERS
* ========================= */
function detectCategory(filename: string): FileCategory {
const ext = path.extname(filename).toLowerCase(); const ext = path.extname(filename).toLowerCase();
if ([".jpg", ".jpeg", ".png", ".webp"].includes(ext)) return "image"; 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"; return "other";
} }
// --- Helper: recursive walk dir ---
async function walkDir( async function walkDir(
dir: string, dir: string,
fileList: string[] = [] result: string[] = []
): Promise<string[]> { ): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true }); const entries = await fs.readdir(dir, { withFileTypes: true });
@@ -29,139 +224,139 @@ async function walkDir(
const fullPath = path.join(dir, entry.name); const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) { if (entry.isDirectory()) {
if (entry.name === "__MACOSX") continue; // skip folder sampah if (entry.name === "__MACOSX") continue;
await walkDir(fullPath, fileList); await walkDir(fullPath, result);
} else { } else {
if (entry.name.startsWith(".") || entry.name === ".DS_Store") continue; // skip file sampah if (entry.name.startsWith(".") || entry.name === ".DS_Store") continue;
fileList.push(fullPath); 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() { export default async function seedAssets() {
console.log("🚀 Seeding assets..."); console.log("🚀 Seeding assets...");
console.log("📁 Upload dir:", UPLOADS_DIR);
// 1. Download zip await ensureDir(UPLOADS_DIR);
/* ===== Download ZIP ===== */
const url = const url =
"https://cld-dkr-makuro-seafile.wibudev.com/f/eadd52c5bd654ec789a3/?dl=1"; "https://cld-dkr-makuro-seafile.wibudev.com/f/eadd52c5bd654ec789a3/?dl=1";
const res = await fetchWithRetry(url, 3, 20000); const res = await fetchWithRetry(url, 3, 20000);
// Validasi content-type if (!res.headers.get("content-type")?.includes("zip")) {
const contentType = res.headers.get("content-type"); throw new Error("Invalid ZIP content-type");
if (!contentType?.includes("zip")) {
throw new Error(`Invalid content-type (${contentType}). Expected ZIP file`);
} }
const buffer = Buffer.from(await res.arrayBuffer()); const buffer = Buffer.from(await res.arrayBuffer());
if (buffer.length < 100 || buffer.toString("utf8", 0, 2) !== "PK") {
// Validasi ukuran file throw new Error("Corrupted ZIP file");
if (buffer.length < 100) {
throw new Error("Downloaded ZIP is empty or corrupted");
} }
// Validasi signature ZIP ("PK") /* ===== Extract ===== */
if (buffer.toString("utf8", 0, 2) !== "PK") { await fs.rm(TMP_DIR, { recursive: true, force: true });
throw new Error("Invalid ZIP signature (PK not found)"); await ensureDir(TMP_DIR);
}
// 2. Extract zip ke folder tmp const zip = new AdmZip(buffer);
const extractDir = path.join(process.cwd(), "tmp_assets"); zip.extractAllTo(TMP_DIR, true);
await fs.rm(extractDir, { recursive: true, force: true });
await fs.mkdir(extractDir, { recursive: 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) { for (const filePath of files) {
const entryName = path.basename(filePath); const entryName = path.basename(filePath);
const category = detectCategory(entryName); const category = detectCategory(entryName);
let finalName = entryName; let result;
let mimeType = "application/octet-stream";
let targetPath = "";
if (category === "image") { if (category === "image") {
const fileBaseName = path.parse(entryName).name; result = await processImage(filePath, entryName);
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 { } else {
targetPath = path.join(UPLOADS_DIR, "other", entryName); result = await processNonImage(filePath, entryName, category);
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.copyFile(filePath, targetPath);
} }
const { finalName, targetPath, mimeType } = result;
const existing = await prisma.fileStorage.findUnique({ const existing = await prisma.fileStorage.findUnique({
where: { name: finalName }, 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) { if (existing) {
// Restore kalau soft deleted
await prisma.fileStorage.update({ await prisma.fileStorage.update({
where: { name: finalName }, where: { name: finalName },
data: { data,
path: targetPath,
realName: entryName,
mimeType,
link: `/uploads/${category}/${finalName}`,
category,
deletedAt: null,
isActive: true,
},
}); });
console.log(`♻️ restored: ${category}/${finalName}`); console.log(`♻️ restored: ${category}/${finalName}`);
} else { } else {
await prisma.fileStorage.create({ await prisma.fileStorage.create({ data });
data: {
name: finalName,
realName: entryName,
path: targetPath,
mimeType,
link: `/uploads/${category}/${finalName}`,
category,
},
});
console.log(`📂 created: ${category}/${finalName}`); console.log(`📂 created: ${category}/${finalName}`);
} }
console.log(`📂 saved: ${category}/${finalName}`);
} }
// 6. Cleanup /* ===== Cleanup ===== */
await fs.rm(extractDir, { recursive: true, force: true }); await fs.rm(TMP_DIR, { recursive: true, force: true });
console.log("✅ Selesai seed assets!"); 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) { if (import.meta.main) {
seedAssets() seedAssets()
.catch((err) => { .catch((err) => {

View File

@@ -65,7 +65,7 @@ const Utils = new Elysia({
}).get("/version", async () => { }).get("/version", async () => {
const packageJson = await fs.readFile( const packageJson = await fs.readFile(
path.join(ROOT, "package.json"), path.join(ROOT, "package.json"),
"utf-8" "utf-8",
); );
const version = JSON.parse(packageJson).version; const version = JSON.parse(packageJson).version;
return { version }; return { version };
@@ -78,9 +78,9 @@ const ApiServer = new Elysia()
.use(swagger({ path: "/api/docs" })) .use(swagger({ path: "/api/docs" }))
.use( .use(
staticPlugin({ staticPlugin({
assets: process.env.WIBU_UPLOAD_DIR, assets: UPLOAD_DIR,
prefix: "/uploads", prefix: "/uploads",
}) }),
) )
.use(cors(corsConfig)) .use(cors(corsConfig))
.use(Utils) .use(Utils)
@@ -127,9 +127,9 @@ const ApiServer = new Elysia()
query: t.Optional( query: t.Optional(
t.Object({ t.Object({
size: t.Optional(t.Number()), size: t.Optional(t.Number()),
}) }),
), ),
} },
) )
.delete( .delete(
"/img/:name", "/img/:name",
@@ -143,7 +143,7 @@ const ApiServer = new Elysia()
params: t.Object({ params: t.Object({
name: t.String(), name: t.String(),
}), }),
} },
) )
.get( .get(
"/imgs", "/imgs",
@@ -161,9 +161,9 @@ const ApiServer = new Elysia()
page: t.Number({ default: 1 }), page: t.Number({ default: 1 }),
count: t.Number({ default: 10 }), count: t.Number({ default: 10 }),
search: t.String({ default: "" }), search: t.String({ default: "" }),
}) }),
), ),
} },
) )
.post( .post(
"/upl-img", "/upl-img",
@@ -176,7 +176,7 @@ const ApiServer = new Elysia()
title: t.String(), title: t.String(),
files: t.Files({ multiple: true }), files: t.Files({ multiple: true }),
}), }),
} },
) )
.post( .post(
"/upl-img-single", "/upl-img-single",
@@ -192,7 +192,7 @@ const ApiServer = new Elysia()
name: t.String(), name: t.String(),
file: t.File(), file: t.File(),
}), }),
} },
) )
.post( .post(
"/upl-csv-single", "/upl-csv-single",
@@ -204,7 +204,7 @@ const ApiServer = new Elysia()
name: t.String(), name: t.String(),
file: t.File(), file: t.File(),
}), }),
} },
) )
.post( .post(
"/upl-csv", "/upl-csv",
@@ -215,8 +215,8 @@ const ApiServer = new Elysia()
body: t.Object({ body: t.Object({
files: t.Files(), files: t.Files(),
}), }),
} },
) ),
); );
export const GET = ApiServer.handle; export const GET = ApiServer.handle;