Merge pull request #14 from Dada1981/codex/persistence-seo-hardening
feat: secure admin authentication with sessions
This commit is contained in:
commit
463f98d078
7 changed files with 250 additions and 80 deletions
10
.env.example
10
.env.example
|
|
@ -3,10 +3,16 @@
|
|||
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||
|
||||
# Required in production. There is a development fallback, but it must not be
|
||||
# used for an internet-facing deployment.
|
||||
# Required in production (at least 12 characters).
|
||||
ADMIN_PASSWORD="CHANGE_ME_TO_A_LONG_RANDOM_PASSWORD"
|
||||
|
||||
# Required in production (at least 32 random characters). This is separate
|
||||
# from the password and signs the short-lived admin session cookie.
|
||||
SESSION_SECRET="CHANGE_ME_TO_AN_INDEPENDENT_LONG_RANDOM_SECRET"
|
||||
|
||||
# Set to 1 when exactly one trusted reverse proxy sits in front of Express.
|
||||
# TRUST_PROXY="1"
|
||||
|
||||
# Optional location for database.json, uploads and migration backups.
|
||||
# DATA_DIR="./data"
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ COPY --from=builder /app/dist ./dist
|
|||
EXPOSE 3000
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV ADMIN_PASSWORD=autor2026
|
||||
|
||||
# Execute standalone node production entrypoint
|
||||
CMD ["node", "dist/server.cjs"]
|
||||
|
|
|
|||
18
README.md
18
README.md
|
|
@ -34,16 +34,15 @@ Stellen Sie sicher, dass sich folgende Dateien im gleichen Ordner auf Ihrem Serv
|
|||
- Der gesamte Code-Ordner
|
||||
|
||||
### 2. Konfiguration anpassen
|
||||
Öffnen Sie die `docker-compose.yml` auf Ihrem Server und passen Sie folgende Umgebungsvariablen an:
|
||||
```yaml
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
# Das Passwort für Ihren Administrationsbereich (/admin):
|
||||
- ADMIN_PASSWORD=IhrSicheresLieblingsPasswort123
|
||||
# (Optional) Für den 21. Jahrhundert Schreibassistenten (Gemini 3.5 Flash)
|
||||
- GEMINI_API_KEY=Ihr_Gemini_API_Schluessel
|
||||
Kopieren Sie `.env.example` nach `.env` und tragen Sie dort die Geheimnisse ein. Die `.env`-Datei wird nicht eingecheckt:
|
||||
```dotenv
|
||||
ADMIN_PASSWORD=IhrSicheresLieblingsPasswort123
|
||||
SESSION_SECRET=EineUnabhaengigeZufaelligeZeichenfolgeMitMindestens32Zeichen
|
||||
GEMINI_API_KEY=Ihr_Gemini_API_Schluessel
|
||||
```
|
||||
|
||||
Der Admin-Login erzeugt eine auf 24 Stunden begrenzte, serverseitige Sitzung in einem `HttpOnly`-, `Secure`- und `SameSite=Strict`-Cookie. Ein Container-Neustart beendet aktive Sitzungen. Das Passwort selbst wird nicht im Browser gespeichert. Ohne `ADMIN_PASSWORD` und `SESSION_SECRET` startet die Anwendung im Produktionsmodus bewusst nicht.
|
||||
|
||||
### 3. Container starten
|
||||
Führen Sie im entsprechenden Verzeichnis folgenden Befehl aus:
|
||||
```bash
|
||||
|
|
@ -54,6 +53,8 @@ Die Anwendung baut das Image und startet die Autoren-Zentrale im Hintergrund. Si
|
|||
#### Reverse Proxy Tipp:
|
||||
Sie können ganz hervorragend einen Reverse Proxy wie **Nginx Proxy Manager**, **Traefik** oder **Caddy** davorhängen, um SSL-Zertifikate (Let's Encrypt) zuzuweisen und Ihre Domain auf den Container-Port `3000` umzuleiten.
|
||||
|
||||
Wenn genau ein vertrauenswürdiger Reverse Proxy vor dem Container sitzt und Port 3000 nicht direkt aus dem Internet erreichbar ist, setzen Sie zusätzlich `TRUST_PROXY=1` in `.env`. So verwendet das Login-Limit die ursprüngliche Client-IP. Bei direkter Veröffentlichung des Containerports darf diese Option nicht aktiviert werden.
|
||||
|
||||
---
|
||||
|
||||
## 💾 Manuelle Installation ohne Docker (Alternativ)
|
||||
|
|
@ -73,6 +74,7 @@ Sollten Sie die Software direkt auf Ihrem Server (ohne Docker) starten wollen:
|
|||
Erstellen Sie eine `.env`-Datei oder exportieren Sie diese im Terminal:
|
||||
```bash
|
||||
export ADMIN_PASSWORD="IhrSicheresPasswort"
|
||||
export SESSION_SECRET="EineUnabhaengigeZufaelligeZeichenfolgeMitMindestens32Zeichen"
|
||||
export GEMINI_API_KEY="Ihr_Gemini_API_Schlüssel"
|
||||
```
|
||||
5. **Starten:**
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ services:
|
|||
- ./data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
# Ändern Sie das Passwort für Ihren Administrationsbereich:
|
||||
- ADMIN_PASSWORD=IhrSicheresPasswort2026
|
||||
- TRUST_PROXY=${TRUST_PROXY:-}
|
||||
# Werte werden aus der nicht eingecheckten .env-Datei gelesen.
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:?ADMIN_PASSWORD muss in .env gesetzt sein}
|
||||
- SESSION_SECRET=${SESSION_SECRET:?SESSION_SECRET muss in .env gesetzt sein}
|
||||
# (Optional) Für den 21. Jahrhundert KI-Klappentextassistenten (Gemini 3.5 Flash)
|
||||
- GEMINI_API_KEY=Ihr_Gemini_API_Schluessel
|
||||
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
||||
|
|
|
|||
230
server.ts
230
server.ts
|
|
@ -1,6 +1,7 @@
|
|||
import express from "express";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "crypto";
|
||||
import dotenv from "dotenv";
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
import { defaultAuthorData } from "./src/defaultData.js";
|
||||
|
|
@ -33,6 +34,32 @@ const LEGACY_DEFAULT_IMAGE_URLS = new Set([
|
|||
]);
|
||||
let writeQueue: Promise<void> = Promise.resolve();
|
||||
let serverReady = false;
|
||||
const SESSION_COOKIE = "author_session";
|
||||
const SESSION_DURATION_MS = 24 * 60 * 60 * 1000;
|
||||
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
||||
const LOGIN_MAX_FAILURES = 5;
|
||||
const GEMINI_WINDOW_MS = 60 * 60 * 1000;
|
||||
const GEMINI_MAX_REQUESTS = 20;
|
||||
|
||||
interface SessionRecord {
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface RateRecord {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const sessions = new Map<string, SessionRecord>();
|
||||
const loginFailures = new Map<string, RateRecord>();
|
||||
const geminiRequests = new Map<string, RateRecord>();
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of sessions) if (value.expiresAt <= now) sessions.delete(key);
|
||||
for (const [key, value] of loginFailures) if (value.resetAt <= now) loginFailures.delete(key);
|
||||
for (const [key, value] of geminiRequests) if (value.resetAt <= now) geminiRequests.delete(key);
|
||||
}, 60 * 60 * 1000).unref();
|
||||
|
||||
const profileKeys = ["scifi", "erotica", "clara", "renee"] as const;
|
||||
type ProfileKey = typeof profileKeys[number];
|
||||
|
|
@ -192,6 +219,34 @@ async function initDatabase(): Promise<AuthorData> {
|
|||
let dbCache: AuthorData;
|
||||
|
||||
// Configure middleware
|
||||
if (process.env.TRUST_PROXY === "1") app.set("trust proxy", 1);
|
||||
|
||||
app.disable("x-powered-by");
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
res.setHeader("X-Frame-Options", "DENY");
|
||||
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()");
|
||||
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
const scriptNonce = randomBytes(18).toString("base64url");
|
||||
res.locals.scriptNonce = scriptNonce;
|
||||
res.setHeader("Content-Security-Policy", [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
"form-action 'self'",
|
||||
`script-src 'self' 'nonce-${scriptNonce}'`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: https:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"frame-src https://open.spotify.com",
|
||||
].join("; "));
|
||||
}
|
||||
next();
|
||||
});
|
||||
app.use(express.json({ limit: "10mb" }));
|
||||
|
||||
// Initialize Google GenAI if API key exists
|
||||
|
|
@ -208,28 +263,108 @@ const getGeminiClient = () => {
|
|||
});
|
||||
};
|
||||
|
||||
// Admin authentication password helper
|
||||
// In production or self-hosted, they set ADMIN_PASSWORD in environment or docker-compose.
|
||||
// Default fallback is "autor2026"
|
||||
const getAdminPassword = () => {
|
||||
return process.env.ADMIN_PASSWORD || "autor2026";
|
||||
};
|
||||
function getAdminPassword(): string {
|
||||
return process.env.ADMIN_PASSWORD || "dev-only-autor2026";
|
||||
}
|
||||
|
||||
// Authorization verification middleware
|
||||
const verifyToken = (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader) {
|
||||
res.status(401).json({ error: "Kein Autorisierungs-Token bereitgestellt." });
|
||||
function getSessionSecret(): string {
|
||||
return process.env.SESSION_SECRET || "dev-only-session-secret-change-me";
|
||||
}
|
||||
|
||||
function validateProductionSecrets(): void {
|
||||
if (process.env.NODE_ENV !== "production") return;
|
||||
const missing = ["ADMIN_PASSWORD", "SESSION_SECRET"].filter((name) => !process.env[name]?.trim());
|
||||
if (missing.length > 0) throw new Error(`Fehlende Produktionskonfiguration: ${missing.join(", ")}`);
|
||||
if ((process.env.ADMIN_PASSWORD?.length || 0) < 12) throw new Error("ADMIN_PASSWORD muss in Produktion mindestens 12 Zeichen lang sein.");
|
||||
if ((process.env.SESSION_SECRET?.length || 0) < 32) throw new Error("SESSION_SECRET muss in Produktion mindestens 32 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
function safeEqual(left: string, right: string): boolean {
|
||||
const leftBuffer = Buffer.from(left);
|
||||
const rightBuffer = Buffer.from(right);
|
||||
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
||||
}
|
||||
|
||||
function parseCookies(req: express.Request): Record<string, string> {
|
||||
return Object.fromEntries((req.headers.cookie || "").split(";").map((part) => part.trim()).filter(Boolean).map((part) => {
|
||||
const separator = part.indexOf("=");
|
||||
if (separator < 0) return [part, ""];
|
||||
return [part.slice(0, separator), decodeURIComponent(part.slice(separator + 1))];
|
||||
}));
|
||||
}
|
||||
|
||||
function sessionSignature(sessionId: string): string {
|
||||
return createHmac("sha256", getSessionSecret()).update(sessionId).digest("base64url");
|
||||
}
|
||||
|
||||
function sessionCookieValue(sessionId: string): string {
|
||||
return `${sessionId}.${sessionSignature(sessionId)}`;
|
||||
}
|
||||
|
||||
function readSessionId(req: express.Request): string | null {
|
||||
const value = parseCookies(req)[SESSION_COOKIE];
|
||||
if (!value) return null;
|
||||
const separator = value.lastIndexOf(".");
|
||||
if (separator < 1) return null;
|
||||
const sessionId = value.slice(0, separator);
|
||||
const signature = value.slice(separator + 1);
|
||||
if (!safeEqual(signature, sessionSignature(sessionId))) return null;
|
||||
const record = sessions.get(sessionId);
|
||||
if (!record || record.expiresAt <= Date.now()) {
|
||||
sessions.delete(sessionId);
|
||||
return null;
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
function setSessionCookie(res: express.Response, sessionId: string): void {
|
||||
const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
|
||||
res.append("Set-Cookie", `${SESSION_COOKIE}=${encodeURIComponent(sessionCookieValue(sessionId))}; Path=/api/admin; Max-Age=${SESSION_DURATION_MS / 1000}; HttpOnly; SameSite=Strict${secure}`);
|
||||
}
|
||||
|
||||
function clearSessionCookie(res: express.Response): void {
|
||||
const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
|
||||
res.append("Set-Cookie", `${SESSION_COOKIE}=; Path=/api/admin; Max-Age=0; HttpOnly; SameSite=Strict${secure}`);
|
||||
}
|
||||
|
||||
function verifySession(req: express.Request, res: express.Response, next: express.NextFunction): void {
|
||||
const sessionId = readSessionId(req);
|
||||
if (!sessionId) {
|
||||
clearSessionCookie(res);
|
||||
res.status(401).json({ error: "Keine gültige Admin-Sitzung vorhanden." });
|
||||
return;
|
||||
}
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
// To keep session simple, secure, and self-hosted, our auth token is just the password itself or adminPassword
|
||||
if (token === getAdminPassword()) {
|
||||
res.locals.sessionId = sessionId;
|
||||
next();
|
||||
} else {
|
||||
res.status(403).json({ error: "Ungültiges Passwort oder Sitzungstoken." });
|
||||
}
|
||||
|
||||
function verifySameOrigin(req: express.Request, res: express.Response, next: express.NextFunction): void {
|
||||
const origin = req.get("origin");
|
||||
if (!origin) {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
res.status(403).json({ error: "Fehlender Origin-Header." });
|
||||
return;
|
||||
}
|
||||
};
|
||||
next();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (new URL(origin).host !== req.get("host")) throw new Error("origin mismatch");
|
||||
next();
|
||||
} catch {
|
||||
res.status(403).json({ error: "Anfrage von einer fremden Herkunft abgelehnt." });
|
||||
}
|
||||
}
|
||||
|
||||
function rateRecord(map: Map<string, RateRecord>, key: string, windowMs: number): RateRecord {
|
||||
const existing = map.get(key);
|
||||
if (!existing || existing.resetAt <= Date.now()) {
|
||||
const fresh = { count: 0, resetAt: Date.now() + windowMs };
|
||||
map.set(key, fresh);
|
||||
return fresh;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
function cleanDomain(value: string): string {
|
||||
return value.toLowerCase().trim().replace(/^https?:\/\//, "").replace(/^www\./, "").split("/")[0];
|
||||
|
|
@ -281,7 +416,7 @@ function absoluteUrl(value: string | undefined, origin: string): string | undefi
|
|||
}
|
||||
}
|
||||
|
||||
function seoMeta(req: express.Request): string {
|
||||
function seoMeta(req: express.Request, scriptNonce?: string): string {
|
||||
const key = profileForRequest(req.hostname, req.path);
|
||||
const profile = dbCache[key];
|
||||
const forwardedProtocol = req.header("x-forwarded-proto")?.split(",")[0].trim();
|
||||
|
|
@ -315,7 +450,7 @@ function seoMeta(req: express.Request): string {
|
|||
`<meta property="og:url" content="${escapeHtml(canonical)}" />`,
|
||||
image ? `<meta property="og:image" content="${escapeHtml(image)}" />` : "",
|
||||
`<meta name="twitter:card" content="${image ? "summary_large_image" : "summary"}" />`,
|
||||
`<script type="application/ld+json">${structuredData}</script>`,
|
||||
`<script${scriptNonce ? ` nonce="${scriptNonce}"` : ""} type="application/ld+json">${structuredData}</script>`,
|
||||
].filter(Boolean).join("\n ");
|
||||
}
|
||||
|
||||
|
|
@ -338,24 +473,46 @@ app.get("/health/ready", (_req, res) => {
|
|||
res.json({ status: "ok", revision: dbCache.revision ?? 0 });
|
||||
});
|
||||
|
||||
// 2. Manage Admin Login
|
||||
app.post("/api/admin/login", (req, res) => {
|
||||
// 2. Manage short-lived admin sessions
|
||||
app.get("/api/admin/session", verifySession, (_req, res) => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.post("/api/admin/login", verifySameOrigin, (req, res) => {
|
||||
const rate = rateRecord(loginFailures, req.ip || "unknown", LOGIN_WINDOW_MS);
|
||||
if (rate.count >= LOGIN_MAX_FAILURES) {
|
||||
res.setHeader("Retry-After", Math.ceil((rate.resetAt - Date.now()) / 1000));
|
||||
res.status(429).json({ error: "Zu viele fehlgeschlagene Anmeldeversuche. Bitte später erneut versuchen." });
|
||||
return;
|
||||
}
|
||||
const { password } = req.body;
|
||||
if (!password) {
|
||||
if (!password || typeof password !== "string") {
|
||||
res.status(400).json({ error: "Passwort ist erforderlich." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password === getAdminPassword()) {
|
||||
// Return the token which client stores in localStorage
|
||||
res.json({ success: true, token: getAdminPassword() });
|
||||
} else {
|
||||
if (!safeEqual(password, getAdminPassword())) {
|
||||
rate.count += 1;
|
||||
res.status(401).json({ error: "Ungültiges Passwort. Bitte versuchen Sie es erneut." });
|
||||
return;
|
||||
}
|
||||
|
||||
loginFailures.delete(req.ip || "unknown");
|
||||
const sessionId = randomBytes(32).toString("base64url");
|
||||
sessions.set(sessionId, { expiresAt: Date.now() + SESSION_DURATION_MS });
|
||||
setSessionCookie(res, sessionId);
|
||||
res.json({ success: true, expiresInSeconds: SESSION_DURATION_MS / 1000 });
|
||||
});
|
||||
|
||||
app.post("/api/admin/logout", verifySameOrigin, (req, res) => {
|
||||
const sessionId = readSessionId(req);
|
||||
if (sessionId) sessions.delete(sessionId);
|
||||
clearSessionCookie(res);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// 3. Save modified profile configurations (About, Projects, Books)
|
||||
app.post("/api/admin/save-profile", verifyToken, async (req, res) => {
|
||||
app.post("/api/admin/save-profile", verifySession, verifySameOrigin, async (req, res) => {
|
||||
const { profileKey, profileData } = req.body;
|
||||
if (profileKey !== "scifi" && profileKey !== "erotica" && profileKey !== "clara" && profileKey !== "renee") {
|
||||
res.status(400).json({ error: "Ungültiger Profilschlüssel." });
|
||||
|
|
@ -380,7 +537,7 @@ app.post("/api/admin/save-profile", verifyToken, async (req, res) => {
|
|||
});
|
||||
|
||||
// 3b. Save legal documents (Impressum & Datenschutzerklärung)
|
||||
app.post("/api/admin/save-legal", verifyToken, async (req, res) => {
|
||||
app.post("/api/admin/save-legal", verifySession, verifySameOrigin, async (req, res) => {
|
||||
const { legalDocuments } = req.body;
|
||||
if (!Array.isArray(legalDocuments)) {
|
||||
res.status(400).json({ error: "legalDocuments muss ein Array sein." });
|
||||
|
|
@ -401,7 +558,15 @@ app.post("/api/admin/save-legal", verifyToken, async (req, res) => {
|
|||
});
|
||||
|
||||
// 4. Creative AI Blurb Assistant for book blurb updates
|
||||
app.post("/api/admin/generate-blurb", verifyToken, async (req, res) => {
|
||||
app.post("/api/admin/generate-blurb", verifySession, verifySameOrigin, async (req, res) => {
|
||||
const sessionId = res.locals.sessionId as string;
|
||||
const rate = rateRecord(geminiRequests, sessionId, GEMINI_WINDOW_MS);
|
||||
if (rate.count >= GEMINI_MAX_REQUESTS) {
|
||||
res.setHeader("Retry-After", Math.ceil((rate.resetAt - Date.now()) / 1000));
|
||||
res.status(429).json({ error: "Das stündliche Limit des Schreibassistenten ist erreicht." });
|
||||
return;
|
||||
}
|
||||
rate.count += 1;
|
||||
const { title, genre, ideas, tone } = req.body;
|
||||
|
||||
const ai = getGeminiClient();
|
||||
|
|
@ -436,7 +601,7 @@ Die Synopsis soll neugierig machen, stark die visuelle Stimmung rüberbringen un
|
|||
});
|
||||
|
||||
// 5. Upload a file via base64
|
||||
app.post("/api/admin/upload-file", verifyToken, async (req, res) => {
|
||||
app.post("/api/admin/upload-file", verifySession, verifySameOrigin, async (req, res) => {
|
||||
const { fileName, base64Data } = req.body;
|
||||
if (!fileName || !base64Data) {
|
||||
res.status(400).json({ error: "Dateiname und Base64-Daten sind erforderlich." });
|
||||
|
|
@ -461,7 +626,7 @@ app.post("/api/admin/upload-file", verifyToken, async (req, res) => {
|
|||
});
|
||||
|
||||
// 6. List uploaded files
|
||||
app.get("/api/admin/list-uploads", verifyToken, async (req, res) => {
|
||||
app.get("/api/admin/list-uploads", verifySession, async (req, res) => {
|
||||
try {
|
||||
const uploadsDir = path.join(DATA_DIR, "uploads");
|
||||
await fs.mkdir(uploadsDir, { recursive: true });
|
||||
|
|
@ -513,7 +678,7 @@ async function startServer() {
|
|||
|
||||
app.use(express.static(distPath, { index: false }));
|
||||
app.get("*", (req: express.Request, res: express.Response) => {
|
||||
const html = indexTemplate.replace("<!-- SEO_META -->\n <title>Autoren-Portfolio</title>", seoMeta(req));
|
||||
const html = indexTemplate.replace("<!-- SEO_META -->\n <title>Autoren-Portfolio</title>", seoMeta(req, res.locals.scriptNonce));
|
||||
if (req.path.startsWith("/admin")) res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive");
|
||||
res.type("html").send(html);
|
||||
});
|
||||
|
|
@ -537,6 +702,7 @@ async function startServer() {
|
|||
}
|
||||
|
||||
async function main() {
|
||||
validateProductionSecrets();
|
||||
dbCache = await initDatabase();
|
||||
await startServer();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ interface AdminPanelProps {
|
|||
export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [token, setToken] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
|
|
@ -50,14 +49,25 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
|||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const [aiResult, setAiResult] = useState("");
|
||||
|
||||
// Check login state on component mount
|
||||
// Restore a valid server-side session on component mount.
|
||||
useEffect(() => {
|
||||
const storedToken = localStorage.getItem("author_admin_token");
|
||||
if (storedToken) {
|
||||
setToken(storedToken);
|
||||
// Remove the legacy value that used to contain the admin password.
|
||||
localStorage.removeItem("author_admin_token");
|
||||
const restoreSession = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/admin/session");
|
||||
if (response.ok) {
|
||||
setIsLoggedIn(true);
|
||||
fetchAuthorData();
|
||||
await fetchAuthorData();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Admin session check failed:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
restoreSession();
|
||||
}, []);
|
||||
|
||||
const fetchAuthorData = async () => {
|
||||
|
|
@ -85,10 +95,9 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
|||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
localStorage.setItem("author_admin_token", data.token);
|
||||
setToken(data.token);
|
||||
setPassword("");
|
||||
setIsLoggedIn(true);
|
||||
fetchAuthorData();
|
||||
await fetchAuthorData();
|
||||
} else {
|
||||
setError(data.error || "Ungültiges Passwort.");
|
||||
}
|
||||
|
|
@ -99,10 +108,14 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
|||
}
|
||||
};
|
||||
|
||||
const handleLogoutLocal = () => {
|
||||
localStorage.removeItem("author_admin_token");
|
||||
const handleLogoutLocal = async () => {
|
||||
try {
|
||||
await fetch("/api/admin/logout", { method: "POST" });
|
||||
} catch (err) {
|
||||
console.error("Admin logout failed:", err);
|
||||
}
|
||||
setIsLoggedIn(false);
|
||||
setToken("");
|
||||
setAuthorData(null);
|
||||
onLogout();
|
||||
};
|
||||
|
||||
|
|
@ -117,7 +130,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
|||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
profileKey,
|
||||
|
|
@ -184,7 +196,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
|||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }),
|
||||
});
|
||||
|
|
@ -215,7 +226,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
|||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }),
|
||||
});
|
||||
|
|
@ -279,7 +289,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
|||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }),
|
||||
});
|
||||
|
|
@ -310,7 +319,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
|||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }),
|
||||
});
|
||||
|
|
@ -339,7 +347,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
|||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: aiTitle,
|
||||
|
|
@ -404,7 +411,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
|||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ legalDocuments: updatedDocs })
|
||||
});
|
||||
|
|
@ -431,7 +437,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
|||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ legalDocuments: updatedDocs })
|
||||
});
|
||||
|
|
@ -708,7 +713,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
|||
<ImagePicker
|
||||
value={profile.avatarUrl}
|
||||
onChange={(url) => updateProfileField("avatarUrl", url)}
|
||||
token={token}
|
||||
label="Autorenfoto / Avatar"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1268,7 +1272,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
|||
<ImagePicker
|
||||
value={profile.heroBannerUrl || ""}
|
||||
onChange={(url) => updateProfileField("heroBannerUrl", url)}
|
||||
token={token}
|
||||
label="Hero Banner-Bild"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-500">Wenn angegeben, wird dieses Bild als stimmungsvoller Hintergrund im oberen Bereich der Webseite hinterlegt.</p>
|
||||
|
|
@ -1400,7 +1403,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
|||
<ImagePicker
|
||||
value={projectForm.imageUrl || ""}
|
||||
onChange={(url) => setProjectForm({ ...projectForm, imageUrl: url })}
|
||||
token={token}
|
||||
label="Projekt-Detailbild"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1596,7 +1598,6 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
|
|||
<ImagePicker
|
||||
value={bookForm.coverUrl || ""}
|
||||
onChange={(url) => setBookForm({ ...bookForm, coverUrl: url })}
|
||||
token={token}
|
||||
label="Buch-Cover"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { Upload, Image as ImageIcon, Link as LinkIcon, X, Check, Loader2, Folder
|
|||
interface ImagePickerProps {
|
||||
value: string;
|
||||
onChange: (url: string) => void;
|
||||
token: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
|
|
@ -13,7 +12,7 @@ interface ServerFile {
|
|||
url: string;
|
||||
}
|
||||
|
||||
export default function ImagePicker({ value, onChange, token, label }: ImagePickerProps) {
|
||||
export default function ImagePicker({ value, onChange, label }: ImagePickerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"upload" | "server" | "url">("upload");
|
||||
const [manualUrl, setManualUrl] = useState(value || "");
|
||||
|
|
@ -40,11 +39,7 @@ export default function ImagePicker({ value, onChange, token, label }: ImagePick
|
|||
setLoadingFiles(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch("/api/admin/list-uploads", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
const response = await fetch("/api/admin/list-uploads");
|
||||
if (!response.ok) {
|
||||
throw new Error("Fehler beim Laden der Serverdateien");
|
||||
}
|
||||
|
|
@ -82,7 +77,6 @@ export default function ImagePicker({ value, onChange, token, label }: ImagePick
|
|||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fileName: file.name,
|
||||
|
|
|
|||
Loading…
Reference in a new issue