Updating (#65)

* Fix ActivityFeed: Remove dynamic import that was causing it to disappear in production

* Fix ActivityFeed hydration error: Move localStorage read to useEffect to prevent server/client mismatch

* Update Node.js version to 25 in Gitea workflows

- Fix EBADENGINE error for camera-controls@3.1.2 which requires Node.js >=22
- Update production-deploy.yml, dev-deploy.yml, and ci-cd-with-gitea-vars.yml.disabled
- Node.js v25 matches local development environment

* Update Dockerfile to use Node.js 25

- Update base image from node:20 to node:25
- Matches Gitea workflow configuration and camera-controls@3.1.2 requirements

* Fix production deployment: Start database dependencies

- Remove --no-deps flag which prevented postgres and redis from starting
- Remove --build flag as image is already built in previous step
- This fixes 'Can't reach database server at postgres:5432' error

* Fix postgres health check in production

- Remove init-db.sql volume mount (not available in CI/CD environment)
- Init script not needed as Prisma handles schema migrations
- Postgres will initialize empty database automatically

* Fix cache permission error in Docker container

- Create cache directories AFTER copying standalone files
- Create both fetch-cache and images subdirectories
- Set proper ownership for nextjs user
- Fixes EACCES permission denied errors for prerender cache

* Fix German jogging fallback text

* Use Directus content in production

* fix: Security vulnerability - block malicious file requests

* fix: Switch projects to Directus, add security fixes and example projects
This commit is contained in:
denshooter
2026-02-15 22:04:26 +01:00
committed by GitHub
parent b7b7ac8207
commit 07741761cc
11 changed files with 279 additions and 58 deletions

View File

@@ -5,7 +5,7 @@ on:
branches: [ dev, main, production ]
env:
NODE_VERSION: '20'
NODE_VERSION: '25'
DOCKER_IMAGE: portfolio-app
CONTAINER_NAME: portfolio-app

View File

@@ -5,7 +5,7 @@ on:
branches: [ dev ]
env:
NODE_VERSION: '20'
NODE_VERSION: '25'
DOCKER_IMAGE: portfolio-app
IMAGE_TAG: dev

View File

@@ -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..."

View File

@@ -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

View File

@@ -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

View File

@@ -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';
if (slug) {
const project = await prisma.project.findFirst({
where: {
// Use Directus instead of Prisma
const projects = await getProjects(locale, {
featured: undefined,
published: true,
slug,
},
orderBy: { createdAt: 'desc' },
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 = 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);

View File

@@ -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:

View File

@@ -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",

View File

@@ -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)) {

View File

@@ -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;

View File

@@ -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);