import express from 'express'; import path from 'path'; import { fileURLToPath } from 'url'; import { createServer } from 'http'; import https from 'https'; import fs from 'fs'; import { exec } from 'child_process'; import sequelize from './database.js'; import cors from 'cors'; import { initializeSocketIO } from './services/socketService.js'; import { User, Log, Club, UserClub, Member, DiaryDate, Participant, Activity, MemberNote, DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag, PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, TeamDocument, Group, GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult, TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory } from './models/index.js'; import authRoutes from './routes/authRoutes.js'; import clubRoutes from './routes/clubRoutes.js'; import diaryRoutes from './routes/diaryRoutes.js'; import memberRoutes from './routes/memberRoutes.js'; import participantRoutes from './routes/participantRoutes.js'; import activityRoutes from './routes/activityRoutes.js'; import memberNoteRoutes from './routes/memberNoteRoutes.js'; import diaryTagRoutes from './routes/diaryTagRoutes.js'; import diaryNoteRoutes from './routes/diaryNoteRoutes.js'; import diaryMemberRoutes from './routes/diaryMemberRoutes.js'; import predefinedActivityRoutes from './routes/predefinedActivityRoutes.js'; import diaryDateActivityRoutes from './routes/diaryDateActivityRoutes.js'; import diaryMemberActivityRoutes from './routes/diaryMemberActivityRoutes.js'; import matchRoutes from './routes/matchRoutes.js'; import Season from './models/Season.js'; import Location from './models/Location.js'; import groupRoutes from './routes/groupRoutes.js'; import diaryDateTagRoutes from './routes/diaryDateTagRoutes.js'; import sessionRoutes from './routes/sessionRoutes.js'; import schedulerRoutes from './routes/schedulerRoutes.js'; import tournamentRoutes from './routes/tournamentRoutes.js'; import accidentRoutes from './routes/accidentRoutes.js'; import trainingStatsRoutes from './routes/trainingStatsRoutes.js'; import officialTournamentRoutes from './routes/officialTournamentRoutes.js'; import myTischtennisRoutes from './routes/myTischtennisRoutes.js'; import clickTtAccountRoutes from './routes/clickTtAccountRoutes.js'; import teamRoutes from './routes/teamRoutes.js'; import clubTeamRoutes from './routes/clubTeamRoutes.js'; import teamDocumentRoutes from './routes/teamDocumentRoutes.js'; import seasonRoutes from './routes/seasonRoutes.js'; import nuscoreSimpleProxyRoutes from './routes/nuscoreSimpleProxyRoutes.js'; import nuscoreApiRoutes from './routes/nuscoreApiRoutes.js'; import memberActivityRoutes from './routes/memberActivityRoutes.js'; import permissionRoutes from './routes/permissionRoutes.js'; import apiLogRoutes from './routes/apiLogRoutes.js'; import clickTtHttpPageRoutes from './routes/clickTtHttpPageRoutes.js'; import memberTransferConfigRoutes from './routes/memberTransferConfigRoutes.js'; import trainingGroupRoutes from './routes/trainingGroupRoutes.js'; import trainingTimeRoutes from './routes/trainingTimeRoutes.js'; import schedulerService from './services/schedulerService.js'; import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js'; import HttpError from './exceptions/HttpError.js'; const app = express(); const port = process.env.PORT || 3005; function captureRawBody(req, res, buf, encoding) { if (!buf || buf.length === 0) return; req.rawBody = buf.toString(encoding || 'utf8'); } const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const SEO_DEFAULTS = { title: 'Trainingstagebuch – Vereinsverwaltung für Tischtennis, Trainingsplanung & Turniere', description: 'Trainingstagebuch ist die Software für Tischtennisvereine: Mitgliederverwaltung, Trainingsplanung, Trainingstagebuch, Gruppen, Turniere, Team-Management, Statistiken und MyTischtennis-Integration.', robots: 'index,follow', }; const SEO_ROUTE_CONFIG = { '/': { title: SEO_DEFAULTS.title, description: SEO_DEFAULTS.description, robots: 'index,follow', }, '/login': { title: 'Login | Trainingstagebuch', description: 'Im Trainingstagebuch einloggen und Vereinsdaten, Trainingsplanung, Mitglieder und Turniere verwalten.', robots: 'noindex,follow', }, '/register': { title: 'Registrieren | Trainingstagebuch', description: 'Kostenlos im Trainingstagebuch registrieren und die Vereinsverwaltung für Tischtennisvereine kennenlernen.', robots: 'noindex,follow', }, '/activate': { title: 'Konto aktivieren | Trainingstagebuch', description: 'Aktivierung des Benutzerkontos im Trainingstagebuch.', robots: 'noindex,follow', }, '/forgot-password': { title: 'Passwort vergessen | Trainingstagebuch', description: 'Zugang zum Trainingstagebuch wiederherstellen.', robots: 'noindex,follow', }, '/reset-password': { title: 'Passwort zurücksetzen | Trainingstagebuch', description: 'Passwort im Trainingstagebuch sicher zurücksetzen.', robots: 'noindex,follow', }, '/impressum': { title: 'Impressum | Trainingstagebuch', description: 'Impressum von Trainingstagebuch.', robots: 'noindex,follow', }, '/datenschutz': { title: 'Datenschutzerklärung | Trainingstagebuch', description: 'Datenschutzerklärung von Trainingstagebuch.', robots: 'noindex,follow', }, }; function normalizeSeoPath(pathname = '/') { if (!pathname || pathname === '') return '/'; if (pathname === '/') return '/'; return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; } function getSeoConfigForPath(pathname = '/') { const normalizedPath = normalizeSeoPath(pathname); const matchedPrefix = Object.keys(SEO_ROUTE_CONFIG) .filter((routePath) => routePath !== '/' && normalizedPath.startsWith(routePath)) .sort((a, b) => b.length - a.length)[0]; return (matchedPrefix && SEO_ROUTE_CONFIG[matchedPrefix]) || SEO_ROUTE_CONFIG[normalizedPath] || { ...SEO_DEFAULTS, robots: 'noindex,follow' }; } function escapeHtmlAttribute(value = '') { return String(value) .replace(/&/g, '&') .replace(/"/g, '"') .replace(//g, '>'); } // CORS-Konfiguration - Socket.IO hat seine eigene CORS-Konfiguration app.use(cors({ origin: true, credentials: true, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'authcode', 'userid'] })); app.use('/api/clicktt/proxy', express.raw({ type: ['multipart/form-data', 'application/octet-stream'], limit: '5mb', verify: captureRawBody, })); app.use(express.json({ verify: captureRawBody })); app.use(express.urlencoded({ extended: true, verify: captureRawBody })); // Request Logging Middleware - loggt alle API-Requests // Wichtig: userId wird später in authMiddleware gesetzt, aber Middleware funktioniert auch ohne app.use(requestLoggingMiddleware); // Globale Fehlerbehandlung, damit der Server bei unerwarteten Fehlern nicht hart abstürzt process.on('uncaughtException', (err) => { console.error('[uncaughtException]', err); }); process.on('unhandledRejection', (reason, promise) => { console.error('[unhandledRejection]', reason); }); app.use('/api/auth', authRoutes); app.use('/api/clubs', clubRoutes); app.use('/api/clubmembers', memberRoutes); app.use('/api/diary', diaryRoutes); app.use('/api/participants', participantRoutes); app.use('/api/activities', activityRoutes); app.use('/api/membernotes', memberNoteRoutes); app.use('/api/diarynotes', diaryNoteRoutes); app.use('/api/tags', diaryTagRoutes); app.use('/api/diarymember', diaryMemberRoutes); app.use('/api/predefined-activities', predefinedActivityRoutes); app.use('/api/diary-date-activities', diaryDateActivityRoutes); app.use('/api/diary-member-activities', diaryMemberActivityRoutes); app.use('/api/matches', matchRoutes); app.use('/api/group', groupRoutes); app.use('/api/diarydatetags', diaryDateTagRoutes); app.use('/api/session', sessionRoutes); app.use('/api/scheduler', schedulerRoutes); app.use('/api/tournament', tournamentRoutes); app.use('/api/accident', accidentRoutes); app.use('/api/training-stats', trainingStatsRoutes); app.use('/api/official-tournaments', officialTournamentRoutes); app.use('/api/mytischtennis', myTischtennisRoutes); app.use('/api/clicktt-account', clickTtAccountRoutes); app.use('/api/teams', teamRoutes); app.use('/api/club-teams', clubTeamRoutes); app.use('/api/team-documents', teamDocumentRoutes); app.use('/api/seasons', seasonRoutes); app.use('/api/proxy/nuscore', nuscoreSimpleProxyRoutes); app.use('/api/nuscore', nuscoreApiRoutes); app.use('/api/member-activities', memberActivityRoutes); app.use('/api/permissions', permissionRoutes); app.use('/api/logs', apiLogRoutes); app.use('/api/clicktt', clickTtHttpPageRoutes); app.use('/api/member-transfer-config', memberTransferConfigRoutes); app.use('/api/training-groups', trainingGroupRoutes); app.use('/api/training-times', trainingTimeRoutes); // Middleware für dynamischen kanonischen Tag (vor express.static) const setCanonicalTag = (req, res, next) => { // Socket.IO-Requests komplett ignorieren if (req.path.startsWith('/socket.io/')) { return next(); } // Nur für HTML-Anfragen (nicht für API, Assets, etc.) if (req.path.startsWith('/api') || req.path.match(/\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|mp3|webmanifest|xml|txt)$/)) { return next(); } // Prüfe, ob die Datei als statische Datei existiert (außer index.html) const staticPath = path.join(__dirname, '../frontend/dist', req.path); fs.access(staticPath, fs.constants.F_OK, (err) => { if (!err && req.path !== '/' && req.path !== '/index.html') { // Datei existiert und ist nicht index.html, lasse express.static sie servieren return next(); } // Datei existiert nicht oder ist index.html, serviere index.html mit dynamischem kanonischen Tag const indexPath = path.join(__dirname, '../frontend/dist/index.html'); fs.readFile(indexPath, 'utf8', (err, data) => { if (err) { return next(); } // Bestimme die kanonische URL (bevorzuge non-www) const protocol = req.protocol || 'https'; const host = req.get('host') || 'tt-tagebuch.de'; const canonicalHost = host.replace(/^www\./, ''); // Entferne www falls vorhanden const normalizedPath = normalizeSeoPath(req.path); const canonicalUrl = `${protocol}://${canonicalHost}${normalizedPath === '/' ? '' : normalizedPath}`; const seo = getSeoConfigForPath(normalizedPath); let updatedData = data.replace( /