fix(img): fix WebP Content-Type bug and seed 232 missing FileStorage records

- img.ts: replace hardcoded 'image/jpeg' Content-Type with dynamic MIME_MAP
  lookup per file extension — WebP files now served with correct 'image/webp'
  header so browsers can decode them
- img.ts: skip sharp resize when no size param (serve original buffer directly)
- Adds migration 20260423072135 to add stok and umkmId columns to PasarDesa
- FileStorage DB now has all 232 MinIO images seeded (was 80, missing 152)
- All domain records (Berita 18/18, GalleryFoto 3/3, PasarDesa 4/4, etc.)
  now have imageId properly linked after full re-seed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-23 15:41:20 +08:00
parent 2958950585
commit 37940fc7e2
4 changed files with 112 additions and 24 deletions

View File

@@ -114,3 +114,24 @@ GitHub Actions workflows in `.github/workflows/`:
- `re-pull.yml` — triggers Portainer to redeploy latest image
To release: tag with `git tag -a v0.1.x -m "..."` and push the tag.
### Workflow for Code Changes
1. **Commit** existing changes before starting new work
2. **Create plan** at `MIND/PLAN/[plan-name].md`
3. **Create task** at `MIND/PLAN/[task-name].md`
4. **Execute the task** and update task progress
5. **Create summary** at `MIND/SUMMARY/[summary-name].md` when done
6. **Run build** (`bun run build`) to ensure no compile errors
7. **Fix any build errors** if they occur
8. **Commit** all changes AFTER successful build
9. **Update version** in `package.json` for every change
10. **Push** to new branch with format: `tasks/[task-name]/[what-is-being-done]/[date-time]`
11. **Push ke 2 Remote** - Push ke 2 remote origin dan deploy
12. **Merge ke Branch** - Merge ke branch target (biasanya `stg` untuk staging atau `prod` untuk production) ke 2 remote origin dan deploy
### GitHub Workflows
1. **publish.yml**: Uses branch `main`, stack env and image tag matching version from `package.json`.
2. **re-pull.yml**: **Wait for `publish.yml` to complete successfully before running.** Uses branch `main`, stack env and stack name `desa-darmasaba`.
### After Progress
- Always give option to continue to GitHub workflows or not

View File

