diff --git a/README.md b/README.md index 6c92c07..f2de241 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run dev Die Website läuft lokal unter: ```text -http://localhost:3010 +http://127.0.0.1:3010 ``` Für den Admin-Bereich müssen in `.env` mindestens gesetzt sein: @@ -177,7 +177,7 @@ scripts/healthcheck.sh Oder direkt: ```bash -curl http://localhost:3010/api/health +curl http://127.0.0.1:3010/api/health ``` Antwort: diff --git a/app/admin/medien/page.tsx b/app/admin/medien/page.tsx index 9717547..f7232b7 100644 --- a/app/admin/medien/page.tsx +++ b/app/admin/medien/page.tsx @@ -1,20 +1,26 @@ -import { mkdir, readdir } from "fs/promises"; -import path from "path"; import Image from "next/image"; import { redirect } from "next/navigation"; import AdminShell from "@/components/admin/AdminShell"; import { requireAdminSession } from "@/lib/admin/auth"; +import { listMediaFiles } from "@/lib/admin/media"; -const uploadDir = path.join(process.cwd(), "public", "uploads", "images"); - -async function getFiles() { - await mkdir(uploadDir, { recursive: true }); - return readdir(uploadDir); +function formatBytes(size: number) { + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / 1024 / 1024).toFixed(1)} MB`; } -export default async function AdminMediaPage() { +function uploadErrorMessage(error?: string) { + if (!error) return ""; + if (error === "missing-file") return "Bitte wählen Sie eine Datei aus."; + if (error === "storage") return "Die Datei konnte nicht gespeichert werden. Bitte Upload-Speicher und Rechte prüfen."; + return error; +} + +export default async function AdminMediaPage({ searchParams }: { searchParams: Promise<{ uploaded?: string; error?: string }> }) { if (!await requireAdminSession()) redirect("/admin/login"); - const files = await getFiles(); + const [files, params] = await Promise.all([listMediaFiles(), searchParams]); + const error = uploadErrorMessage(params.error); return ( @@ -22,25 +28,40 @@ export default async function AdminMediaPage() {

Assets

Medienverwaltung

+ {params.uploaded === "1" &&

Datei wurde erfolgreich hochgeladen.

} + {error &&

Upload fehlgeschlagen: {error}

}

Uploads

-
- {files.map((file) => { - const url = `/uploads/images/${file}`; - return ( - - {/\.(png|jpe?g|webp)$/i.test(file) - ? {file} - : {file}} - - ); - })} - {files.length === 0 &&

Noch keine Uploads vorhanden.

} -
+ {files.length === 0 ? ( +
+

Noch keine Medien vorhanden

+

Hochgeladene Bilder und PDF-Dateien erscheinen hier mit Vorschau und Metadaten.

+
+ ) : ( +
+ {files.map((file) => ( + + ))} +
+ )}
); diff --git a/app/api/admin/media/route.ts b/app/api/admin/media/route.ts index 4a54fe1..134e0b8 100644 --- a/app/api/admin/media/route.ts +++ b/app/api/admin/media/route.ts @@ -1,38 +1,34 @@ -import { mkdir, readdir, writeFile } from "fs/promises"; -import path from "path"; import { NextResponse } from "next/server"; import { requireAdminSession } from "@/lib/admin/auth"; +import { listMediaFiles, saveMediaFile } from "@/lib/admin/media"; -const uploadDir = path.join(process.cwd(), "public", "uploads", "images"); -const allowedTypes = new Set(["image/jpeg", "image/png", "image/webp", "application/pdf"]); - -async function ensureUploadDir() { - await mkdir(uploadDir, { recursive: true }); +function relativeRedirect(target: string) { + return new NextResponse(null, { + status: 303, + headers: { Location: target }, + }); } export async function GET() { if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); - await ensureUploadDir(); - const files = await readdir(uploadDir); - return NextResponse.json({ - files: files.map((file) => ({ name: file, url: `/uploads/images/${file}` })), - }); + return NextResponse.json({ files: await listMediaFiles() }); } export async function POST(request: Request) { if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); - await ensureUploadDir(); const form = await request.formData(); const file = form.get("file"); - if (!(file instanceof File) || !allowedTypes.has(file.type) || file.size > 5 * 1024 * 1024) { - return NextResponse.json({ message: "Bitte eine JPG-, PNG-, WebP- oder PDF-Datei bis 5 MB hochladen." }, { status: 400 }); + if (!(file instanceof File)) { + return relativeRedirect("/admin/medien?error=missing-file"); } - const extension = path.extname(file.name).toLowerCase(); - const safeName = `${Date.now()}-${file.name.toLowerCase().replace(/[^a-z0-9.-]/g, "-")}`; - const target = safeName.endsWith(extension) ? safeName : `${safeName}${extension}`; - await writeFile(path.join(uploadDir, target), Buffer.from(await file.arrayBuffer())); + try { + const result = await saveMediaFile(file); + if (result.error) return relativeRedirect(`/admin/medien?error=${encodeURIComponent(result.error)}`); + } catch { + return relativeRedirect("/admin/medien?error=storage"); + } - return NextResponse.redirect(new URL("/admin/medien?uploaded=1", request.url)); + return relativeRedirect("/admin/medien?uploaded=1"); } diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts index e2256ea..977e900 100644 --- a/app/api/admin/settings/route.ts +++ b/app/api/admin/settings/route.ts @@ -5,5 +5,5 @@ import { saveSiteSettings } from "@/lib/admin/store"; export async function POST(request: Request) { if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); await saveSiteSettings(await request.formData()); - return NextResponse.redirect(new URL("/admin/einstellungen?saved=1", request.url)); + return new NextResponse(null, { status: 303, headers: { Location: "/admin/einstellungen?saved=1" } }); } diff --git a/app/api/admin/smtp/route.ts b/app/api/admin/smtp/route.ts index 7ce6da3..ea05bb2 100644 --- a/app/api/admin/smtp/route.ts +++ b/app/api/admin/smtp/route.ts @@ -5,5 +5,5 @@ import { saveSmtpSettings } from "@/lib/admin/store"; export async function POST(request: Request) { if (!await requireAdminSession()) return NextResponse.json({ message: "Nicht autorisiert." }, { status: 401 }); await saveSmtpSettings(await request.formData()); - return NextResponse.redirect(new URL("/admin/smtp?saved=1", request.url)); + return new NextResponse(null, { status: 303, headers: { Location: "/admin/smtp?saved=1" } }); } diff --git a/app/globals.css b/app/globals.css index 50962f6..eb29a89 100644 --- a/app/globals.css +++ b/app/globals.css @@ -750,6 +750,10 @@ h3 { gap: 14px; } +.admin-message { + margin-bottom: 18px; +} + .media-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -772,6 +776,69 @@ h3 { object-fit: cover; } +.media-empty { + border: 1px dashed var(--line); + border-radius: 8px; + background: #f8fafc; + padding: 24px; +} + +.media-empty p { + color: var(--muted); +} + +.media-list { + display: grid; + gap: 14px; +} + +.media-row { + display: grid; + grid-template-columns: 180px minmax(0, 1fr); + gap: 16px; + align-items: center; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; + padding: 12px; +} + +.media-preview { + display: grid; + min-height: 110px; + place-items: center; + border: 1px solid var(--line); + border-radius: 8px; + background: #f8fafc; + color: var(--navy); + font-weight: 800; + overflow: hidden; +} + +.media-preview img { + width: 100%; + height: 110px; + object-fit: cover; +} + +.media-meta { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin: 0; +} + +.media-meta dt { + color: var(--muted); + font-size: 13px; + font-weight: 800; +} + +.media-meta dd { + margin: 0; + overflow-wrap: anywhere; +} + .system-list { display: grid; gap: 0; @@ -896,6 +963,11 @@ h3 { display: grid; } + .media-row, + .media-meta { + grid-template-columns: 1fr; + } + .system-list div { grid-template-columns: 1fr; gap: 4px; diff --git a/app/seo.ts b/app/seo.ts index d019f77..0d2de93 100644 --- a/app/seo.ts +++ b/app/seo.ts @@ -2,7 +2,7 @@ import type { Metadata } from "next"; const siteName = "Funktechnik Schubert"; const defaultTitle = "Funktechnik Schubert | Funkgeräte-Service & Messtechnik"; -const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3010"; +const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://127.0.0.1:3010"; const keywords = [ "Funktechnik Schubert", "Funkgeräte Reparatur", diff --git a/docker-compose.yml b/docker-compose.yml index 8ad4b29..47148c1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: ports: - "3010:3010" environment: - NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3010} + NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://127.0.0.1:3010} NEXT_PUBLIC_APP_VERSION: "0.2.2" ADMIN_EMAIL: ${ADMIN_EMAIL:-} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} diff --git a/lib/admin/media.ts b/lib/admin/media.ts new file mode 100644 index 0000000..cbe4b22 --- /dev/null +++ b/lib/admin/media.ts @@ -0,0 +1,106 @@ +import { randomUUID } from "crypto"; +import { mkdir, readdir, rm, stat, writeFile } from "fs/promises"; +import path from "path"; +import { uploadDirectory } from "@/lib/runtime/config"; + +const maxUploadSize = 5 * 1024 * 1024; + +const allowedMimeTypes = new Map([ + ["image/jpeg", [".jpg", ".jpeg"]], + ["image/png", [".png"]], + ["image/webp", [".webp"]], + ["application/pdf", [".pdf"]], +]); + +export type MediaFile = { + name: string; + url: string; + type: string; + size: number; + uploadedAt: string; + isImage: boolean; +}; + +export async function ensureUploadDirectory() { + await mkdir(uploadDirectory, { recursive: true }); + const probe = path.join(uploadDirectory, `.write-check-${Date.now()}`); + await writeFile(probe, "ok", "utf8"); + await rm(probe, { force: true }); +} + +export function validateUpload(file: File) { + const extensions = allowedMimeTypes.get(file.type); + const extension = path.extname(file.name).toLowerCase(); + + if (!extensions || !extensions.includes(extension)) { + return "Bitte eine JPG-, PNG-, WebP- oder PDF-Datei hochladen."; + } + + if (file.size <= 0) { + return "Die Datei ist leer."; + } + + if (file.size > maxUploadSize) { + return "Die Datei darf maximal 5 MB groß sein."; + } + + return ""; +} + +export function createSafeUploadName(originalName: string) { + const extension = path.extname(originalName).toLowerCase(); + const rawBaseName = path.basename(originalName, extension); + const safeBaseName = rawBaseName + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80) || "upload"; + + return `${Date.now()}-${randomUUID().slice(0, 8)}-${safeBaseName}${extension}`; +} + +export function resolveUploadTarget(fileName: string) { + const target = path.resolve(uploadDirectory, fileName); + const uploadRoot = path.resolve(uploadDirectory); + + if (!target.startsWith(`${uploadRoot}${path.sep}`)) { + throw new Error("Ungültiger Upload-Pfad."); + } + + return target; +} + +export async function listMediaFiles(): Promise { + await mkdir(uploadDirectory, { recursive: true }); + const entries = await readdir(uploadDirectory); + const visibleEntries = entries.filter((entry) => !entry.startsWith(".")); + const files = await Promise.all(visibleEntries.map(async (entry) => { + const fileStat = await stat(path.join(uploadDirectory, entry)); + const extension = path.extname(entry).toLowerCase(); + + return { + name: entry, + url: `/uploads/images/${encodeURIComponent(entry)}`, + type: extension.replace(".", "").toUpperCase() || "Datei", + size: fileStat.size, + uploadedAt: fileStat.birthtime.toISOString(), + isImage: [".jpg", ".jpeg", ".png", ".webp"].includes(extension), + }; + })); + + return files.sort((left, right) => right.uploadedAt.localeCompare(left.uploadedAt)); +} + +export async function saveMediaFile(file: File) { + const error = validateUpload(file); + if (error) return { error }; + + await ensureUploadDirectory(); + const safeName = createSafeUploadName(file.name); + const target = resolveUploadTarget(safeName); + await writeFile(target, Buffer.from(await file.arrayBuffer())); + + return { fileName: safeName }; +} diff --git a/proxy.ts b/proxy.ts index 31d43e4..44871a4 100644 --- a/proxy.ts +++ b/proxy.ts @@ -7,7 +7,9 @@ export function proxy(request: NextRequest) { if (!pathname.startsWith("/admin") || pathname === "/admin/login") return NextResponse.next(); if (!request.cookies.get(adminSessionCookie)?.value) { - const loginUrl = new URL("/admin/login", request.url); + const loginUrl = request.nextUrl.clone(); + loginUrl.pathname = "/admin/login"; + loginUrl.search = ""; loginUrl.searchParams.set("next", pathname); return NextResponse.redirect(loginUrl); } diff --git a/scripts/deploy.sh b/scripts/deploy.sh index f87dd96..0b17c4a 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -2,7 +2,7 @@ set -eu COMPOSE="${COMPOSE:-docker compose}" -BASE_URL="${BASE_URL:-http://localhost:3010}" +BASE_URL="${BASE_URL:-http://127.0.0.1:3010}" echo "Pruefe Docker Compose Konfiguration..." $COMPOSE config >/dev/null diff --git a/scripts/healthcheck.sh b/scripts/healthcheck.sh index 9e17c56..9abf19b 100755 --- a/scripts/healthcheck.sh +++ b/scripts/healthcheck.sh @@ -1,7 +1,7 @@ #!/usr/bin/env sh set -eu -BASE_URL="${BASE_URL:-http://localhost:3010}" +BASE_URL="${BASE_URL:-http://127.0.0.1:3010}" HEALTH_URL="${BASE_URL%/}/api/health" echo "Checking ${HEALTH_URL}"