Merge cursor/umfassende-plattform-berarbeitung-d0f0 into dev_test

Resolve email API TLS/env var merge conflicts and bring latest platform changes into dev_test.
This commit is contained in:
Cursor Agent
2026-01-14 02:11:17 +00:00
102 changed files with 6325 additions and 1780 deletions

View File

@@ -26,7 +26,20 @@ export async function POST(request: NextRequest) {
// Track page view
if (type === 'pageview' && page) {
const projectIdNum = projectId ? parseInt(projectId.toString()) : null;
let projectIdNum: number | null = null;
if (projectId != null) {
const raw = projectId.toString();
const parsed = parseInt(raw, 10);
if (Number.isFinite(parsed)) {
projectIdNum = parsed;
} else {
const bySlug = await prisma.project.findFirst({
where: { slug: raw },
select: { id: true },
});
projectIdNum = bySlug?.id ?? null;
}
}
// Create page view record
await prisma.pageView.create({
@@ -83,7 +96,7 @@ export async function POST(request: NextRequest) {
where: {
OR: [
{ id: parseInt(slug) || 0 },
{ title: { contains: slug, mode: 'insensitive' } }
{ slug }
]
}
});

View File

@@ -1,9 +1,7 @@
import { type NextRequest, NextResponse } from "next/server";
import { PrismaClient } from '@prisma/client';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
import { checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
const prisma = new PrismaClient();
import { checkRateLimit, getRateLimitHeaders, requireSessionAuth } from '@/lib/auth';
import { prisma } from "@/lib/prisma";
export async function PUT(
request: NextRequest,
@@ -25,6 +23,11 @@ export async function PUT(
);
}
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
const authError = requireSessionAuth(request);
if (authError) return authError;
const resolvedParams = await params;
const id = parseInt(resolvedParams.id);
const body = await request.json();
@@ -93,6 +96,11 @@ export async function DELETE(
);
}
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
const authError = requireSessionAuth(request);
if (authError) return authError;
const resolvedParams = await params;
const id = parseInt(resolvedParams.id);

View File

@@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import { getContentByKey } from "@/lib/content";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const key = searchParams.get("key");
const locale = searchParams.get("locale") || "en";
if (!key) {
return NextResponse.json({ error: "key is required" }, { status: 400 });
}
const translation = await getContentByKey({ key, locale });
if (!translation) return NextResponse.json({ content: null });
return NextResponse.json({ content: translation });
}

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSessionAuth } from "@/lib/auth";
import { upsertContentByKey } from "@/lib/content";
export async function GET(request: NextRequest) {
const isAdminRequest = request.headers.get("x-admin-request") === "true";
if (!isAdminRequest) return NextResponse.json({ error: "Admin access required" }, { status: 403 });
const authError = requireSessionAuth(request);
if (authError) return authError;
const pages = await prisma.contentPage.findMany({
orderBy: { key: "asc" },
include: {
translations: {
select: { locale: true, updatedAt: true, title: true, slug: true },
},
},
});
return NextResponse.json({ pages });
}
export async function POST(request: NextRequest) {
const isAdminRequest = request.headers.get("x-admin-request") === "true";
if (!isAdminRequest) return NextResponse.json({ error: "Admin access required" }, { status: 403 });
const authError = requireSessionAuth(request);
if (authError) return authError;
const body = await request.json();
const { key, locale, title, slug, content, metaDescription, keywords } = body as Record<string, unknown>;
if (!key || typeof key !== "string") {
return NextResponse.json({ error: "key is required" }, { status: 400 });
}
if (!locale || typeof locale !== "string") {
return NextResponse.json({ error: "locale is required" }, { status: 400 });
}
if (!content || typeof content !== "object") {
return NextResponse.json({ error: "content (JSON) is required" }, { status: 400 });
}
const saved = await upsertContentByKey({
key,
locale,
title: typeof title === "string" ? title : null,
slug: typeof slug === "string" ? slug : null,
content,
metaDescription: typeof metaDescription === "string" ? metaDescription : null,
keywords: typeof keywords === "string" ? keywords : null,
});
return NextResponse.json({ saved });
}

View File

@@ -3,7 +3,7 @@ import nodemailer from "nodemailer";
import SMTPTransport from "nodemailer/lib/smtp-transport";
import Mail from "nodemailer/lib/mailer";
import { checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { prisma } from "@/lib/prisma";
// Sanitize input to prevent XSS
function sanitizeInput(input: string, maxLength: number = 10000): string {
@@ -115,10 +115,12 @@ export async function POST(request: NextRequest) {
connectionTimeout: 30000, // 30 seconds
greetingTimeout: 30000, // 30 seconds
socketTimeout: 60000, // 60 seconds
// TLS hardening (allow insecure only when explicitly enabled)
tls: process.env.SMTP_ALLOW_INSECURE_TLS === 'true'
// TLS hardening (allow insecure/self-signed only when explicitly enabled)
tls:
process.env.SMTP_ALLOW_INSECURE_TLS === "true" ||
process.env.SMTP_ALLOW_SELF_SIGNED === "true"
? { rejectUnauthorized: false }
: { rejectUnauthorized: true, minVersion: 'TLSv1.2' }
: { rejectUnauthorized: true, minVersion: "TLSv1.2" },
};
// Creating transport with configured options

View File

@@ -1,66 +1,58 @@
import { NextResponse } from "next/server";
import NodeCache from "node-cache";
// Use a dynamic import for node-fetch so tests that mock it (via jest.mock) are respected
async function getFetch() {
try {
const mod = await import("node-fetch");
// support both CJS and ESM interop
return (mod as { default: unknown }).default ?? mod;
} catch (_err) {
return globalThis.fetch;
}
}
import { prisma } from "@/lib/prisma";
export const runtime = "nodejs"; // Force Node runtime
const GHOST_API_URL = process.env.GHOST_API_URL;
const GHOST_API_KEY = process.env.GHOST_API_KEY;
const cache = new NodeCache({ stdTTL: 300 }); // Cache für 5 Minuten
type GhostPost = {
type LegacyPost = {
slug: string;
id: string;
title: string;
feature_image: string;
visibility: string;
published_at: string;
meta_description: string | null;
updated_at: string;
html: string;
reading_time: number;
meta_description: string;
};
type GhostPostsResponse = {
posts: Array<GhostPost>;
type LegacyPostsResponse = {
posts: Array<LegacyPost>;
};
export async function GET() {
const cacheKey = "ghostPosts";
const cachedPosts = cache.get<GhostPostsResponse>(cacheKey);
const cacheKey = "projects:legacyPosts";
const cachedPosts = cache.get<LegacyPostsResponse>(cacheKey);
if (cachedPosts) {
return NextResponse.json(cachedPosts);
}
try {
const fetchFn = await getFetch();
const response = await (fetchFn as unknown as typeof fetch)(
`${GHOST_API_URL}/ghost/api/content/posts/?key=${GHOST_API_KEY}&limit=all`,
);
const posts: GhostPostsResponse =
(await response.json()) as GhostPostsResponse;
const projects = await prisma.project.findMany({
where: { published: true },
orderBy: { updatedAt: "desc" },
select: {
id: true,
slug: true,
title: true,
updatedAt: true,
metaDescription: true,
},
});
if (!posts || !posts.posts) {
console.error("Invalid posts data");
return NextResponse.json([]);
}
const payload: LegacyPostsResponse = {
posts: projects.map((p) => ({
id: String(p.id),
slug: p.slug,
title: p.title,
meta_description: p.metaDescription ?? null,
updated_at: (p.updatedAt ?? new Date()).toISOString(),
})),
};
cache.set(cacheKey, posts); // Daten im Cache speichern
return NextResponse.json(posts);
cache.set(cacheKey, payload);
return NextResponse.json(payload);
} catch (error) {
console.error("Failed to fetch posts from Ghost:", error);
console.error("Failed to fetch projects:", error);
return NextResponse.json(
{ error: "Failed to fetch projects" },
{ status: 500 },

View File

@@ -1,10 +1,8 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export const runtime = "nodejs"; // Force Node runtime
const GHOST_API_URL = process.env.GHOST_API_URL;
const GHOST_API_KEY = process.env.GHOST_API_KEY;
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const slug = searchParams.get("slug");
@@ -14,59 +12,37 @@ export async function GET(request: Request) {
}
try {
// Debug: show whether fetch is present/mocked
const project = await prisma.project.findUnique({
where: { slug },
select: {
id: true,
slug: true,
title: true,
updatedAt: true,
metaDescription: true,
description: true,
content: true,
},
});
/* eslint-disable @typescript-eslint/no-explicit-any */
console.log(
"DEBUG fetch in fetchProject:",
typeof (globalThis as any).fetch,
"globalIsMock:",
!!(globalThis as any).fetch?._isMockFunction,
);
// Try global fetch first (as tests often mock it). If it fails or returns undefined,
// fall back to dynamically importing node-fetch.
let response: any;
if (typeof (globalThis as any).fetch === "function") {
try {
response = await (globalThis as any).fetch(
`${GHOST_API_URL}/ghost/api/content/posts/slug/${slug}/?key=${GHOST_API_KEY}`,
);
} catch (_e) {
response = undefined;
}
if (!project) {
return NextResponse.json({ posts: [] }, { status: 200 });
}
if (!response || typeof response.ok === "undefined") {
try {
const mod = await import("node-fetch");
const nodeFetch = (mod as any).default ?? mod;
response = await (nodeFetch as any)(
`${GHOST_API_URL}/ghost/api/content/posts/slug/${slug}/?key=${GHOST_API_KEY}`,
);
} catch (_err) {
response = undefined;
}
}
/* eslint-enable @typescript-eslint/no-explicit-any */
// Debug: inspect the response returned from the fetch
// Debug: inspect the response returned from the fetch
console.log("DEBUG fetch response:", response);
if (!response || !response.ok) {
throw new Error(
`Failed to fetch post: ${response?.statusText ?? "no response"}`,
);
}
const post = await response.json();
return NextResponse.json(post);
// Legacy shape (Ghost-like) for compatibility with older frontend/tests.
return NextResponse.json({
posts: [
{
id: String(project.id),
title: project.title,
meta_description: project.metaDescription ?? project.description ?? "",
slug: project.slug,
updated_at: (project.updatedAt ?? new Date()).toISOString(),
},
],
});
} catch (error) {
console.error("Failed to fetch post from Ghost:", error);
console.error("Failed to fetch project:", error);
return NextResponse.json(
{ error: "Failed to fetch project" },
{ status: 500 },

View File

@@ -3,6 +3,7 @@ import { prisma } from '@/lib/prisma';
import { apiCache } from '@/lib/cache';
import { checkRateLimit, getRateLimitHeaders, requireSessionAuth } from '@/lib/auth';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
import { generateUniqueSlug } from '@/lib/slug';
export async function GET(
request: NextRequest,
@@ -88,12 +89,37 @@ export async function PUT(
const data = await request.json();
// Remove difficulty field if it exists (since we're removing it)
const { difficulty, ...projectData } = data;
const { difficulty, slug, defaultLocale, ...projectData } = data;
// Keep slug stable by default; only update if explicitly provided,
// or if the project currently has no slug (e.g. after migration).
const existing = await prisma.project.findUnique({
where: { id },
select: { slug: true, title: true },
});
const nextSlug =
typeof slug === 'string' && slug.trim()
? slug.trim()
: existing?.slug?.trim()
? existing.slug
: await generateUniqueSlug({
base: String(projectData.title || existing?.title || 'project'),
isTaken: async (candidate) => {
const found = await prisma.project.findUnique({
where: { slug: candidate },
select: { id: true },
});
return !!found && found.id !== id;
},
});
const project = await prisma.project.update({
where: { id },
data: {
...projectData,
slug: nextSlug,
defaultLocale: typeof defaultLocale === 'string' && defaultLocale ? defaultLocale : undefined,
updatedAt: new Date(),
// Keep existing difficulty if not provided
...(difficulty ? { difficulty } : {})

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSessionAuth } from "@/lib/auth";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const isAdminRequest = request.headers.get("x-admin-request") === "true";
if (!isAdminRequest) return NextResponse.json({ error: "Admin access required" }, { status: 403 });
const authError = requireSessionAuth(request);
if (authError) return authError;
const { id: idParam } = await params;
const id = parseInt(idParam, 10);
if (!Number.isFinite(id)) return NextResponse.json({ error: "Invalid project id" }, { status: 400 });
const { searchParams } = new URL(request.url);
const locale = searchParams.get("locale") || "en";
const translation = await prisma.projectTranslation.findFirst({
where: { projectId: id, locale },
});
return NextResponse.json({ translation });
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const isAdminRequest = request.headers.get("x-admin-request") === "true";
if (!isAdminRequest) return NextResponse.json({ error: "Admin access required" }, { status: 403 });
const authError = requireSessionAuth(request);
if (authError) return authError;
const { id: idParam } = await params;
const id = parseInt(idParam, 10);
if (!Number.isFinite(id)) return NextResponse.json({ error: "Invalid project id" }, { status: 400 });
const body = (await request.json()) as {
locale?: string;
title?: string;
description?: string;
};
const locale = body.locale || "en";
const title = body.title?.trim();
const description = body.description?.trim();
if (!title || !description) {
return NextResponse.json({ error: "title and description are required" }, { status: 400 });
}
const saved = await prisma.projectTranslation.upsert({
where: { projectId_locale: { projectId: id, locale } },
create: {
projectId: id,
locale,
title,
description,
},
update: {
title,
description,
},
});
return NextResponse.json({ translation: saved });
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { projectService } from '@/lib/prisma';
import { prisma, projectService } from '@/lib/prisma';
import { requireSessionAuth } from '@/lib/auth';
export async function GET(request: NextRequest) {
@@ -9,16 +9,39 @@ export async function GET(request: NextRequest) {
const authError = requireSessionAuth(request);
if (authError) return authError;
// Get all projects with full data
const projectsResult = await projectService.getAllProjects();
// Projects (with translations)
const projectsResult = await projectService.getAllProjects({ limit: 10000 });
const projects = projectsResult.projects || projectsResult;
const projectIds = projects.map((p: { id: number }) => p.id);
const projectTranslations = await prisma.projectTranslation.findMany({
where: { projectId: { in: projectIds } },
orderBy: [{ projectId: 'asc' }, { locale: 'asc' }],
});
// CMS content pages (with translations)
const contentPages = await prisma.contentPage.findMany({
orderBy: { key: 'asc' },
include: {
translations: {
orderBy: { locale: 'asc' },
},
},
});
const siteSettings = await prisma.siteSettings.findUnique({ where: { id: 1 } });
// Format for export
const exportData = {
version: '1.0',
version: '2.0',
exportDate: new Date().toISOString(),
siteSettings,
contentPages,
projectTranslations,
projects: projects.map(project => ({
id: project.id,
slug: (project as unknown as { slug?: string }).slug,
defaultLocale: (project as unknown as { defaultLocale?: string }).defaultLocale,
title: project.title,
description: project.description,
content: project.content,

View File

@@ -1,86 +1,309 @@
import { NextRequest, NextResponse } from 'next/server';
import { projectService } from '@/lib/prisma';
import { requireSessionAuth } from '@/lib/auth';
import { NextRequest, NextResponse } from "next/server";
import { prisma, projectService } from "@/lib/prisma";
import { requireSessionAuth } from "@/lib/auth";
import type { Prisma } from "@prisma/client";
type ImportSiteSettings = {
defaultLocale?: unknown;
locales?: unknown;
theme?: unknown;
};
type ImportContentPageTranslation = {
locale?: unknown;
title?: unknown;
slug?: unknown;
content?: unknown;
metaDescription?: unknown;
keywords?: unknown;
};
type ImportContentPage = {
key?: unknown;
status?: unknown;
translations?: unknown;
};
type ImportProject = {
id?: unknown;
slug?: unknown;
defaultLocale?: unknown;
title?: unknown;
description?: unknown;
content?: unknown;
tags?: unknown;
category?: unknown;
featured?: unknown;
github?: unknown;
live?: unknown;
published?: unknown;
imageUrl?: unknown;
difficulty?: unknown;
timeToComplete?: unknown;
technologies?: unknown;
challenges?: unknown;
lessonsLearned?: unknown;
futureImprovements?: unknown;
demoVideo?: unknown;
screenshots?: unknown;
colorScheme?: unknown;
accessibility?: unknown;
performance?: unknown;
analytics?: unknown;
};
type ImportProjectTranslation = {
projectId?: unknown;
locale?: unknown;
title?: unknown;
description?: unknown;
content?: unknown;
metaDescription?: unknown;
keywords?: unknown;
ogImage?: unknown;
schema?: unknown;
};
type ImportPayload = {
projects?: unknown;
siteSettings?: unknown;
contentPages?: unknown;
projectTranslations?: unknown;
};
function asString(v: unknown): string | null {
return typeof v === "string" ? v : null;
}
function asStringArray(v: unknown): string[] | null {
if (!Array.isArray(v)) return null;
const allStrings = v.filter((x) => typeof x === "string") as string[];
return allStrings.length === v.length ? allStrings : null;
}
export async function POST(request: NextRequest) {
try {
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
const isAdminRequest = request.headers.get("x-admin-request") === "true";
if (!isAdminRequest) {
return NextResponse.json({ error: "Admin access required" }, { status: 403 });
}
const authError = requireSessionAuth(request);
if (authError) return authError;
const body = await request.json();
const body = (await request.json()) as ImportPayload;
// Validate import data structure
if (!body.projects || !Array.isArray(body.projects)) {
if (!Array.isArray(body.projects)) {
return NextResponse.json(
{ error: 'Invalid import data format' },
{ status: 400 }
{ error: "Invalid import data format" },
{ status: 400 },
);
}
const results = {
imported: 0,
skipped: 0,
errors: [] as string[]
errors: [] as string[],
};
// Import SiteSettings (optional)
if (body.siteSettings && typeof body.siteSettings === "object") {
try {
const ss = body.siteSettings as ImportSiteSettings;
const defaultLocale = asString(ss.defaultLocale);
const locales = asStringArray(ss.locales);
const theme = ss.theme as Prisma.InputJsonValue | undefined;
await prisma.siteSettings.upsert({
where: { id: 1 },
create: {
id: 1,
...(defaultLocale ? { defaultLocale } : {}),
...(locales ? { locales } : {}),
...(theme ? { theme } : {}),
},
update: {
...(defaultLocale ? { defaultLocale } : {}),
...(locales ? { locales } : {}),
...(theme ? { theme } : {}),
},
});
} catch {
// non-blocking
}
}
// Import CMS content pages (optional)
if (Array.isArray(body.contentPages)) {
for (const page of body.contentPages) {
try {
const key = asString((page as ImportContentPage)?.key);
if (!key) continue;
const statusRaw = asString((page as ImportContentPage)?.status);
const status = statusRaw === "DRAFT" || statusRaw === "PUBLISHED" ? statusRaw : "PUBLISHED";
const upserted = await prisma.contentPage.upsert({
where: { key },
create: { key, status },
update: { status },
});
const translations = (page as ImportContentPage)?.translations;
if (Array.isArray(translations)) {
for (const tr of translations as ImportContentPageTranslation[]) {
const locale = asString(tr?.locale);
if (!locale || typeof tr?.content === "undefined" || tr?.content === null) continue;
await prisma.contentPageTranslation.upsert({
where: { pageId_locale: { pageId: upserted.id, locale } },
create: {
pageId: upserted.id,
locale,
title: asString(tr.title),
slug: asString(tr.slug),
content: tr.content as Prisma.InputJsonValue,
metaDescription: asString(tr.metaDescription),
keywords: asString(tr.keywords),
},
update: {
title: asString(tr.title),
slug: asString(tr.slug),
content: tr.content as Prisma.InputJsonValue,
metaDescription: asString(tr.metaDescription),
keywords: asString(tr.keywords),
},
});
}
}
} catch (error) {
const key = asString((page as ImportContentPage)?.key) ?? "unknown";
results.errors.push(
`Failed to import content page "${key}": ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
}
// Preload existing titles once (avoid O(n^2) DB reads during import)
const existingProjectsResult = await projectService.getAllProjects({ limit: 10000 });
const existingProjects = existingProjectsResult.projects || existingProjectsResult;
const existingTitles = new Set(existingProjects.map(p => p.title));
const existingSlugs = new Set(
existingProjects
.map((p) => (p as unknown as { slug?: string }).slug)
.filter((s): s is string => typeof s === "string" && s.length > 0),
);
// Process each project
for (const projectData of body.projects) {
for (const projectData of body.projects as ImportProject[]) {
try {
// Check if project already exists (by title)
const exists = existingTitles.has(projectData.title);
const title = asString(projectData.title);
if (!title) continue;
const exists = existingTitles.has(title);
if (exists) {
results.skipped++;
results.errors.push(`Project "${projectData.title}" already exists`);
results.errors.push(`Project "${title}" already exists`);
continue;
}
// Create new project
await projectService.createProject({
title: projectData.title,
description: projectData.description,
content: projectData.content,
tags: projectData.tags || [],
category: projectData.category,
featured: projectData.featured || false,
github: projectData.github,
live: projectData.live,
const created = await projectService.createProject({
slug: asString(projectData.slug) ?? undefined,
defaultLocale: asString(projectData.defaultLocale) ?? "en",
title,
description: asString(projectData.description) ?? "",
content: projectData.content as Prisma.InputJsonValue | undefined,
tags: (asStringArray(projectData.tags) ?? []) as string[],
category: asString(projectData.category) ?? "General",
featured: projectData.featured === true,
github: asString(projectData.github) ?? undefined,
live: asString(projectData.live) ?? undefined,
published: projectData.published !== false, // Default to true
imageUrl: projectData.imageUrl,
difficulty: projectData.difficulty || 'Intermediate',
timeToComplete: projectData.timeToComplete,
technologies: projectData.technologies || [],
challenges: projectData.challenges || [],
lessonsLearned: projectData.lessonsLearned || [],
futureImprovements: projectData.futureImprovements || [],
demoVideo: projectData.demoVideo,
screenshots: projectData.screenshots || [],
colorScheme: projectData.colorScheme || 'Dark',
imageUrl: asString(projectData.imageUrl) ?? undefined,
difficulty: asString(projectData.difficulty) ?? "Intermediate",
timeToComplete: asString(projectData.timeToComplete) ?? undefined,
technologies: (asStringArray(projectData.technologies) ?? []) as string[],
challenges: (asStringArray(projectData.challenges) ?? []) as string[],
lessonsLearned: (asStringArray(projectData.lessonsLearned) ?? []) as string[],
futureImprovements: (asStringArray(projectData.futureImprovements) ?? []) as string[],
demoVideo: asString(projectData.demoVideo) ?? undefined,
screenshots: (asStringArray(projectData.screenshots) ?? []) as string[],
colorScheme: asString(projectData.colorScheme) ?? "Dark",
accessibility: projectData.accessibility !== false, // Default to true
performance: projectData.performance || {
performance: (projectData.performance as Record<string, unknown> | null) || {
lighthouse: 0,
bundleSize: '0KB',
loadTime: '0s'
bundleSize: "0KB",
loadTime: "0s",
},
analytics: projectData.analytics || {
analytics: (projectData.analytics as Record<string, unknown> | null) || {
views: 0,
likes: 0,
shares: 0
}
shares: 0,
},
});
// Import translations (optional, from export v2)
if (Array.isArray(body.projectTranslations)) {
for (const tr of body.projectTranslations as ImportProjectTranslation[]) {
const projectId = typeof tr?.projectId === "number" ? tr.projectId : null;
const locale = asString(tr?.locale);
if (!projectId || !locale) continue;
// Map translation to created project by original slug/title when possible.
// We match by slug if available in exported project list; otherwise by title.
const exportedProject = (body.projects as ImportProject[]).find(
(p) => typeof p.id === "number" && p.id === projectId,
);
const exportedSlug = asString(exportedProject?.slug);
const matches =
(exportedSlug && (created as unknown as { slug?: string }).slug === exportedSlug) ||
(!!asString(exportedProject?.title) &&
(created as unknown as { title?: string }).title === asString(exportedProject?.title));
if (!matches) continue;
const trTitle = asString(tr.title);
const trDescription = asString(tr.description);
if (!trTitle || !trDescription) continue;
await prisma.projectTranslation.upsert({
where: {
projectId_locale: {
projectId: (created as unknown as { id: number }).id,
locale,
},
},
create: {
projectId: (created as unknown as { id: number }).id,
locale,
title: trTitle,
description: trDescription,
content: (tr.content as Prisma.InputJsonValue) ?? null,
metaDescription: asString(tr.metaDescription),
keywords: asString(tr.keywords),
ogImage: asString(tr.ogImage),
schema: (tr.schema as Prisma.InputJsonValue) ?? null,
},
update: {
title: trTitle,
description: trDescription,
content: (tr.content as Prisma.InputJsonValue) ?? null,
metaDescription: asString(tr.metaDescription),
keywords: asString(tr.keywords),
ogImage: asString(tr.ogImage),
schema: (tr.schema as Prisma.InputJsonValue) ?? null,
},
});
}
}
results.imported++;
existingTitles.add(projectData.title);
existingTitles.add(title);
const slug = asString(projectData.slug);
if (slug) existingSlugs.add(slug);
} catch (error) {
results.skipped++;
results.errors.push(`Failed to import "${projectData.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
const title = asString(projectData.title) ?? "unknown";
results.errors.push(
`Failed to import "${title}": ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
@@ -90,10 +313,10 @@ export async function POST(request: NextRequest) {
results
});
} catch (error) {
console.error('Import error:', error);
console.error("Import error:", error);
return NextResponse.json(
{ error: 'Failed to import projects' },
{ status: 500 }
{ error: "Failed to import projects" },
{ status: 500 },
);
}
}

View File

@@ -1,21 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { apiCache } from '@/lib/cache';
import { requireSessionAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
import { requireSessionAuth, checkRateLimit, getRateLimitHeaders, getClientIp } from '@/lib/auth';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
import { generateUniqueSlug } from '@/lib/slug';
export async function GET(request: NextRequest) {
try {
// Rate limiting
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
if (!checkRateLimit(ip, 10, 60000)) { // 10 requests per minute
const ip = getClientIp(request);
const rlKey = ip !== "unknown" ? ip : `dev_unknown:${request.headers.get("user-agent") || "ua"}`;
// In development we keep this very high to avoid breaking local navigation/HMR.
const max = process.env.NODE_ENV === "development" ? 300 : 60;
if (!checkRateLimit(rlKey, max, 60000)) {
return new NextResponse(
JSON.stringify({ error: 'Rate limit exceeded' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
...getRateLimitHeaders(ip, 10, 60000)
...getRateLimitHeaders(rlKey, max, 60000)
}
}
);
@@ -154,11 +158,27 @@ export async function POST(request: NextRequest) {
// Remove difficulty field if it exists (since we're removing it)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { difficulty, ...projectData } = data;
const { difficulty, slug, defaultLocale, ...projectData } = data;
const derivedSlug =
typeof slug === 'string' && slug.trim()
? slug.trim()
: await generateUniqueSlug({
base: String(projectData.title || 'project'),
isTaken: async (candidate) => {
const existing = await prisma.project.findUnique({
where: { slug: candidate },
select: { id: true },
});
return !!existing;
},
});
const project = await prisma.project.create({
data: {
...projectData,
slug: derivedSlug,
defaultLocale: typeof defaultLocale === 'string' && defaultLocale ? defaultLocale : undefined,
// Set default difficulty since it's required in schema
difficulty: 'INTERMEDIATE',
performance: data.performance || { lighthouse: 0, bundleSize: '0KB', loadTime: '0s' },

View File

@@ -9,28 +9,15 @@ export async function GET(request: NextRequest) {
const category = searchParams.get('category');
if (slug) {
// Search by slug (convert title to slug format)
const projects = await prisma.project.findMany({
const project = await prisma.project.findFirst({
where: {
published: true
published: true,
slug,
},
orderBy: { createdAt: 'desc' }
orderBy: { createdAt: 'desc' },
});
// Find exact match by converting titles to slugs
const foundProject = projects.find(project => {
const projectSlug = project.title.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return projectSlug === slug;
});
if (foundProject) {
return NextResponse.json({ projects: [foundProject] });
}
// If no exact match, return empty array
return NextResponse.json({ projects: [] });
return NextResponse.json({ projects: project ? [project] : [] });
}
if (search) {

View File

@@ -1,164 +1,22 @@
import { NextResponse } from "next/server";
interface Project {
slug: string;
updated_at?: string; // Optional timestamp for last modification
}
interface ProjectsData {
posts: Project[];
}
import { generateSitemapXml, getSitemapEntries } from "@/lib/sitemap";
export const dynamic = "force-dynamic";
export const runtime = "nodejs"; // Force Node runtime
// Read Ghost API config at runtime, tests may set env vars in beforeAll
// Funktion, um die XML für die Sitemap zu generieren
function generateXml(sitemapRoutes: { url: string; lastModified: string }[]) {
const xmlHeader = '<?xml version="1.0" encoding="UTF-8"?>';
const urlsetOpen =
'<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">';
const urlsetClose = "</urlset>";
const urlEntries = sitemapRoutes
.map(
(route) => `
<url>
<loc>${route.url}</loc>
<lastmod>${route.lastModified}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>`,
)
.join("");
return `${xmlHeader}${urlsetOpen}${urlEntries}${urlsetClose}`;
}
export const runtime = "nodejs";
export async function GET() {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
// Statische Routen
const staticRoutes = [
{
url: `${baseUrl}/`,
lastModified: new Date().toISOString(),
priority: 1,
changeFreq: "weekly",
},
{
url: `${baseUrl}/legal-notice`,
lastModified: new Date().toISOString(),
priority: 0.5,
changeFreq: "yearly",
},
{
url: `${baseUrl}/privacy-policy`,
lastModified: new Date().toISOString(),
priority: 0.5,
changeFreq: "yearly",
},
];
// In test environment we can short-circuit and use a mocked posts payload
if (process.env.NODE_ENV === "test" && process.env.GHOST_MOCK_POSTS) {
const mockData = JSON.parse(process.env.GHOST_MOCK_POSTS);
const projects = (mockData as ProjectsData).posts || [];
const sitemapRoutes = projects.map((project) => {
const lastModified = project.updated_at || new Date().toISOString();
return {
url: `${baseUrl}/projects/${project.slug}`,
lastModified,
priority: 0.8,
changeFreq: "monthly",
};
});
const allRoutes = [...staticRoutes, ...sitemapRoutes];
const xml = generateXml(allRoutes);
// For tests return a plain object so tests can inspect `.body` easily
if (process.env.NODE_ENV === "test") {
return new NextResponse(xml, {
headers: { "Content-Type": "application/xml" },
});
}
try {
const entries = await getSitemapEntries();
const xml = generateSitemapXml(entries);
return new NextResponse(xml, {
headers: { "Content-Type": "application/xml" },
});
}
try {
// Debug: show whether fetch is present/mocked
// Try global fetch first (tests may mock global.fetch)
let response: Response | undefined;
try {
if (typeof globalThis.fetch === "function") {
response = await globalThis.fetch(
`${process.env.GHOST_API_URL}/ghost/api/content/posts/?key=${process.env.GHOST_API_KEY}&limit=all`,
);
// Debug: inspect the result
console.log("DEBUG sitemap global fetch returned:", response);
}
} catch (_e) {
response = undefined;
}
if (!response || typeof response.ok === "undefined" || !response.ok) {
try {
const mod = await import("node-fetch");
const nodeFetch = mod.default ?? mod;
response = await (nodeFetch as unknown as typeof fetch)(
`${process.env.GHOST_API_URL}/ghost/api/content/posts/?key=${process.env.GHOST_API_KEY}&limit=all`,
);
} catch (err) {
console.log("Failed to fetch posts from Ghost:", err);
return new NextResponse(generateXml(staticRoutes), {
headers: { "Content-Type": "application/xml" },
});
}
}
if (!response || !response.ok) {
console.error(
`Failed to fetch posts: ${response?.statusText ?? "no response"}`,
);
return new NextResponse(generateXml(staticRoutes), {
headers: { "Content-Type": "application/xml" },
});
}
const projectsData = (await response.json()) as ProjectsData;
const projects = projectsData.posts;
// Dynamische Projekt-Routen generieren
const sitemapRoutes = projects.map((project) => {
const lastModified = project.updated_at || new Date().toISOString();
return {
url: `${baseUrl}/projects/${project.slug}`,
lastModified,
priority: 0.8,
changeFreq: "monthly",
};
});
const allRoutes = [...staticRoutes, ...sitemapRoutes];
// Rückgabe der Sitemap im XML-Format
return new NextResponse(generateXml(allRoutes), {
headers: { "Content-Type": "application/xml" },
});
} catch (error) {
console.log("Failed to fetch posts from Ghost:", error);
// Rückgabe der statischen Routen, falls Fehler auftritt
return new NextResponse(generateXml(staticRoutes), {
console.error("Failed to generate sitemap:", error);
// Fail closed: return minimal sitemap
const xml = generateSitemapXml([]);
return new NextResponse(xml, {
status: 500,
headers: { "Content-Type": "application/xml" },
});
}