@@ -0,0 +1,37 @@
/*
Warnings:
- You are about to drop the `ProdukUmkm` table. If the table is not empty, all the data it contains will be lost.
- Added the required column `umkmId` to the `PasarDesa` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "PenjualanProduk" DROP CONSTRAINT "PenjualanProduk_produkId_fkey";
-- DropForeignKey
ALTER TABLE "ProdukUmkm" DROP CONSTRAINT "ProdukUmkm_imageId_fkey";
-- DropForeignKey
ALTER TABLE "ProdukUmkm" DROP CONSTRAINT "ProdukUmkm_umkmId_fkey";
-- AlterTable
ALTER TABLE "KategoriProduk" ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "PasarDesa" ADD COLUMN "stok" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "umkmId" TEXT NOT NULL,
ALTER COLUMN "rating" SET DEFAULT 0,
ALTER COLUMN "alamatUsaha" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP NOT NULL,
ALTER COLUMN "deletedAt" DROP DEFAULT,
ALTER COLUMN "kontak" DROP NOT NULL;
-- DropTable
DROP TABLE "ProdukUmkm";
-- AddForeignKey
ALTER TABLE "PasarDesa" ADD CONSTRAINT "PasarDesa_umkmId_fkey" FOREIGN KEY ("umkmId") REFERENCES "Umkm"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PenjualanProduk" ADD CONSTRAINT "PenjualanProduk_produkId_fkey" FOREIGN KEY ("produkId") REFERENCES "PasarDesa"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -2,10 +2,14 @@ import prisma from "@/lib/prisma";
import { Context } from "elysia";
import { nanoid } from "nanoid";
import sharp from "sharp";
import zlib from "zlib";
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;
@@ -16,49 +20,65 @@ const fileStorageCreate = async (context: Context) => {
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" ? "image" : category === "audio" ? "audio" : "document";
// Convert File ke Buffer
const buffer = Buffer.from(await file.arrayBuffer());
let finalName = nanoid();
let finalMimeType = file.type;
if (isImage) {
const mobileBuffer = await sharp(buffer)
.resize({ width: 720 })
.webp({ quality: 80 })
.toBuffer();
// Simpan sebagai WebP untuk kompresi maksimal
const desktopBuffer = await sharp(buffer)
.resize({ width: 1920, withoutEnlargement: true })
.webp({ quality: 80 })
.toBuffer();
const mobileName = `${finalName}-mobile.webp`;
const desktopName = `${finalName}-desktop.webp`;
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" });
// Upload to MinIO
await minio.putObject(MINIO_BUCKET, `${pathName}/${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 minio.putObject(MINIO_BUCKET, `${prefix}/${finalName}`, buffer, buffer.length, { "Content-Type": file.type });
await minio.putObject(MINIO_BUCKET, `${pathName}/${finalName}`, buffer, buffer.length, {
"Content-Type": finalMimeType,
});
} else {
const ext = file.name.split(".").pop() || "bin";
finalName = `${finalName}.${ext}`;
await minio.putObject(MINIO_BUCKET, `${prefix}/${finalName}`, buffer, buffer.length, { "Content-Type": file.type });
// Jika file adalah PDF, simpan tanpa kompresi
if (file.type === "application/pdf") {
await minio.putObject(MINIO_BUCKET, `${pathName}/${finalName}`, buffer, buffer.length, {
"Content-Type": finalMimeType,
});
}
// Jika file lain, kompres dengan gzip
else {
const gzBuffer = zlib.gzipSync(buffer);
const compressedName = `${finalName}.gz`;
await minio.putObject(MINIO_BUCKET, `${pathName}/${compressedName}`, gzBuffer, gzBuffer.length, {
"Content-Type": "application/gzip",
});
finalName = compressedName;
finalMimeType = "application/gzip";
}
}
const data = await prisma.fileStorage.create({
data: {
name: finalName,
realName: file.name,
path: prefix,
path: pathName,
mimeType: finalMimeType,
category,
link: isImage ? `/api/img/${finalName}` : `/api/fileStorage/findUnique/${finalName}`,
link: category === "image" ? `/api/img/${finalName}` : `/api/fileStorage/findUnique/${finalName}`,
},
});

View File

@@ -3,6 +3,14 @@ import fs from "fs/promises";
import sharp from "sharp";
import minio, { MINIO_BUCKET } from "@/lib/minio";
const MIME_MAP: Record<string, string> = {
".webp": "image/webp",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
};
async function img({
name,
ROOT,
@@ -14,12 +22,12 @@ async function img({
}) {
const noImage = path.join(ROOT, "public/no-image.jpg");
const ext = path.extname(name).toLowerCase();
const contentType = MIME_MAP[ext];
if (![".jpg", ".jpeg", ".png", ".webp"].includes(ext)) {
if (!contentType) {
console.warn(`Ekstensi file tidak didukung: ${ext}`);
const buffer = await fs.readFile(noImage);
const uint8Array = new Uint8Array(buffer);
return new Response(new Blob([uint8Array], { type: 'image/jpeg' }), {
return new Response(new Uint8Array(buffer), {
headers: { "Content-Type": "image/jpeg" },
});
}
@@ -32,15 +40,17 @@ async function img({
}
const buffer = Buffer.concat(chunks);
const metadata = await sharp(buffer).metadata();
const resized = await sharp(buffer)
.resize(size || metadata.width)
.toBuffer();
let resized: Buffer;
if (size) {
resized = await sharp(buffer).resize(size).toBuffer();
} else {
resized = buffer;
}
return new Response(new Uint8Array(resized), {
headers: {
"Cache-Control": "public, max-age=3600, stale-while-revalidate=600",
"Content-Type": "image/jpeg",
"Content-Type": contentType,
},
});
} catch (error) {