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]>
139 lines
4.6 KiB
TypeScript
139 lines
4.6 KiB
TypeScript
"use server";
|
|
|
|
import { redirect } from "next/navigation";
|
|
import { revalidatePath } from "next/cache";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { logAdminAction } from "@/lib/audit-log";
|
|
import { isValidUploadPath } from "@/lib/uploads";
|
|
import { requireAdmin, str, num, strArray } from "./_shared";
|
|
|
|
export async function createHarvestAction(formData: FormData): Promise<void> {
|
|
const admin = await requireAdmin();
|
|
const year = num(formData, "year");
|
|
const type = str(formData, "type");
|
|
const slug = `${type.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${year}`;
|
|
// Checkbox: nur in formData vorhanden, wenn angehakt — fehlt sie ganz
|
|
// (kein Feld im Formular), gilt "veroeffentlicht" als sicherer Default fuer
|
|
// manuell im Admin-Bereich angelegte Ernten (bisheriges Verhalten vor
|
|
// Einfuehrung von isPublished).
|
|
const isPublished = !formData.has("isPublished") || formData.get("isPublished") === "on";
|
|
|
|
const harvest = await prisma.harvest.create({
|
|
data: {
|
|
year,
|
|
type,
|
|
slug,
|
|
harvestDate: new Date(str(formData, "harvestDate")),
|
|
location: str(formData, "location") || "Altenmünster",
|
|
description: str(formData, "description"),
|
|
images: strArray(formData, "images").filter(isValidUploadPath),
|
|
isPublished,
|
|
},
|
|
});
|
|
|
|
await logAdminAction(admin, {
|
|
action: "harvest.create",
|
|
entityType: "Harvest",
|
|
entityId: harvest.id,
|
|
detail: `${type} ${year}`,
|
|
});
|
|
|
|
revalidatePath("/admin/ernten");
|
|
revalidatePath("/ernten");
|
|
redirect("/admin/ernten");
|
|
}
|
|
|
|
export async function updateHarvestAction(formData: FormData): Promise<void> {
|
|
const admin = await requireAdmin();
|
|
const id = str(formData, "id");
|
|
const isPublished = formData.get("isPublished") === "on";
|
|
|
|
await prisma.harvest.update({
|
|
where: { id },
|
|
data: {
|
|
year: num(formData, "year"),
|
|
type: str(formData, "type"),
|
|
harvestDate: new Date(str(formData, "harvestDate")),
|
|
location: str(formData, "location") || "Altenmünster",
|
|
description: str(formData, "description"),
|
|
images: strArray(formData, "images").filter(isValidUploadPath),
|
|
isPublished,
|
|
},
|
|
});
|
|
|
|
await logAdminAction(admin, { action: "harvest.update", entityType: "Harvest", entityId: id });
|
|
|
|
revalidatePath("/admin/ernten");
|
|
revalidatePath("/ernten");
|
|
redirect("/admin/ernten");
|
|
}
|
|
|
|
/**
|
|
* Isolierte "Veroeffentlichen"/"Verbergen"-Aktion, getrennt von
|
|
* updateHarvestAction — so kann der Status per Knopfdruck direkt aus der
|
|
* Ernten-Liste umgeschaltet werden, ohne durch das komplette Bearbeiten-
|
|
* Formular zu muessen (insbesondere fuer den "veroeffentlichen"-Bestaetigungs-
|
|
* schritt bei aus ApiCore synchronisierten Entwuerfen, siehe apicore-sync.ts).
|
|
*/
|
|
export async function toggleHarvestPublishedAction(formData: FormData): Promise<void> {
|
|
const admin = await requireAdmin();
|
|
const id = str(formData, "id");
|
|
const isPublished = formData.get("isPublished") === "on";
|
|
|
|
const harvest = await prisma.harvest.update({
|
|
where: { id },
|
|
data: { isPublished },
|
|
});
|
|
|
|
await logAdminAction(admin, {
|
|
action: isPublished ? "harvest.publish" : "harvest.unpublish",
|
|
entityType: "Harvest",
|
|
entityId: id,
|
|
detail: `${harvest.type} ${harvest.year}`,
|
|
});
|
|
|
|
revalidatePath("/admin/ernten");
|
|
revalidatePath("/ernten");
|
|
redirect("/admin/ernten");
|
|
}
|
|
|
|
export async function createBatchAction(formData: FormData): Promise<void> {
|
|
const admin = await requireAdmin();
|
|
const code = str(formData, "code");
|
|
const batch = await prisma.batch.create({
|
|
data: {
|
|
code,
|
|
harvestId: str(formData, "harvestId"),
|
|
fillDate: new Date(str(formData, "fillDate")),
|
|
bestBeforeDate: new Date(str(formData, "bestBeforeDate")),
|
|
glassCount: num(formData, "glassCount"),
|
|
remainingGlasses: num(formData, "remainingGlasses"),
|
|
},
|
|
});
|
|
|
|
await logAdminAction(admin, { action: "batch.create", entityType: "Batch", entityId: batch.id, detail: code });
|
|
|
|
revalidatePath("/admin/chargen");
|
|
redirect("/admin/chargen");
|
|
}
|
|
|
|
export async function updateBatchAction(formData: FormData): Promise<void> {
|
|
const admin = await requireAdmin();
|
|
const id = str(formData, "id");
|
|
|
|
await prisma.batch.update({
|
|
where: { id },
|
|
data: {
|
|
fillDate: new Date(str(formData, "fillDate")),
|
|
bestBeforeDate: new Date(str(formData, "bestBeforeDate")),
|
|
glassCount: num(formData, "glassCount"),
|
|
remainingGlasses: num(formData, "remainingGlasses"),
|
|
},
|
|
});
|
|
|
|
await logAdminAction(admin, { action: "batch.update", entityType: "Batch", entityId: id });
|
|
|
|
revalidatePath("/admin/chargen");
|
|
redirect("/admin/chargen");
|
|
}
|