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
|
.git
|
||||||
README.md
|
README.md
|
||||||
data/*.json
|
data/*.json
|
||||||
public/uploads/images/*
|
storage/uploads/images/*
|
||||||
!public/uploads/images/.gitkeep
|
!storage/uploads/images/.gitkeep
|
||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -9,9 +9,10 @@ data/contact-inquiries.json
|
||||||
data/repair-inquiries.json
|
data/repair-inquiries.json
|
||||||
data/site-settings.json
|
data/site-settings.json
|
||||||
data/smtp-settings.json
|
data/smtp-settings.json
|
||||||
public/uploads/images/*
|
storage/uploads/images/*
|
||||||
!public/uploads/.gitkeep
|
!storage/.gitkeep
|
||||||
!public/uploads/images/.gitkeep
|
!storage/uploads/.gitkeep
|
||||||
|
!storage/uploads/images/.gitkeep
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.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/.next/static ./.next/static
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts/runtime-start.js ./runtime-start.js
|
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 \
|
RUN mkdir -p /app/data /app/storage/uploads/images /app/.next/cache \
|
||||||
&& chown -R nextjs:nodejs /app/data /app/public/uploads /app/.next/cache
|
&& chown -R nextjs:nodejs /app/data /app/storage /app/.next/cache
|
||||||
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
EXPOSE 3010
|
EXPOSE 3010
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ Port:
|
||||||
Die Runtime-Daten werden über Docker-Volumes gespeichert:
|
Die Runtime-Daten werden über Docker-Volumes gespeichert:
|
||||||
|
|
||||||
- `funktechnik-data` → `/app/data`
|
- `funktechnik-data` → `/app/data`
|
||||||
- `funktechnik-uploads` → `/app/public/uploads/images`
|
- `funktechnik-storage` → `/app/storage`
|
||||||
- `funktechnik-next-cache` → `/app/.next/cache`
|
- `funktechnik-next-cache` → `/app/.next/cache`
|
||||||
|
|
||||||
Dadurch sind keine manuellen `chmod`- oder `chown`-Befehle notwendig.
|
Dadurch sind keine manuellen `chmod`- oder `chown`-Befehle notwendig.
|
||||||
|
|
@ -166,7 +166,9 @@ data/site-settings.json
|
||||||
data/smtp-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
|
## Healthcheck
|
||||||
|
|
||||||
|
|
@ -223,7 +225,7 @@ scripts/healthcheck.sh
|
||||||
scripts/backup.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
|
## Restore
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ export default async function AdminSystemPage() {
|
||||||
<h2>Betriebsregeln</h2>
|
<h2>Betriebsregeln</h2>
|
||||||
<ul className="list">
|
<ul className="list">
|
||||||
<li>Runtime-Daten liegen unter <code>data/</code> und werden nicht versioniert.</li>
|
<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>
|
<li>Olympus- und SMTP-Integration sind vorbereitet, aber nicht aktiv implementiert.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</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"
|
DOCKER_ENV: "true"
|
||||||
volumes:
|
volumes:
|
||||||
- funktechnik-data:/app/data
|
- funktechnik-data:/app/data
|
||||||
- funktechnik-uploads:/app/public/uploads/images
|
- funktechnik-storage:/app/storage
|
||||||
- funktechnik-next-cache:/app/.next/cache
|
- funktechnik-next-cache:/app/.next/cache
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
funktechnik-data:
|
funktechnik-data:
|
||||||
funktechnik-uploads:
|
funktechnik-storage:
|
||||||
funktechnik-next-cache:
|
funktechnik-next-cache:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { randomUUID } from "crypto";
|
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 path from "path";
|
||||||
import { uploadDirectory } from "@/lib/runtime/config";
|
import { migrateLegacyUploads, uploadDirectory } from "@/lib/runtime/config";
|
||||||
|
|
||||||
const maxUploadSize = 5 * 1024 * 1024;
|
const maxUploadSize = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
|
@ -23,6 +23,15 @@ export type MediaFile = {
|
||||||
isPdf: boolean;
|
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() {
|
export async function ensureUploadDirectory() {
|
||||||
await mkdir(uploadDirectory, { recursive: true });
|
await mkdir(uploadDirectory, { recursive: true });
|
||||||
const probe = path.join(uploadDirectory, `.write-check-${Date.now()}`);
|
const probe = path.join(uploadDirectory, `.write-check-${Date.now()}`);
|
||||||
|
|
@ -81,6 +90,7 @@ function isAllowedStoredFile(fileName: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listMediaFiles(): Promise<MediaFile[]> {
|
export async function listMediaFiles(): Promise<MediaFile[]> {
|
||||||
|
await migrateLegacyUploads();
|
||||||
await mkdir(uploadDirectory, { recursive: true });
|
await mkdir(uploadDirectory, { recursive: true });
|
||||||
const entries = await readdir(uploadDirectory);
|
const entries = await readdir(uploadDirectory);
|
||||||
const visibleEntries = entries.filter(isAllowedStoredFile);
|
const visibleEntries = entries.filter(isAllowedStoredFile);
|
||||||
|
|
@ -92,7 +102,7 @@ export async function listMediaFiles(): Promise<MediaFile[]> {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: entry,
|
name: entry,
|
||||||
url: `/uploads/images/${encodeURIComponent(entry)}`,
|
url: `/api/media/${encodeURIComponent(entry)}`,
|
||||||
type: isPdf ? "pdf" as const : "image" as const,
|
type: isPdf ? "pdf" as const : "image" as const,
|
||||||
extension: extension.replace(".", "").toUpperCase() || "Datei",
|
extension: extension.replace(".", "").toUpperCase() || "Datei",
|
||||||
size: fileStat.size,
|
size: fileStat.size,
|
||||||
|
|
@ -117,6 +127,24 @@ export async function saveMediaFile(file: File) {
|
||||||
return { fileName: safeName };
|
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) {
|
export async function deleteMediaFile(fileName: string) {
|
||||||
if (!isAllowedStoredFile(fileName) || path.basename(fileName) !== fileName) {
|
if (!isAllowedStoredFile(fileName) || path.basename(fileName) !== fileName) {
|
||||||
return { error: "Ungültiger Dateiname." };
|
return { error: "Ungültiger Dateiname." };
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { existsSync } from "fs";
|
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 path from "path";
|
||||||
import { appVersion } from "./version";
|
import { appVersion } from "./version";
|
||||||
|
|
||||||
export const dataDirectory = path.join(process.cwd(), "data");
|
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() {
|
export function adminConfigurationStatus() {
|
||||||
return process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD && process.env.ADMIN_SESSION_SECRET ? "configured" : "missing";
|
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() {
|
export async function ensureRuntimeDirectories() {
|
||||||
await mkdir(dataDirectory, { recursive: true });
|
await mkdir(dataDirectory, { recursive: true });
|
||||||
await mkdir(uploadDirectory, { recursive: true });
|
await mkdir(uploadDirectory, { recursive: true });
|
||||||
|
await migrateLegacyUploads();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkStorage() {
|
export async function checkStorage() {
|
||||||
|
|
@ -36,4 +39,20 @@ export function smtpStatus() {
|
||||||
return "prepared";
|
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 };
|
export { appVersion };
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
TARGET="${BACKUP_DIR}/funktechnik-data-${STAMP}.tar.gz"
|
TARGET="${BACKUP_DIR}/funktechnik-data-${STAMP}.tar.gz"
|
||||||
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
mkdir -p "$BACKUP_DIR"
|
||||||
tar -czf "$TARGET" data public/uploads
|
tar -czf "$TARGET" data storage
|
||||||
|
|
||||||
echo "Backup erstellt: $TARGET"
|
echo "Backup erstellt: $TARGET"
|
||||||
echo "Hinweis: .env wird aus Sicherheitsgruenden nicht automatisch gesichert."
|
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 requiredEnv = ["ADMIN_EMAIL", "ADMIN_PASSWORD", "ADMIN_SESSION_SECRET"];
|
||||||
const requiredDirs = [
|
const requiredDirs = [
|
||||||
path.join(process.cwd(), "data"),
|
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"),
|
path.join(process.cwd(), ".next", "cache"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -21,6 +21,26 @@ function ensureWritableDirectory(directory) {
|
||||||
fs.rmSync(probe, { force: true });
|
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]);
|
const missingEnv = requiredEnv.filter((name) => !process.env[name]);
|
||||||
if (missingEnv.length > 0) {
|
if (missingEnv.length > 0) {
|
||||||
fail(`Start abgebrochen: fehlende Umgebungsvariablen: ${missingEnv.join(", ")}. Bitte .env anhand von .env.example konfigurieren.`);
|
fail(`Start abgebrochen: fehlende Umgebungsvariablen: ${missingEnv.join(", ")}. Bitte .env anhand von .env.example konfigurieren.`);
|
||||||
|
|
@ -28,6 +48,7 @@ if (missingEnv.length > 0) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const directory of requiredDirs) ensureWritableDirectory(directory);
|
for (const directory of requiredDirs) ensureWritableDirectory(directory);
|
||||||
|
migrateLegacyUploads();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(`Start abgebrochen: Runtime-Verzeichnis ist nicht beschreibbar. Details: ${error instanceof Error ? error.message : "unbekannter Fehler"}`);
|
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