fix(admin): serve media from private storage

This commit is contained in:
Schubert Ferenc 2026-07-03 23:45:33 +02:00
parent d9da86dd46
commit faddaa91d0
14 changed files with 114 additions and 20 deletions

View file

@ -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

7
.gitignore vendored
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -37,7 +37,7 @@ export default async function AdminSystemPage() {
<h2>Betriebsregeln</h2>
<ul className="list">
<li>Runtime-Daten liegen unter <code>data/</code> und werden nicht versioniert.</li>
<li>Uploads liegen unter <code>public/uploads/images/</code> und werden nicht versioniert.</li>
<li>Uploads liegen unter <code>storage/uploads/images/</code> und werden nicht versioniert.</li>
<li>Olympus- und SMTP-Integration sind vorbereitet, aber nicht aktiv implementiert.</li>
</ul>
</section>

View file

@ -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",
},
});
}

View file

@ -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:

View file

@ -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<MediaFile[]> {
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<MediaFile[]> {
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." };

View file

@ -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 };

View file

@ -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."

View file

@ -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"}`);
}

View file

@ -0,0 +1 @@