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:
21
CLAUDE.md
21
CLAUDE.md
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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}`,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user