diff --git a/.gitea/workflows/ci-cd-with-gitea-vars.yml.disabled b/.gitea/workflows/ci-cd-with-gitea-vars.yml.disabled index ddb42ba..6d9608a 100644 --- a/.gitea/workflows/ci-cd-with-gitea-vars.yml.disabled +++ b/.gitea/workflows/ci-cd-with-gitea-vars.yml.disabled @@ -5,7 +5,7 @@ on: branches: [ dev, main, production ] env: - NODE_VERSION: '20' + NODE_VERSION: '25' DOCKER_IMAGE: portfolio-app CONTAINER_NAME: portfolio-app diff --git a/.gitea/workflows/dev-deploy.yml b/.gitea/workflows/dev-deploy.yml index c7d01e1..068053d 100644 --- a/.gitea/workflows/dev-deploy.yml +++ b/.gitea/workflows/dev-deploy.yml @@ -5,7 +5,7 @@ on: branches: [ dev ] env: - NODE_VERSION: '20' + NODE_VERSION: '25' DOCKER_IMAGE: portfolio-app IMAGE_TAG: dev diff --git a/.gitea/workflows/production-deploy.yml b/.gitea/workflows/production-deploy.yml index 822943a..8889d1d 100644 --- a/.gitea/workflows/production-deploy.yml +++ b/.gitea/workflows/production-deploy.yml @@ -5,7 +5,7 @@ on: branches: [ production ] env: - NODE_VERSION: '20' + NODE_VERSION: '25' DOCKER_IMAGE: portfolio-app IMAGE_TAG: production @@ -70,11 +70,13 @@ jobs: export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}" export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}" export ADMIN_SESSION_SECRET="${{ secrets.ADMIN_SESSION_SECRET }}" + export DIRECTUS_URL="${{ vars.DIRECTUS_URL || 'https://cms.dk0.dev' }}" + export DIRECTUS_STATIC_TOKEN="${{ secrets.DIRECTUS_STATIC_TOKEN || '' }}" # Start new container with updated image (docker-compose will handle this) echo "🆕 Starting new production container..." echo "📝 Environment check: N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-(not set)}" - docker compose -f $COMPOSE_FILE up -d --no-deps --build portfolio + docker compose -f $COMPOSE_FILE up -d portfolio # Wait for new container to be healthy echo "⏳ Waiting for new container to be healthy..." diff --git a/Dockerfile b/Dockerfile index 2487baa..d0e4cc7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Multi-stage build for optimized production image -FROM node:20 AS base +FROM node:25 AS base # Install dependencies only when needed FROM base AS deps @@ -67,10 +67,6 @@ RUN adduser --system --uid 1001 nextjs # Copy the built application COPY --from=builder /app/public ./public -# Set the correct permission for prerender cache -RUN mkdir .next -RUN chown nextjs:nodejs .next - # Automatically leverage output traces to reduce image size # https://nextjs.org/docs/advanced-features/output-file-tracing # Copy standalone output (contains server.js and all dependencies) @@ -79,6 +75,10 @@ RUN chown nextjs:nodejs .next COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +# Create cache directories with correct permissions AFTER copying standalone +RUN mkdir -p .next/cache/fetch-cache .next/cache/images && \ + chown -R nextjs:nodejs .next/cache + # Copy Prisma files COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index ec21198..c11214f 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -1,10 +1,19 @@ import { NextIntlClientProvider } from "next-intl"; import { setRequestLocale } from "next-intl/server"; import React from "react"; +import { notFound } from "next/navigation"; import ConsentBanner from "../components/ConsentBanner"; import { getLocalizedMessage } from "@/lib/i18n-loader"; -async function loadEnhancedMessages(locale: string) { +// Supported locales - must match middleware.ts +const SUPPORTED_LOCALES = ["en", "de"] as const; +type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]; + +function isValidLocale(locale: string): locale is SupportedLocale { + return SUPPORTED_LOCALES.includes(locale as SupportedLocale); +} + +async function loadEnhancedMessages(locale: SupportedLocale) { // Lade basis JSON Messages const baseMessages = (await import(`../../messages/${locale}.json`)).default; @@ -13,6 +22,11 @@ async function loadEnhancedMessages(locale: string) { return baseMessages; } +// Define valid static params to prevent malicious path traversal +export function generateStaticParams() { + return SUPPORTED_LOCALES.map((locale) => ({ locale })); +} + export default async function LocaleLayout({ children, params, @@ -21,6 +35,12 @@ export default async function LocaleLayout({ params: Promise<{ locale: string }>; }) { const { locale } = await params; + + // Security: Validate locale to prevent malicious imports + if (!isValidLocale(locale)) { + notFound(); + } + // Ensure next-intl actually uses the route segment locale for this request. setRequestLocale(locale); // Load messages explicitly by route locale to avoid falling back to the wrong diff --git a/app/api/projects/search/route.ts b/app/api/projects/search/route.ts index f4fdc27..1689546 100644 --- a/app/api/projects/search/route.ts +++ b/app/api/projects/search/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { prisma } from '@/lib/prisma'; +import { getProjects } from '@/lib/directus'; export async function GET(request: NextRequest) { try { @@ -7,56 +7,27 @@ export async function GET(request: NextRequest) { const slug = searchParams.get('slug'); const search = searchParams.get('search'); const category = searchParams.get('category'); + const locale = searchParams.get('locale') || 'en'; + // Use Directus instead of Prisma + const projects = await getProjects(locale, { + featured: undefined, + published: true, + category: category && category !== 'All' ? category : undefined, + search: search || undefined, + }); + + if (!projects) { + // Directus not available or no projects found + return NextResponse.json({ projects: [] }); + } + + // Filter by slug if provided (since Directus query doesn't support slug filter directly) if (slug) { - const project = await prisma.project.findFirst({ - where: { - published: true, - slug, - }, - orderBy: { createdAt: 'desc' }, - }); - + const project = projects.find(p => p.slug === slug); return NextResponse.json({ projects: project ? [project] : [] }); } - if (search) { - // General search - const projects = await prisma.project.findMany({ - where: { - published: true, - OR: [ - { title: { contains: search, mode: 'insensitive' } }, - { description: { contains: search, mode: 'insensitive' } }, - { tags: { hasSome: [search] } }, - { content: { contains: search, mode: 'insensitive' } } - ] - }, - orderBy: { createdAt: 'desc' } - }); - - return NextResponse.json({ projects }); - } - - if (category && category !== 'All') { - // Filter by category - const projects = await prisma.project.findMany({ - where: { - published: true, - category: category - }, - orderBy: { createdAt: 'desc' } - }); - - return NextResponse.json({ projects }); - } - - // Return all published projects if no specific search - const projects = await prisma.project.findMany({ - where: { published: true }, - orderBy: { createdAt: 'desc' } - }); - return NextResponse.json({ projects }); } catch (error) { console.error('Error searching projects:', error); diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 6ab4a32..8cf01e0 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -12,6 +12,8 @@ services: - NODE_ENV=production - DATABASE_URL=postgresql://portfolio_user:portfolio_pass@postgres:5432/portfolio_db?schema=public - REDIS_URL=redis://redis:6379 + - DIRECTUS_URL=${DIRECTUS_URL:-https://cms.dk0.dev} + - DIRECTUS_STATIC_TOKEN=${DIRECTUS_STATIC_TOKEN:-} - NEXT_PUBLIC_BASE_URL=https://dk0.dev - MY_EMAIL=${MY_EMAIL:-contact@dk0.dev} - MY_INFO_EMAIL=${MY_INFO_EMAIL:-info@dk0.dev} @@ -60,7 +62,6 @@ services: - POSTGRES_PASSWORD=portfolio_pass volumes: - postgres_data:/var/lib/postgresql/data - - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro networks: - portfolio_net healthcheck: diff --git a/messages/de.json b/messages/de.json index e6161c2..838aa4e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -58,7 +58,7 @@ "selfHosting": "Self-Hosting & DevOps", "gaming": "Gaming", "gameServers": "Game-Server einrichten", - "jogging": "Joggen la Kopf freibekommen und aktiv bleiben" + "jogging": "Joggen um den Kopf freizubekommen und aktiv bleiben" }, "currentlyReading": { "title": "Aktuell am Lesen", diff --git a/middleware.ts b/middleware.ts index e7aed7d..7fe0a11 100644 --- a/middleware.ts +++ b/middleware.ts @@ -4,6 +4,29 @@ import type { NextRequest } from "next/server"; const SUPPORTED_LOCALES = ["en", "de"] as const; type SupportedLocale = (typeof SUPPORTED_LOCALES)[number]; +// Security: Block common malicious file patterns +const BLOCKED_PATTERNS = [ + /\.php$/i, + /\.asp$/i, + /\.aspx$/i, + /\.jsp$/i, + /\.cgi$/i, + /\.env$/i, + /\.sql$/i, + /\.gz$/i, + /\.tar$/i, + /\.zip$/i, + /\.rar$/i, + /\.bash_history$/i, + /ftpsync\.settings$/i, + /__MACOSX/i, + /\.well-known\.zip$/i, +]; + +function isBlockedPath(pathname: string): boolean { + return BLOCKED_PATTERNS.some((pattern) => pattern.test(pathname)); +} + function pickLocaleFromHeader(acceptLanguage: string | null): SupportedLocale { if (!acceptLanguage) return "en"; const lower = acceptLanguage.toLowerCase(); @@ -20,6 +43,11 @@ function hasLocalePrefix(pathname: string): boolean { export function middleware(request: NextRequest) { const { pathname, search } = request.nextUrl; + // Security: Block malicious/suspicious requests immediately + if (isBlockedPath(pathname)) { + return new NextResponse(null, { status: 404 }); + } + // If a locale-prefixed request hits a public asset path (e.g. /de/images/me.jpg), // redirect to the non-prefixed asset path. if (hasLocalePrefix(pathname)) { diff --git a/nginx.production.conf b/nginx.production.conf index 0dbd616..a8df9a5 100644 --- a/nginx.production.conf +++ b/nginx.production.conf @@ -82,6 +82,27 @@ http { # Avoid `unsafe-eval` in production CSP add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://analytics.dk0.dev; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://analytics.dk0.dev;"; + # Block common malicious file extensions and paths + location ~* \.(php|asp|aspx|jsp|cgi|sh|bat|cmd|exe|dll)$ { + return 404; + } + + # Block access to sensitive files + location ~* (\.env|\.sql|\.tar|\.gz|\.zip|\.rar|\.bash_history|ftpsync\.settings|__MACOSX) { + return 404; + } + + # Block access to .well-known if not explicitly needed + location ~ /\.well-known(?!\/acme-challenge) { + return 404; + } + + # Block access to hidden files and directories + location ~ /\. { + deny all; + return 404; + } + # Cache static assets location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 1y; diff --git a/scripts/add-example-projects.js b/scripts/add-example-projects.js new file mode 100644 index 0000000..ec51029 --- /dev/null +++ b/scripts/add-example-projects.js @@ -0,0 +1,178 @@ +#!/usr/bin/env node +/** + * Add Example Projects to Directus + * + * Creates 3 example projects in Directus CMS + * + * Usage: + * node scripts/add-example-projects.js + */ + +require('dotenv').config(); + +const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev'; +const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN; + +if (!DIRECTUS_TOKEN) { + console.error('❌ Error: DIRECTUS_STATIC_TOKEN not found in .env'); + process.exit(1); +} + +async function directusRequest(endpoint, method = 'GET', body = null) { + const url = `${DIRECTUS_URL}/${endpoint}`; + const options = { + method, + headers: { + 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, + 'Content-Type': 'application/json' + } + }; + + if (body) { + options.body = JSON.stringify(body); + } + + try { + const response = await fetch(url, options); + if (!response.ok) { + const text = await response.text(); + console.error(`HTTP ${response.status}: ${text}`); + return null; + } + return await response.json(); + } catch (error) { + console.error(`Error calling ${method} ${endpoint}:`, error.message); + return null; + } +} + +const exampleProjects = [ + { + slug: 'portfolio-website', + status: 'published', + featured: true, + category: 'Web Application', + difficulty: 'Advanced', + date: '2024', + github: 'https://github.com/denshooter/portfolio', + live: 'https://dk0.dev', + image_url: 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=800', + tags: ['Next.js', 'React', 'TypeScript', 'Tailwind CSS'], + technologies: ['Next.js 14', 'TypeScript', 'Tailwind CSS', 'Directus', 'Docker'], + challenges: 'Building a performant, SEO-optimized portfolio with multilingual support', + lessons_learned: 'Learned about Next.js App Router, Server Components, and modern deployment strategies', + future_improvements: 'Add blog section, improve animations, add dark mode', + translations: [ + { + languages_code: 'en-US', + title: 'Portfolio Website', + description: 'A modern, performant portfolio website built with Next.js and Directus CMS', + content: '# Portfolio Website\n\nThis is my personal portfolio built with modern web technologies.\n\n## Features\n\n- 🚀 Fast and performant\n- 🌍 Multilingual (EN/DE)\n- 📱 Fully responsive\n- ♿ Accessible\n- 🎨 Beautiful design\n\n## Tech Stack\n\n- Next.js 14 with App Router\n- TypeScript\n- Tailwind CSS\n- Directus CMS\n- Docker deployment', + meta_description: 'Modern portfolio website showcasing my projects and skills', + keywords: 'portfolio, nextjs, react, typescript, web development' + }, + { + languages_code: 'de-DE', + title: 'Portfolio Website', + description: 'Eine moderne, performante Portfolio-Website mit Next.js und Directus CMS', + content: '# Portfolio Website\n\nDies ist mein persönliches Portfolio mit modernen Web-Technologien.\n\n## Features\n\n- 🚀 Schnell und performant\n- 🌍 Mehrsprachig (EN/DE)\n- 📱 Voll responsiv\n- ♿ Barrierefrei\n- 🎨 Schönes Design\n\n## Tech Stack\n\n- Next.js 14 mit App Router\n- TypeScript\n- Tailwind CSS\n- Directus CMS\n- Docker Deployment', + meta_description: 'Moderne Portfolio-Website mit meinen Projekten und Fähigkeiten', + keywords: 'portfolio, nextjs, react, typescript, webentwicklung' + } + ] + }, + { + slug: 'task-manager-app', + status: 'published', + featured: true, + category: 'Web Application', + difficulty: 'Intermediate', + date: '2023', + github: 'https://github.com/example/task-manager', + image_url: 'https://images.unsplash.com/photo-1484480974693-6ca0a78fb36b?w=800', + tags: ['React', 'Node.js', 'MongoDB', 'REST API'], + technologies: ['React', 'Node.js', 'Express', 'MongoDB', 'JWT'], + challenges: 'Implementing real-time updates and secure authentication', + lessons_learned: 'Learned about WebSockets, JWT authentication, and database optimization', + future_improvements: 'Add team collaboration features, mobile app, calendar integration', + translations: [ + { + languages_code: 'en-US', + title: 'Task Manager App', + description: 'A full-stack task management application with real-time updates', + content: '# Task Manager App\n\nA comprehensive task management solution for individuals and teams.\n\n## Features\n\n- ✅ Create and manage tasks\n- 🔔 Real-time notifications\n- 🔐 Secure authentication\n- 📊 Progress tracking\n- 🎨 Customizable categories\n\n## Implementation\n\nBuilt with React on the frontend and Node.js/Express on the backend. Uses MongoDB for data storage and JWT for authentication.', + meta_description: 'Full-stack task management application', + keywords: 'task manager, productivity, react, nodejs, mongodb' + }, + { + languages_code: 'de-DE', + title: 'Task Manager App', + description: 'Eine Full-Stack Aufgabenverwaltungs-App mit Echtzeit-Updates', + content: '# Task Manager App\n\nEine umfassende Aufgabenverwaltungslösung für Einzelpersonen und Teams.\n\n## Features\n\n- ✅ Aufgaben erstellen und verwalten\n- 🔔 Echtzeit-Benachrichtigungen\n- 🔐 Sichere Authentifizierung\n- 📊 Fortschrittsverfolgung\n- 🎨 Anpassbare Kategorien\n\n## Implementierung\n\nErstellt mit React im Frontend und Node.js/Express im Backend. Nutzt MongoDB für Datenspeicherung und JWT für Authentifizierung.', + meta_description: 'Full-Stack Aufgabenverwaltungs-Anwendung', + keywords: 'task manager, produktivität, react, nodejs, mongodb' + } + ] + }, + { + slug: 'weather-dashboard', + status: 'published', + featured: false, + category: 'Web Application', + difficulty: 'Beginner', + date: '2023', + live: 'https://weather-demo.example.com', + image_url: 'https://images.unsplash.com/photo-1504608524841-42fe6f032b4b?w=800', + tags: ['JavaScript', 'API', 'CSS', 'HTML'], + technologies: ['Vanilla JavaScript', 'OpenWeather API', 'CSS Grid', 'LocalStorage'], + challenges: 'Working with external APIs and handling async data', + lessons_learned: 'Learned about API integration, error handling, and responsive design', + future_improvements: 'Add weather forecasts, save favorite locations, add charts', + translations: [ + { + languages_code: 'en-US', + title: 'Weather Dashboard', + description: 'A simple weather dashboard showing current weather conditions', + content: '# Weather Dashboard\n\nA clean and simple weather dashboard for checking current conditions.\n\n## Features\n\n- 🌤️ Current weather data\n- 📍 Location search\n- 💾 Save favorite locations\n- 📱 Responsive design\n- 🎨 Clean UI\n\n## Technical Details\n\nBuilt with vanilla JavaScript and the OpenWeather API. Uses CSS Grid for layout and LocalStorage for saving user preferences.', + meta_description: 'Simple weather dashboard application', + keywords: 'weather, dashboard, javascript, api' + }, + { + languages_code: 'de-DE', + title: 'Wetter Dashboard', + description: 'Ein einfaches Wetter-Dashboard mit aktuellen Wetterbedingungen', + content: '# Wetter Dashboard\n\nEin übersichtliches Wetter-Dashboard zur Anzeige aktueller Bedingungen.\n\n## Features\n\n- 🌤️ Aktuelle Wetterdaten\n- 📍 Standortsuche\n- 💾 Favoriten speichern\n- 📱 Responsives Design\n- 🎨 Sauberes UI\n\n## Technische Details\n\nErstellt mit Vanilla JavaScript und der OpenWeather API. Nutzt CSS Grid für das Layout und LocalStorage zum Speichern von Benutzereinstellungen.', + meta_description: 'Einfache Wetter-Dashboard Anwendung', + keywords: 'wetter, dashboard, javascript, api' + } + ] + } +]; + +async function addProjects() { + console.log('\n📦 Adding Example Projects to Directus...\n'); + + for (const projectData of exampleProjects) { + console.log(`\n📁 Creating: ${projectData.translations[0].title}`); + + try { + const result = await directusRequest( + 'items/projects', + 'POST', + projectData + ); + + if (result) { + console.log(` ✅ Successfully created project: ${projectData.slug}`); + } else { + console.log(` ❌ Failed to create project: ${projectData.slug}`); + } + } catch (error) { + console.error(` ❌ Error creating project ${projectData.slug}:`, error.message); + } + } + + console.log('\n✅ Done!\n'); +} + +addProjects().catch(console.error);