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
- Runtime-Daten liegen unter
data/ und werden nicht versioniert.
- - Uploads liegen unter
public/uploads/images/ und werden nicht versioniert.
+ - Uploads liegen unter
storage/uploads/images/ und werden nicht versioniert.
- Olympus- und SMTP-Integration sind vorbereitet, aber nicht aktiv implementiert.
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 @@
+