diff --git a/.dockerignore b/.dockerignore index 9b5cf41..fb3f6f5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,8 +6,8 @@ npm-debug.log* .git README.md data/*.json -public/uploads/images/* -!public/uploads/images/.gitkeep +storage/uploads/images/* +!storage/uploads/images/.gitkeep *.tmp *.temp .DS_Store diff --git a/.gitignore b/.gitignore index eb72981..cbe8f57 100644 --- a/.gitignore +++ b/.gitignore @@ -9,9 +9,10 @@ data/contact-inquiries.json data/repair-inquiries.json data/site-settings.json data/smtp-settings.json -public/uploads/images/* -!public/uploads/.gitkeep -!public/uploads/images/.gitkeep +storage/uploads/images/* +!storage/.gitkeep +!storage/uploads/.gitkeep +!storage/uploads/images/.gitkeep .DS_Store *.tmp *.temp diff --git a/Dockerfile b/Dockerfile index 90bfbd4..60c8b8b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,8 +26,8 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/scripts/runtime-start.js ./runtime-start.js -RUN mkdir -p /app/data /app/public/uploads/images /app/.next/cache \ - && chown -R nextjs:nodejs /app/data /app/public/uploads /app/.next/cache +RUN mkdir -p /app/data /app/storage/uploads/images /app/.next/cache \ + && chown -R nextjs:nodejs /app/data /app/storage /app/.next/cache USER nextjs EXPOSE 3010 diff --git a/README.md b/README.md index f2de241..329ed24 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ Port: Die Runtime-Daten werden über Docker-Volumes gespeichert: - `funktechnik-data` → `/app/data` -- `funktechnik-uploads` → `/app/public/uploads/images` +- `funktechnik-storage` → `/app/storage` - `funktechnik-next-cache` → `/app/.next/cache` Dadurch sind keine manuellen `chmod`- oder `chown`-Befehle notwendig. @@ -166,7 +166,9 @@ data/site-settings.json data/smtp-settings.json ``` -Uploads unter `public/uploads/images/` werden ebenfalls nicht committed. +Uploads liegen unter `storage/uploads/images/`, werden über `/api/media/[filename]` ausgeliefert und nicht committed. + +Alte Uploads aus `public/uploads/images` werden beim Start einmalig nach `storage/uploads/images` migriert, falls sie dort noch nicht vorhanden sind. ## Healthcheck @@ -223,7 +225,7 @@ scripts/healthcheck.sh scripts/backup.sh ``` -Das Backup enthält `data/` und `public/uploads/`. Die `.env` wird bewusst nicht automatisch gesichert. Sie muss separat sicher abgelegt werden. +Das Backup enthält `data/` und `storage/`. Die `.env` wird bewusst nicht automatisch gesichert. Sie muss separat sicher abgelegt werden. ## Restore diff --git a/app/admin/system/page.tsx b/app/admin/system/page.tsx index 4e9b3ad..d71e400 100644 --- a/app/admin/system/page.tsx +++ b/app/admin/system/page.tsx @@ -37,7 +37,7 @@ export default async function AdminSystemPage() {

Betriebsregeln

diff --git a/app/api/media/[filename]/route.ts b/app/api/media/[filename]/route.ts new file mode 100644 index 0000000..4d4c315 --- /dev/null +++ b/app/api/media/[filename]/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { readMediaFile } from "@/lib/admin/media"; + +export async function GET(_request: Request, { params }: { params: Promise<{ filename: string }> }) { + const { filename } = await params; + const media = await readMediaFile(decodeURIComponent(filename)); + + if (!media) { + return NextResponse.json({ message: "Datei nicht gefunden." }, { status: 404 }); + } + + return new NextResponse(media.data, { + status: 200, + headers: { + "Content-Type": media.mimeType, + "Content-Length": String(media.size), + "Content-Disposition": "inline", + "Cache-Control": "private, max-age=300", + "X-Content-Type-Options": "nosniff", + }, + }); +} diff --git a/docker-compose.yml b/docker-compose.yml index 47148c1..4b670a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,11 +18,11 @@ services: DOCKER_ENV: "true" volumes: - funktechnik-data:/app/data - - funktechnik-uploads:/app/public/uploads/images + - funktechnik-storage:/app/storage - funktechnik-next-cache:/app/.next/cache restart: unless-stopped volumes: funktechnik-data: - funktechnik-uploads: + funktechnik-storage: funktechnik-next-cache: diff --git a/lib/admin/media.ts b/lib/admin/media.ts index 411b718..74a6e37 100644 --- a/lib/admin/media.ts +++ b/lib/admin/media.ts @@ -1,7 +1,7 @@ import { randomUUID } from "crypto"; -import { mkdir, readdir, rm, stat, writeFile } from "fs/promises"; +import { mkdir, readFile, readdir, rm, stat, writeFile } from "fs/promises"; import path from "path"; -import { uploadDirectory } from "@/lib/runtime/config"; +import { migrateLegacyUploads, uploadDirectory } from "@/lib/runtime/config"; const maxUploadSize = 5 * 1024 * 1024; @@ -23,6 +23,15 @@ export type MediaFile = { isPdf: boolean; }; +export function getMediaMimeType(fileName: string) { + const extension = path.extname(fileName).toLowerCase(); + if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg"; + if (extension === ".png") return "image/png"; + if (extension === ".webp") return "image/webp"; + if (extension === ".pdf") return "application/pdf"; + return ""; +} + export async function ensureUploadDirectory() { await mkdir(uploadDirectory, { recursive: true }); const probe = path.join(uploadDirectory, `.write-check-${Date.now()}`); @@ -81,6 +90,7 @@ function isAllowedStoredFile(fileName: string) { } export async function listMediaFiles(): Promise { + await migrateLegacyUploads(); await mkdir(uploadDirectory, { recursive: true }); const entries = await readdir(uploadDirectory); const visibleEntries = entries.filter(isAllowedStoredFile); @@ -92,7 +102,7 @@ export async function listMediaFiles(): Promise { return { name: entry, - url: `/uploads/images/${encodeURIComponent(entry)}`, + url: `/api/media/${encodeURIComponent(entry)}`, type: isPdf ? "pdf" as const : "image" as const, extension: extension.replace(".", "").toUpperCase() || "Datei", size: fileStat.size, @@ -117,6 +127,24 @@ export async function saveMediaFile(file: File) { return { fileName: safeName }; } +export async function readMediaFile(fileName: string) { + if (!isAllowedStoredFile(fileName) || path.basename(fileName) !== fileName) { + return null; + } + + const mimeType = getMediaMimeType(fileName); + if (!mimeType) return null; + + const target = resolveUploadTarget(fileName); + + try { + const [data, fileStat] = await Promise.all([readFile(target), stat(target)]); + return { data, mimeType, size: fileStat.size, uploadedAt: fileStat.birthtime.toISOString() }; + } catch { + return null; + } +} + export async function deleteMediaFile(fileName: string) { if (!isAllowedStoredFile(fileName) || path.basename(fileName) !== fileName) { return { error: "Ungültiger Dateiname." }; diff --git a/lib/runtime/config.ts b/lib/runtime/config.ts index a412dcd..42eab92 100644 --- a/lib/runtime/config.ts +++ b/lib/runtime/config.ts @@ -1,10 +1,12 @@ import { existsSync } from "fs"; -import { access, mkdir, writeFile, rm } from "fs/promises"; +import { access, copyFile, mkdir, readdir, rm, writeFile } from "fs/promises"; import path from "path"; import { appVersion } from "./version"; export const dataDirectory = path.join(process.cwd(), "data"); -export const uploadDirectory = path.join(process.cwd(), "public", "uploads", "images"); +export const storageDirectory = path.join(process.cwd(), "storage"); +export const uploadDirectory = path.join(storageDirectory, "uploads", "images"); +export const legacyPublicUploadDirectory = path.join(process.cwd(), "public", "uploads", "images"); export function adminConfigurationStatus() { return process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD && process.env.ADMIN_SESSION_SECRET ? "configured" : "missing"; @@ -17,6 +19,7 @@ export function isDockerEnvironment() { export async function ensureRuntimeDirectories() { await mkdir(dataDirectory, { recursive: true }); await mkdir(uploadDirectory, { recursive: true }); + await migrateLegacyUploads(); } export async function checkStorage() { @@ -36,4 +39,20 @@ export function smtpStatus() { return "prepared"; } +export async function migrateLegacyUploads() { + if (!existsSync(legacyPublicUploadDirectory)) return; + + await mkdir(uploadDirectory, { recursive: true }); + const entries = await readdir(legacyPublicUploadDirectory, { withFileTypes: true }); + + await Promise.all(entries.map(async (entry) => { + if (!entry.isFile() || entry.name.startsWith(".")) return; + + const source = path.join(legacyPublicUploadDirectory, entry.name); + const target = path.join(uploadDirectory, entry.name); + + if (!existsSync(target)) await copyFile(source, target); + })); +} + export { appVersion }; diff --git a/scripts/backup.sh b/scripts/backup.sh index 6430d9b..3214006 100755 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -6,7 +6,7 @@ STAMP="$(date +%Y%m%d-%H%M%S)" TARGET="${BACKUP_DIR}/funktechnik-data-${STAMP}.tar.gz" mkdir -p "$BACKUP_DIR" -tar -czf "$TARGET" data public/uploads +tar -czf "$TARGET" data storage echo "Backup erstellt: $TARGET" echo "Hinweis: .env wird aus Sicherheitsgruenden nicht automatisch gesichert." diff --git a/scripts/runtime-start.js b/scripts/runtime-start.js index 31eb0a8..c9c835f 100644 --- a/scripts/runtime-start.js +++ b/scripts/runtime-start.js @@ -5,7 +5,7 @@ const path = require("path"); const requiredEnv = ["ADMIN_EMAIL", "ADMIN_PASSWORD", "ADMIN_SESSION_SECRET"]; const requiredDirs = [ path.join(process.cwd(), "data"), - path.join(process.cwd(), "public", "uploads", "images"), + path.join(process.cwd(), "storage", "uploads", "images"), path.join(process.cwd(), ".next", "cache"), ]; @@ -21,6 +21,26 @@ function ensureWritableDirectory(directory) { fs.rmSync(probe, { force: true }); } +function migrateLegacyUploads() { + const legacyDirectory = path.join(process.cwd(), "public", "uploads", "images"); + const storageDirectory = path.join(process.cwd(), "storage", "uploads", "images"); + + if (!fs.existsSync(legacyDirectory)) return; + + fs.mkdirSync(storageDirectory, { recursive: true }); + for (const fileName of fs.readdirSync(legacyDirectory)) { + if (fileName.startsWith(".")) continue; + + const source = path.join(legacyDirectory, fileName); + const target = path.join(storageDirectory, fileName); + const sourceStat = fs.statSync(source); + + if (sourceStat.isFile() && !fs.existsSync(target)) { + fs.copyFileSync(source, target); + } + } +} + const missingEnv = requiredEnv.filter((name) => !process.env[name]); if (missingEnv.length > 0) { fail(`Start abgebrochen: fehlende Umgebungsvariablen: ${missingEnv.join(", ")}. Bitte .env anhand von .env.example konfigurieren.`); @@ -28,6 +48,7 @@ if (missingEnv.length > 0) { try { for (const directory of requiredDirs) ensureWritableDirectory(directory); + migrateLegacyUploads(); } catch (error) { fail(`Start abgebrochen: Runtime-Verzeichnis ist nicht beschreibbar. Details: ${error instanceof Error ? error.message : "unbekannter Fehler"}`); } diff --git a/public/uploads/.gitkeep b/storage/.gitkeep similarity index 100% rename from public/uploads/.gitkeep rename to storage/.gitkeep diff --git a/public/uploads/images/.gitkeep b/storage/uploads/.gitkeep similarity index 100% rename from public/uploads/images/.gitkeep rename to storage/uploads/.gitkeep diff --git a/storage/uploads/images/.gitkeep b/storage/uploads/images/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/storage/uploads/images/.gitkeep @@ -0,0 +1 @@ +