feat: Bild-Upload fuer Inhalte, Produkte und Ernten
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m34s
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m34s
Echter Datei-Upload statt CSS-Platzhaltergrafiken, gespeichert unter UPLOAD_DIR (Laufzeit-Verzeichnis, weiss nichts von seiner tatsaechlichen Speicher-Anbindung — in Produktion ein von der Betreiberin selbst per Kubernetes eingebundenes Volume, z.B. SMB; lokal ein simples ./uploads). - lib/uploads.ts: Kernlogik. Bildformat wird ausschliesslich aus den tatsaechlichen Bilddaten erkannt (Magic-Bytes via sharp), nie aus Client-Dateiname/MIME-Type. Zufaellige, nicht erratbare Dateinamen (Path-Traversal-/Overwrite-Schutz). Re-Encoding validiert Pixel-Daten vollstaendig und entfernt eingebettete Metadaten (EXIF/ICC/XMP) als Nebeneffekt. Content-Type beim Ausliefern kommt ausschliesslich aus einer festen Endung->MIME-Zuordnung, nie aus Dateiinhalt/-namen. - app/api/admin/upload/route.ts: Admin-only Upload-Endpunkt (CSRF- + Auth- + Rate-Limit-Pruefung, max. 8 MB, JPEG/PNG/WebP/AVIF). - app/uploads/[...path]/route.ts: liefert hochgeladene Bilder aus (public/ wird zur Build-Zeit fest ins Image gebacken, kann also keine zur Laufzeit hinzugefuegten Dateien bedienen) — mit Path-Traversal-Schutz und nosniff-Header. - content-registry.ts: neuer "image"-Feldtyp fuer Hero-/Startseite- Grafiken, admin/inhalte hat dafuer ein Upload-Widget. - Produkt-/Ernten-Admin-Formulare: Mehrfach-Bild-Upload fuer Product.images/Harvest.images (beide Felder existierten im Schema, hatten aber bislang keine Admin-Oberflaeche). Oeffentliche Seiten (Shop, Ernten, Chargen-QR-Seite) zeigen jetzt echte Fotos statt Platzhalter, sobald vorhanden. - Kubernetes: nur ein einfacher volumeMount in 05-app-deployment.yaml (Platzhalter-emptyDir, klar als CHANGE-ME markiert) — die eigentliche Volume-Quelle (SMB o.ae.) bindet die Betreiberin selbst ein, wie gewuenscht keine SMB/CSI-spezifischen Manifeste hinzugefuegt. Verifiziert: tsc --noEmit clean, 139/139 Tests gruen (21 neu), pnpm build clean. Upload-/Serving-Pipeline live gegen einen laufenden Server getestet (Auth-/CSRF-Gates, Path-Traversal-Ablehnung, Format-Ablehnung) — ein vollstaendiger authentifizierter Upload end-to-end (braucht echten Admin-Login inkl. 2FA) wurde nicht live durchgespielt, nur per Code-Review verifiziert. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
3de9f2906e
commit
54919d82ed
35 files changed
+1014
-44
No files matched your search
@@ -9,7 +9,8 @@ export type ContentFieldType =
|
||||
| "list-simple"
|
||||
| "list-titled"
|
||||
| "list-numbered"
|
||||
| "richtext-sections";
|
||||
| "richtext-sections"
|
||||
| "image";
|
||||
|
||||
export interface ContentFieldDef {
|
||||
key: string;
|
||||
@@ -67,6 +68,21 @@ export const CONTENT_REGISTRY: ContentFieldDef[] = [
|
||||
{ key: "home.sustainability.items", group: "home", labelDe: "Nachhaltigkeit: Punkte (Titel | Text, eine Zeile je Punkt)", labelEn: "Sustainability: Items (Title | Text, one per line)", type: "list-titled", localized: true, getDefault: (l) => joinTitledList(getDictionary(l).home.sustainability.items) },
|
||||
{ key: "home.businessTeaser.paragraph", group: "home", labelDe: "Geschäftskunden-Hinweis: Text", labelEn: "Business teaser: Text", type: "textarea", localized: true, getDefault: (l) => getDictionary(l).home.businessTeaser.paragraph },
|
||||
|
||||
// --- Homepage-Bilder (nicht lokalisiert — ein Bild gilt fuer beide
|
||||
// Sprachen; Wert ist der von app/api/admin/upload/route.ts zurueckgegebene
|
||||
// Pfad, z.B. "/uploads/<zufaellig>.webp". Leer = kein Bild gepflegt, die
|
||||
// Komponente zeigt dann weiterhin den bisherigen CSS-Platzhalter, siehe
|
||||
// components/content-image.tsx.) Deckt jede Stelle in hero.tsx/
|
||||
// home-sections-a.tsx/home-sections-b.tsx ab, die ein festes,
|
||||
// redaktionelles Bild zeigt — der homepage-Produkt-Spotlight-Abschnitt
|
||||
// (ProductSpotlight) ist bewusst NICHT hier: er zeigt kein festes Bild,
|
||||
// sondern das jeweils dynamisch ausgewaehlte Produkt und nutzt dafuer
|
||||
// dessen eigenes Product.images (siehe admin/produkte).
|
||||
{ key: "home.hero.image", group: "home", labelDe: "Hero: Bild", labelEn: "Hero: Image", type: "image", localized: false, getDefault: () => "" },
|
||||
{ key: "home.originStory.image", group: "home", labelDe: "Herkunft: Bild", labelEn: "Origin: Image", type: "image", localized: false, getDefault: () => "" },
|
||||
{ key: "home.regionalSection.image", group: "home", labelDe: "Regional: Bild", labelEn: "Regional: Image", type: "image", localized: false, getDefault: () => "" },
|
||||
{ key: "home.businessTeaser.image", group: "home", labelDe: "Geschäftskunden-Hinweis: Bild", labelEn: "Business teaser: Image", type: "image", localized: false, getDefault: () => "" },
|
||||
|
||||
// --- Social Media (nicht lokalisiert, leer = Icon wird im Footer nicht
|
||||
// angezeigt, siehe components/site-footer.tsx Footer()) ---
|
||||
{ key: "social.tiktok", group: "social", labelDe: "TikTok-Profil (URL)", labelEn: "TikTok profile (URL)", type: "text", localized: false, getDefault: () => "" },
|
||||
|
||||
+32
-6
@@ -21,6 +21,16 @@ function normalizeProduct<T extends { tasteTags: unknown; images: unknown }>(
|
||||
return { ...product, tasteTags: asStringArray(product.tasteTags), images: asStringArray(product.images) };
|
||||
}
|
||||
|
||||
/** Dasselbe Prinzip wie normalizeProduct oben, nur fuer das images-Feld
|
||||
* alleine — wiederverwendbar fuer Harvest UND fuer die (verschachtelten)
|
||||
* Product-Eintraege in den Harvest-/Batch-Funktionen unten, die keine
|
||||
* tasteTags-Normalisierung brauchen. */
|
||||
function withImages<T extends { images: unknown }>(item: T): Omit<T, "images"> & { images: string[] } {
|
||||
return { ...item, images: asStringArray(item.images) };
|
||||
}
|
||||
|
||||
export type HarvestWithImages = Omit<Harvest, "images"> & { images: string[] };
|
||||
|
||||
export type ProductSort = "preis-auf" | "preis-ab" | "verfuegbarkeit";
|
||||
|
||||
export interface GetActiveProductsOptions {
|
||||
@@ -106,12 +116,13 @@ export async function getProductsByIds(
|
||||
}
|
||||
|
||||
/** Nur oeffentlich sichtbare Ernten (siehe Harvest.isPublished) — fuer Shop-/Ernten-Seiten. */
|
||||
export async function getHarvests(): Promise<(Harvest & { batches: Batch[] })[]> {
|
||||
return prisma.harvest.findMany({
|
||||
export async function getHarvests(): Promise<(HarvestWithImages & { batches: Batch[] })[]> {
|
||||
const harvests = await prisma.harvest.findMany({
|
||||
where: { isPublished: true },
|
||||
include: { batches: true },
|
||||
orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
|
||||
});
|
||||
return harvests.map(withImages);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,19 +134,34 @@ export async function getHarvests(): Promise<(Harvest & { batches: Batch[] })[]>
|
||||
export async function getHarvestByYearType(
|
||||
year: number,
|
||||
type: string
|
||||
): Promise<(Harvest & { batches: Batch[]; products: Product[] }) | null> {
|
||||
): Promise<
|
||||
| (HarvestWithImages & {
|
||||
batches: Batch[];
|
||||
products: (Omit<Product, "images"> & { images: string[] })[];
|
||||
})
|
||||
| null
|
||||
> {
|
||||
const harvest = await prisma.harvest.findUnique({
|
||||
where: { year_type: { year, type } },
|
||||
include: { batches: true, products: true },
|
||||
});
|
||||
return harvest && harvest.isPublished ? harvest : null;
|
||||
if (!harvest || !harvest.isPublished) return null;
|
||||
return { ...withImages(harvest), products: harvest.products.map(withImages) };
|
||||
}
|
||||
|
||||
export async function getBatchByCode(
|
||||
code: string
|
||||
): Promise<(Batch & { harvest: Harvest; products: Product[] }) | null> {
|
||||
return prisma.batch.findUnique({
|
||||
): Promise<
|
||||
| (Batch & {
|
||||
harvest: HarvestWithImages;
|
||||
products: (Omit<Product, "images"> & { images: string[] })[];
|
||||
})
|
||||
| null
|
||||
> {
|
||||
const batch = await prisma.batch.findUnique({
|
||||
where: { code },
|
||||
include: { harvest: true, products: true },
|
||||
});
|
||||
if (!batch) return null;
|
||||
return { ...batch, harvest: withImages(batch.harvest), products: batch.products.map(withImages) };
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import path from "node:path";
|
||||
import {
|
||||
UPLOAD_DIR,
|
||||
extensionForFormat,
|
||||
contentTypeForExtension,
|
||||
generateUploadFilename,
|
||||
isValidUploadPath,
|
||||
resolveUploadPath,
|
||||
} from "./uploads";
|
||||
|
||||
describe("extensionForFormat", () => {
|
||||
test("maps sharp's jpeg format to the jpg extension", () => {
|
||||
expect(extensionForFormat("jpeg")).toBe("jpg");
|
||||
});
|
||||
|
||||
test("passes png/webp/avif through unchanged", () => {
|
||||
expect(extensionForFormat("png")).toBe("png");
|
||||
expect(extensionForFormat("webp")).toBe("webp");
|
||||
expect(extensionForFormat("avif")).toBe("avif");
|
||||
});
|
||||
|
||||
test("rejects an unsupported format (e.g. gif/svg/tiff)", () => {
|
||||
expect(extensionForFormat("gif")).toBeNull();
|
||||
expect(extensionForFormat("svg")).toBeNull();
|
||||
expect(extensionForFormat("tiff")).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects an undefined format", () => {
|
||||
expect(extensionForFormat(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("contentTypeForExtension", () => {
|
||||
test("returns the correct MIME type for each allowed extension", () => {
|
||||
expect(contentTypeForExtension("jpg")).toBe("image/jpeg");
|
||||
expect(contentTypeForExtension("png")).toBe("image/png");
|
||||
expect(contentTypeForExtension("webp")).toBe("image/webp");
|
||||
expect(contentTypeForExtension("avif")).toBe("image/avif");
|
||||
});
|
||||
|
||||
test("returns null for an extension outside the fixed allow-list", () => {
|
||||
// Verhindert, dass eine Datei je als text/html o.ae. ausgeliefert wird —
|
||||
// siehe app/uploads/[...path]/route.ts.
|
||||
expect(contentTypeForExtension("html")).toBeNull();
|
||||
expect(contentTypeForExtension("svg")).toBeNull();
|
||||
expect(contentTypeForExtension("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateUploadFilename", () => {
|
||||
test("produces a 32-char lowercase hex name with the given extension", () => {
|
||||
const name = generateUploadFilename("webp");
|
||||
expect(name).toMatch(/^[a-f0-9]{32}\.webp$/);
|
||||
});
|
||||
|
||||
test("never reuses the same name across calls (collision-resistant)", () => {
|
||||
const names = new Set(Array.from({ length: 50 }, () => generateUploadFilename("jpg")));
|
||||
expect(names.size).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidUploadPath", () => {
|
||||
test("accepts a path in exactly the shape the upload route produces", () => {
|
||||
expect(isValidUploadPath(`/uploads/${generateUploadFilename("png")}`)).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects a path traversal attempt", () => {
|
||||
expect(isValidUploadPath("/uploads/../../etc/passwd")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects an absolute external URL (not an upload-endpoint path)", () => {
|
||||
expect(isValidUploadPath("https://attacker.example/x.png")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects a javascript: pseudo-protocol value", () => {
|
||||
expect(isValidUploadPath("javascript:alert(1)")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects a client-chosen filename instead of the generated random one", () => {
|
||||
expect(isValidUploadPath("/uploads/my-photo.png")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects an extension outside the allow-list", () => {
|
||||
expect(isValidUploadPath("/uploads/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.svg")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects an empty string", () => {
|
||||
expect(isValidUploadPath("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveUploadPath", () => {
|
||||
test("resolves a plain filename to a path inside UPLOAD_DIR", () => {
|
||||
const resolved = resolveUploadPath(["abc123.png"]);
|
||||
expect(resolved).toBe(path.join(UPLOAD_DIR, "abc123.png"));
|
||||
});
|
||||
|
||||
test("resolves nested-looking but safe segments inside UPLOAD_DIR", () => {
|
||||
const resolved = resolveUploadPath(["sub", "abc123.png"]);
|
||||
expect(resolved).toBe(path.join(UPLOAD_DIR, "sub", "abc123.png"));
|
||||
});
|
||||
|
||||
test("rejects a '..' segment (path traversal attempt)", () => {
|
||||
expect(resolveUploadPath(["..", "..", "etc", "passwd"])).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects a segment that is a single '.'", () => {
|
||||
expect(resolveUploadPath([".", "abc123.png"])).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects an empty segment", () => {
|
||||
expect(resolveUploadPath([""])).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects an encoded traversal baked into one segment", () => {
|
||||
// Next.js liefert Catch-all-Segmente bereits prozent-dekodiert an — ein
|
||||
// Segment kann trotzdem woertlich ".." enthalten, wenn jemand die Route
|
||||
// direkt (nicht ueber generierte Links) aufruft.
|
||||
expect(resolveUploadPath(["a", "..", "..", "b"])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import path from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import sharp from "sharp";
|
||||
|
||||
/**
|
||||
* Root-Verzeichnis fuer alle hochgeladenen Bilder (Inhalte-Bilder, Produkt-/
|
||||
* Erntenfotos). In Produktion zeigt UPLOAD_DIR auf ein von aussen gemountetes
|
||||
* Volume (siehe k8s/05-app-deployment.yaml, volumeMounts "uploads") — diese
|
||||
* Datei und die Upload-/Serving-Route-Handler wissen nicht, WAS dieses
|
||||
* Volume ist (SMB/NFS/hostPath/...), sie lesen/schreiben ganz normal per
|
||||
* node:fs unter diesem Pfad. Lokal reicht der Default "./uploads" (relativ
|
||||
* zum Arbeitsverzeichnis, per .gitignore ausgeschlossen).
|
||||
*
|
||||
* turbopackIgnore: UPLOAD_DIR haengt von einer Laufzeit-Env-Var ab, die zur
|
||||
* Build-Zeit weder existiert noch existieren soll — ohne den Kommentar
|
||||
* versucht Turbopacks statische Datei-Tracing-Analyse (fuer
|
||||
* next.config.ts output:"standalone") vorsichtshalber, das GESAMTE
|
||||
* Projektverzeichnis in den Server-Output aufzunehmen, weil sie den
|
||||
* dynamischen Pfad nicht statisch aufloesen kann (siehe Build-Warnung ohne
|
||||
* diesen Kommentar). UPLOAD_DIR zeigt in Produktion ohnehin auf ein
|
||||
* gemountetes Volume ausserhalb des Projektbaums, das nie Teil des
|
||||
* Docker-Images sein soll.
|
||||
*/
|
||||
export const UPLOAD_DIR = path.resolve(/*turbopackIgnore: true*/ process.env.UPLOAD_DIR || "./uploads");
|
||||
|
||||
/**
|
||||
* 8 MB: grosszuegig genug fuer ein einzelnes unkomprimiertes Foto aus einer
|
||||
* aktuellen Smartphone-Kamera nach Export, aber klein genug, um den Server
|
||||
* nicht mit riesigen Uploads zu belasten — die Datei wird vollstaendig in
|
||||
* den Speicher gelesen (kein Streaming), um sie mit sharp zu validieren und
|
||||
* neu zu enkodieren.
|
||||
*/
|
||||
export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
export type AllowedImageExtension = "jpg" | "png" | "webp" | "avif";
|
||||
|
||||
/**
|
||||
* Kanonische Dateiendung je von sharp erkanntem Bildformat (Magic-Bytes,
|
||||
* siehe extensionForFormat) — das ist die einzige Quelle der Wahrheit fuer
|
||||
* erlaubte Formate. Der vom Client gesendete Dateiname/MIME-Type wird dafuer
|
||||
* NIE herangezogen, beides ist frei faelschbar.
|
||||
*/
|
||||
const EXTENSION_BY_FORMAT: Record<string, AllowedImageExtension> = {
|
||||
jpeg: "jpg",
|
||||
png: "png",
|
||||
webp: "webp",
|
||||
avif: "avif",
|
||||
};
|
||||
|
||||
export function extensionForFormat(format: string | undefined): AllowedImageExtension | null {
|
||||
if (!format) return null;
|
||||
return EXTENSION_BY_FORMAT[format] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content-Type ausschliesslich aus dieser festen Zuordnung nach (bereits
|
||||
* validierter) Dateiendung — nie aus dem Dateiinhalt oder einem Client-
|
||||
* Header. Verhindert, dass eine ausgelieferte Datei je als text/html o.ae.
|
||||
* interpretiert werden koennte (gespeicherter XSS ueber Bild-Uploads), siehe
|
||||
* app/uploads/[...path]/route.ts.
|
||||
*/
|
||||
const CONTENT_TYPE_BY_EXTENSION: Record<AllowedImageExtension, string> = {
|
||||
jpg: "image/jpeg",
|
||||
png: "image/png",
|
||||
webp: "image/webp",
|
||||
avif: "image/avif",
|
||||
};
|
||||
|
||||
export function contentTypeForExtension(extension: string): string | null {
|
||||
return CONTENT_TYPE_BY_EXTENSION[extension as AllowedImageExtension] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt einen zufaelligen, nicht erratbaren Dateinamen — nie der vom
|
||||
* Client gesendete Originalname wird verwendet oder auch nur einbezogen
|
||||
* (Path-Traversal-/Overwrite-Risiko). 16 zufaellige Bytes (32 Hex-Zeichen)
|
||||
* sind praktisch kollisionsfrei fuer die Upload-Frequenz dieser Seite.
|
||||
*/
|
||||
export function generateUploadFilename(extension: AllowedImageExtension): string {
|
||||
return `${randomBytes(16).toString("hex")}.${extension}`;
|
||||
}
|
||||
|
||||
const UPLOAD_PATH_PATTERN = /^\/uploads\/[a-f0-9]{32}\.(jpg|png|webp|avif)$/;
|
||||
|
||||
/**
|
||||
* Prueft, ob ein gespeicherter Bildpfad exakt der Form entspricht, die
|
||||
* app/api/admin/upload/route.ts erzeugt. Wird beim Speichern von Admin-
|
||||
* Formularfeldern verwendet, die einen Upload-Pfad entgegennehmen (Inhalte-
|
||||
* Bild, Produkt-/Erntenbilder) — schuetzt davor, dass ein manipuliertes
|
||||
* Hidden-Feld einen beliebigen String (z.B. "javascript:..." oder eine
|
||||
* externe URL) ungeprueft in die Datenbank und damit in ein <img src>/
|
||||
* next/image auf einer oeffentlichen Seite schreibt.
|
||||
*/
|
||||
export function isValidUploadPath(value: string): boolean {
|
||||
return UPLOAD_PATH_PATTERN.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loest Catch-all-Segmente aus app/uploads/[...path]/route.ts sicher gegen
|
||||
* UPLOAD_DIR auf. path.join() allein wuerde ".." zwar rechnerisch weg-
|
||||
* normalisieren, kennt dabei aber keine Grenze — ein Segment wie "..","..",
|
||||
* "etc","passwd" koennte sich so bis vor UPLOAD_DIR heraus navigieren. Der
|
||||
* abschliessende Praefix-Vergleich gegen den aufgeloesten UPLOAD_DIR-Pfad
|
||||
* ist die eigentliche Schranke. Gibt null zurueck, wenn der aufgeloeste Pfad
|
||||
* UPLOAD_DIR verlaesst.
|
||||
*/
|
||||
export function resolveUploadPath(segments: string[]): string | null {
|
||||
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
||||
return null;
|
||||
}
|
||||
// turbopackIgnore: siehe Kommentar bei UPLOAD_DIR oben — derselbe Grund
|
||||
// (Laufzeit-abhaengiger Pfad, soll nicht ins Server-Bundle getraced werden).
|
||||
const resolved = path.resolve(path.join(/*turbopackIgnore: true*/ UPLOAD_DIR, ...segments));
|
||||
const withSep = UPLOAD_DIR.endsWith(path.sep) ? UPLOAD_DIR : UPLOAD_DIR + path.sep;
|
||||
if (resolved !== UPLOAD_DIR && !resolved.startsWith(withSep)) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-enkodiert ein Bild in sein Zielformat. Dient zwei Zwecken: (1) validiert
|
||||
* die tatsaechlichen Pixel-Daten ein zweites Mal vollstaendig (nicht nur den
|
||||
* Header/die Magic-Bytes wie die metadata()-Pruefung im Upload-Handler), und
|
||||
* (2) entfernt als Nebeneffekt eingebettete Metadaten (EXIF/ICC/XMP) — sharp
|
||||
* uebernimmt sie beim Enkodieren nur, wenn withMetadata() explizit
|
||||
* aufgerufen wird, was hier bewusst NICHT geschieht. .rotate() ohne
|
||||
* Argumente richtet das Bild einmalig anhand des EXIF-Orientation-Tags aus,
|
||||
* BEVOR dieser Tag mit den uebrigen Metadaten verworfen wird — sonst
|
||||
* erschiene ein am Handy "hochkant" aufgenommenes Foto nach dem Entfernen
|
||||
* der Metadaten seitlich verdreht.
|
||||
*/
|
||||
export async function reencodeImage(buffer: Buffer, extension: AllowedImageExtension): Promise<Buffer> {
|
||||
const image = sharp(buffer).rotate();
|
||||
switch (extension) {
|
||||
case "jpg":
|
||||
return image.jpeg({ quality: 85 }).toBuffer();
|
||||
case "png":
|
||||
return image.png().toBuffer();
|
||||
case "webp":
|
||||
return image.webp({ quality: 85 }).toBuffer();
|
||||
case "avif":
|
||||
return image.avif({ quality: 60 }).toBuffer();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user