fix(storage): migrate fileStorage handlers and seeder from local disk/Seafile to MinIO

Root cause: images not showing because:
1. seed_assets.ts was listing from Seafile (unreliable, 502 errors)
2. fileStorage create/findUniq/del handlers used local disk (not available in containers)
3. link format in file-storage.json had inconsistent path prefix

Changes:
- seed_assets.ts: list objects from MinIO bucket instead of Seafile API; seed
  link as /api/img/{name}, path as "image" (matches MinIO prefix)
- fileStorage/create.ts: upload image/audio/document to MinIO via putObject;
  remove WIBU_UPLOAD_DIR dependency and all fs.writeFile calls
- fileStorage/findUniq.ts: stream file from MinIO via getObject; remove
  WIBU_UPLOAD_DIR and fs.readFile
- fileStorage/del.ts: delete from MinIO via removeObject; remove fs.unlink
- file-storage.json: fix path field to "image" (was full path); all 80 entries
  already have correct link=/api/img/{name} format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-23 14:28:08 +08:00
parent d145611221
commit 2958950585
5 changed files with 433 additions and 299 deletions

View File

@@ -1,41 +1,28 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
import fs from "fs/promises";
import path from "path";
import { nanoid } from "nanoid";
import sharp from "sharp";
import zlib from "zlib";
const UPLOAD_DIR = process.env.WIBU_UPLOAD_DIR;
import minio, { MINIO_BUCKET } from "@/lib/minio";
const fileStorageCreate = async (context: Context) => {
const body = (await context.body) as {
name: string;
file: File;
};
const body = (await context.body) as { name: string; file: File };
const file = body.file;
const name = body.name;
if (!file) return { status: 400, body: "No file uploaded" };
if (!name) return { status: 400, body: "No name provided" };
if (!UPLOAD_DIR) return { status: 500, body: "UPLOAD_DIR is not defined" };
const isImage = file.type.startsWith("image/");
const isAudio = file.type.startsWith("audio/");
const category = isImage ? "image" : isAudio ? "audio" : "document";
const prefix = category === "image" ? "image" : category === "audio" ? "audio" : "documents";
const pathName = category === "image" ? "images" : category === "audio" ? "audio" : "documents";
const rootPath = path.join(UPLOAD_DIR, pathName);
await fs.mkdir(rootPath, { recursive: true });
// Convert File ke Buffer
const buffer = Buffer.from(await file.arrayBuffer());
let finalName = nanoid();
let finalMimeType = file.type;
if (isImage) {
// Simpan sebagai WebP untuk kompresi maksimal
const mobileBuffer = await sharp(buffer)
.resize({ width: 720 })
.webp({ quality: 80 })
@@ -49,40 +36,29 @@ const fileStorageCreate = async (context: Context) => {
const mobileName = `${finalName}-mobile.webp`;
const desktopName = `${finalName}-desktop.webp`;
await fs.writeFile(path.join(rootPath, mobileName), mobileBuffer);
await fs.writeFile(path.join(rootPath, desktopName), desktopBuffer);
await minio.putObject(MINIO_BUCKET, `${prefix}/${mobileName}`, mobileBuffer, mobileBuffer.length, { "Content-Type": "image/webp" });
await minio.putObject(MINIO_BUCKET, `${prefix}/${desktopName}`, desktopBuffer, desktopBuffer.length, { "Content-Type": "image/webp" });
// Simpan metadata untuk versi desktop sebagai default
finalName = desktopName;
finalMimeType = "image/webp";
} else if (isAudio) {
// Simpan file audio tanpa kompresi
const ext = file.name.split(".").pop() || "mp3";
finalName = `${finalName}.${ext}`;
await fs.writeFile(path.join(rootPath, finalName), buffer);
await minio.putObject(MINIO_BUCKET, `${prefix}/${finalName}`, buffer, buffer.length, { "Content-Type": file.type });
} else {
// Jika file adalah PDF, simpan tanpa kompresi
if (file.type === "application/pdf") {
await fs.writeFile(path.join(rootPath, finalName), buffer);
}
// Jika file lain, kompres dengan gzip
else {
const gzBuffer = zlib.gzipSync(buffer);
const compressedName = `${finalName}.gz`;
await fs.writeFile(path.join(rootPath, compressedName), gzBuffer);
finalName = compressedName;
finalMimeType = "application/gzip";
}
const ext = file.name.split(".").pop() || "bin";
finalName = `${finalName}.${ext}`;
await minio.putObject(MINIO_BUCKET, `${prefix}/${finalName}`, buffer, buffer.length, { "Content-Type": file.type });
}
const data = await prisma.fileStorage.create({
data: {
name: finalName,
realName: file.name,
path: pathName, // Store relative path (e.g., "images", "audio", "documents")
path: prefix,
mimeType: finalMimeType,
category,
link: `/api/fileStorage/findUnique/${finalName}`,
link: isImage ? `/api/img/${finalName}` : `/api/fileStorage/findUnique/${finalName}`,
},
});

View File

@@ -1,60 +1,26 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
import fs from "fs/promises";
import path from "path";
const UPLOAD_DIR = process.env.WIBU_UPLOAD_DIR;
import minio, { MINIO_BUCKET } from "@/lib/minio";
const fileStorageDelete = async (context: Context) => {
const { params } = context;
const id = (context.params as { id: string })?.id;
const id = params?.id as string;
if (!id) return { status: 400, body: "ID file tidak ditemukan" };
if (!id) {
return {
status: 400,
body: "ID file tidak ditemukan",
};
}
const file = await prisma.fileStorage.findUnique({ where: { id } });
if (!UPLOAD_DIR) {
return {
status: 500,
body: "UPLOAD_DIR belum dikonfigurasi",
};
}
// Cek file dari database
const file = await prisma.fileStorage.findUnique({
where: { id },
});
if (!file) {
return {
status: 404,
body: "File tidak ditemukan di database",
};
}
const filePath = path.join(UPLOAD_DIR, file.path, file.name);
if (!file) return { status: 404, body: "File tidak ditemukan di database" };
try {
// Hapus file dari filesystem
await fs.unlink(filePath);
await minio.removeObject(MINIO_BUCKET, `${file.path}/${file.name}`);
} catch (err) {
console.error("Gagal hapus file:", err);
// Tetap lanjutkan hapus dari database meskipun file fisik tidak ditemukan
console.error("Gagal hapus file dari MinIO:", err);
// Tetap lanjutkan hapus dari database
}
// Hapus dari database
await prisma.fileStorage.delete({
where: { id },
});
await prisma.fileStorage.delete({ where: { id } });
return {
message: "File berhasil dihapus",
deletedId: id,
};
return { message: "File berhasil dihapus", deletedId: id };
};
export default fileStorageDelete;

View File

@@ -1,44 +1,36 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
import fs from "fs/promises";
import path from "path";
const UPLOAD_DIR = process.env.WIBU_UPLOAD_DIR;
import minio, { MINIO_BUCKET } from "@/lib/minio";
const fileStorageFindUnique = async (context: Context) => {
const { name } = context.params;
const data = await prisma.fileStorage.findUnique({
where: {
name,
},
});
const data = await prisma.fileStorage.findUnique({ where: { name } });
if (!data) {
context.set.status = "No Content";
return {
status: 404,
message: "File not found",
};
context.set.status = "Not Found";
return { status: 404, message: "File not found" };
}
if (!UPLOAD_DIR) {
context.set.status = "Internal Server Error";
return {
status: 500,
message: "UPLOAD_DIR is not defined",
try {
const stream = await minio.getObject(MINIO_BUCKET, `${data.path}/${data.name}`);
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk));
}
const file = Buffer.concat(chunks);
context.set.headers = {
"Content-Type": data.mimeType,
"Content-Length": String(file.length),
"Cache-Control": "public, max-age=3600, stale-while-revalidate=600",
};
return file;
} catch {
context.set.status = "Not Found";
return { status: 404, message: "File not found in storage" };
}
console.log(data);
const file = await fs.readFile(path.join(UPLOAD_DIR, data.path, data.name));
context.set.headers = {
"Content-Type": data.mimeType,
"Content-Length": file.length,
};
return file;
};
export default fileStorageFindUnique;