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
|
- `re-pull.yml` — triggers Portainer to redeploy latest image
|
||||||
|
|
||||||
To release: tag with `git tag -a v0.1.x -m "..."` and push the tag.
|
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 { Context } from "elysia";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
import zlib from "zlib";
|
||||||
import minio, { MINIO_BUCKET } from "@/lib/minio";
|
import minio, { MINIO_BUCKET } from "@/lib/minio";
|
||||||
|
|
||||||
const fileStorageCreate = async (context: Context) => {
|
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 file = body.file;
|
||||||
const name = body.name;
|
const name = body.name;
|
||||||
@@ -16,49 +20,65 @@ const fileStorageCreate = async (context: Context) => {
|
|||||||
const isImage = file.type.startsWith("image/");
|
const isImage = file.type.startsWith("image/");
|
||||||
const isAudio = file.type.startsWith("audio/");
|
const isAudio = file.type.startsWith("audio/");
|
||||||
const category = isImage ? "image" : isAudio ? "audio" : "document";
|
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());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
let finalName = nanoid();
|
let finalName = nanoid();
|
||||||
let finalMimeType = file.type;
|
let finalMimeType = file.type;
|
||||||
|
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
const mobileBuffer = await sharp(buffer)
|
// Simpan sebagai WebP untuk kompresi maksimal
|
||||||
.resize({ width: 720 })
|
|
||||||
.webp({ quality: 80 })
|
|
||||||
.toBuffer();
|
|
||||||
|
|
||||||
const desktopBuffer = await sharp(buffer)
|
const desktopBuffer = await sharp(buffer)
|
||||||
.resize({ width: 1920, withoutEnlargement: true })
|
.resize({ width: 1920, withoutEnlargement: true })
|
||||||
.webp({ quality: 80 })
|
.webp({ quality: 80 })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
const mobileName = `${finalName}-mobile.webp`;
|
|
||||||
const desktopName = `${finalName}-desktop.webp`;
|
const desktopName = `${finalName}-desktop.webp`;
|
||||||
|
|
||||||
await minio.putObject(MINIO_BUCKET, `${prefix}/${mobileName}`, mobileBuffer, mobileBuffer.length, { "Content-Type": "image/webp" });
|
// Upload to MinIO
|
||||||
await minio.putObject(MINIO_BUCKET, `${prefix}/${desktopName}`, desktopBuffer, desktopBuffer.length, { "Content-Type": "image/webp" });
|
await minio.putObject(MINIO_BUCKET, `${pathName}/${desktopName}`, desktopBuffer, desktopBuffer.length, {
|
||||||
|
"Content-Type": "image/webp",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simpan metadata untuk versi desktop sebagai default
|
||||||
finalName = desktopName;
|
finalName = desktopName;
|
||||||
finalMimeType = "image/webp";
|
finalMimeType = "image/webp";
|
||||||
} else if (isAudio) {
|
} else if (isAudio) {
|
||||||
|
// Simpan file audio tanpa kompresi
|
||||||
const ext = file.name.split(".").pop() || "mp3";
|
const ext = file.name.split(".").pop() || "mp3";
|
||||||
finalName = `${finalName}.${ext}`;
|
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 {
|
} else {
|
||||||
const ext = file.name.split(".").pop() || "bin";
|
// Jika file adalah PDF, simpan tanpa kompresi
|
||||||
finalName = `${finalName}.${ext}`;
|
if (file.type === "application/pdf") {
|
||||||
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 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({
|
const data = await prisma.fileStorage.create({
|
||||||
data: {
|
data: {
|
||||||
name: finalName,
|
name: finalName,
|
||||||
realName: file.name,
|
realName: file.name,
|
||||||
path: prefix,
|
path: pathName,
|
||||||
mimeType: finalMimeType,
|
mimeType: finalMimeType,
|
||||||
category,
|
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 sharp from "sharp";
|
||||||
import minio, { MINIO_BUCKET } from "@/lib/minio";
|
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({
|
async function img({
|
||||||
name,
|
name,
|
||||||
ROOT,
|
ROOT,
|
||||||
@@ -14,12 +22,12 @@ async function img({
|
|||||||
}) {
|
}) {
|
||||||
const noImage = path.join(ROOT, "public/no-image.jpg");
|
const noImage = path.join(ROOT, "public/no-image.jpg");
|
||||||
const ext = path.extname(name).toLowerCase();
|
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}`);
|
console.warn(`Ekstensi file tidak didukung: ${ext}`);
|
||||||
const buffer = await fs.readFile(noImage);
|
const buffer = await fs.readFile(noImage);
|
||||||
const uint8Array = new Uint8Array(buffer);
|
return new Response(new Uint8Array(buffer), {
|
||||||
return new Response(new Blob([uint8Array], { type: 'image/jpeg' }), {
|
|
||||||
headers: { "Content-Type": "image/jpeg" },
|
headers: { "Content-Type": "image/jpeg" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -32,15 +40,17 @@ async function img({
|
|||||||
}
|
}
|
||||||
const buffer = Buffer.concat(chunks);
|
const buffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
const metadata = await sharp(buffer).metadata();
|
let resized: Buffer;
|
||||||
const resized = await sharp(buffer)
|
if (size) {
|
||||||
.resize(size || metadata.width)
|
resized = await sharp(buffer).resize(size).toBuffer();
|
||||||
.toBuffer();
|
} else {
|
||||||
|
resized = buffer;
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(new Uint8Array(resized), {
|
return new Response(new Uint8Array(resized), {
|
||||||
headers: {
|
headers: {
|
||||||
"Cache-Control": "public, max-age=3600, stale-while-revalidate=600",
|
"Cache-Control": "public, max-age=3600, stale-while-revalidate=600",
|
||||||
"Content-Type": "image/jpeg",
|
"Content-Type": contentType,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user