fix(admin): serve media from private storage
This commit is contained in:
parent
d9da86dd46
commit
faddaa91d0
14 changed files with 114 additions and 20 deletions
|
|
@ -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
7
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
22
app/api/media/[filename]/route.ts
Normal file
22
app/api/media/[filename]/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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." };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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"}`);
|
||||
}
|
||||
|
|
|
|||
1
storage/uploads/images/.gitkeep
Normal file
1
storage/uploads/images/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
Loading…
Add table
Add a link
Reference in a new issue