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 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, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact } 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 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 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 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; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // 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(express.json()); // 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/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/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/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 canonicalUrl = `${protocol}://${canonicalHost}${req.path === '/' ? '' : req.path}`; // Ersetze den kanonischen Tag const updatedData = data.replace( //, `` ); res.setHeader('Content-Type', 'text/html'); res.send(updatedData); }); }); }; app.use(setCanonicalTag); app.use(express.static(path.join(__dirname, '../frontend/dist'))); // Globale Fehlerbehandlung für API-Routen (MUSS nach allen Routes sein!) app.use((err, req, res, next) => { if (res.headersSent) { return next(err); } const status = err?.statusCode || err?.status || 500; // Unterstützung für Fehlercodes let errorResponse; if (err instanceof HttpError && err.errorCode) { // Neues Format mit Fehlercode errorResponse = err.toJSON(); } else { // Legacy-Format: String-Nachricht const message = err?.message || 'Interner Serverfehler'; errorResponse = { message }; } const response = { success: false, ...errorResponse, // Für Rückwärtskompatibilität: error-Feld mit Nachricht error: errorResponse.message || errorResponse.code || 'Interner Serverfehler' }; if (process.env.NODE_ENV === 'dev' || process.env.NODE_ENV === 'development') { response.debug = { stack: err?.stack || null }; } console.error('[ExpressError]', err); res.status(status).json(response); }); (async () => { try { await sequelize.authenticate(); // Einmalige Migration: deutsche Spaltennamen -> englische const renameColumnIfExists = async (table, from, to, typeSql) => { try { const [rows] = await sequelize.query(`SHOW COLUMNS FROM \`${table}\` LIKE :col`, { replacements: { col: from } }); if (Array.isArray(rows) && rows.length > 0) { await sequelize.query(`ALTER TABLE \`${table}\` CHANGE \`${from}\` \`${to}\` ${typeSql}`); } } catch (e) { console.error(`[migration] Failed to rename ${table}.${from} -> ${to}:`, e.message); } }; // official_competitions await renameColumnIfExists('official_competitions', 'altersklasse_wettbewerb', 'age_class_competition', 'VARCHAR(255) NULL'); await renameColumnIfExists('official_competitions', 'leistungsklasse', 'performance_class', 'VARCHAR(255) NULL'); await renameColumnIfExists('official_competitions', 'startzeit', 'start_time', 'VARCHAR(255) NULL'); await renameColumnIfExists('official_competitions', 'meldeschluss_datum', 'registration_deadline_date', 'VARCHAR(255) NULL'); await renameColumnIfExists('official_competitions', 'meldeschluss_online', 'registration_deadline_online', 'VARCHAR(255) NULL'); await renameColumnIfExists('official_competitions', 'stichtag', 'cutoff_date', 'VARCHAR(255) NULL'); await renameColumnIfExists('official_competitions', 'offen_fuer', 'open_to', 'VARCHAR(255) NULL'); await renameColumnIfExists('official_competitions', 'vorrunde', 'preliminary_round', 'VARCHAR(255) NULL'); await renameColumnIfExists('official_competitions', 'endrunde', 'final_round', 'VARCHAR(255) NULL'); await renameColumnIfExists('official_competitions', 'max_teilnehmer', 'max_participants', 'VARCHAR(255) NULL'); await renameColumnIfExists('official_competitions', 'startgeld', 'entry_fee', 'VARCHAR(255) NULL'); // official_tournaments await renameColumnIfExists('official_tournaments', 'termin', 'event_date', 'VARCHAR(255) NULL'); await renameColumnIfExists('official_tournaments', 'veranstalter', 'organizer', 'VARCHAR(255) NULL'); await renameColumnIfExists('official_tournaments', 'ausrichter', 'host', 'VARCHAR(255) NULL'); await renameColumnIfExists('official_tournaments', 'austragungsorte', 'venues', 'TEXT NULL'); await renameColumnIfExists('official_tournaments', 'konkurrenztypen', 'competition_types', 'TEXT NULL'); await renameColumnIfExists('official_tournaments', 'meldeschluesse', 'registration_deadlines', 'TEXT NULL'); const isDev = process.env.STAGE === 'dev'; // Foreign Keys temporär deaktivieren für MySQL try { await sequelize.query('SET FOREIGN_KEY_CHECKS = 0'); } catch (e) { console.warn('[sync] Could not disable foreign key checks:', e?.message); } const safeSync = async (model) => { try { if (isDev) { await model.sync({ alter: true }); } else { await model.sync(); } } catch (e) { try { console.error(`[sync] ${model?.name || 'model'} alter failed:`, e?.message || e); await model.sync(); } catch (e2) { console.error(`[sync] fallback failed for ${model?.name || 'model'}:`, e2?.message || e2); } } }; await safeSync(User); await safeSync(Club); await safeSync(UserClub); await safeSync(Log); await safeSync(Member); await safeSync(DiaryDate); await safeSync(Participant); await safeSync(Activity); await safeSync(MemberNote); await safeSync(DiaryNote); await safeSync(DiaryTag); await safeSync(MemberDiaryTag); await safeSync(DiaryDateTag); await safeSync(DiaryMemberTag); await safeSync(DiaryMemberNote); await safeSync(PredefinedActivity); await safeSync(PredefinedActivityImage); await safeSync(DiaryDateActivity); await safeSync(DiaryMemberActivity); await safeSync(OfficialTournament); await safeSync(OfficialCompetition); await safeSync(OfficialCompetitionMember); await safeSync(Season); await safeSync(League); await safeSync(Team); await safeSync(Location); await safeSync(Match); await safeSync(Group); await safeSync(GroupActivity); await safeSync(Tournament); await safeSync(TournamentGroup); await safeSync(TournamentMember); await safeSync(TournamentMatch); await safeSync(TournamentResult); await safeSync(Accident); await safeSync(UserToken); await safeSync(MyTischtennis); await safeSync(MyTischtennisUpdateHistory); await safeSync(MyTischtennisFetchLog); await safeSync(ApiLog); await safeSync(MemberTransferConfig); await safeSync(MemberContact); await safeSync(ClubTeam); await safeSync(TeamDocument); // Foreign Keys wieder aktivieren try { await sequelize.query('SET FOREIGN_KEY_CHECKS = 1'); } catch (e) { console.warn('[sync] Could not enable foreign key checks:', e?.message); } // Start scheduler service schedulerService.start(); // Erstelle HTTP-Server für API const httpServer = createServer(app); // WICHTIG: Socket.IO muss VOR dem Server-Start initialisiert werden // damit es Upgrade-Requests abfangen kann let socketIOInitialized = false; // Erstelle HTTPS-Server für Socket.IO (direkt mit SSL) const httpsPort = process.env.HTTPS_PORT || 3051; // Prüfe, ob SSL-Zertifikate vorhanden sind const sslKeyPath = '/etc/letsencrypt/live/tt-tagebuch.de/privkey.pem'; const sslCertPath = '/etc/letsencrypt/live/tt-tagebuch.de/fullchain.pem'; if (fs.existsSync(sslKeyPath) && fs.existsSync(sslCertPath)) { try { const httpsOptions = { key: fs.readFileSync(sslKeyPath), cert: fs.readFileSync(sslCertPath) }; // Erstelle HTTPS-Server mit Express-App const httpsServer = https.createServer(httpsOptions, app); // Initialisiere Socket.IO auf HTTPS-Server VOR dem Listen initializeSocketIO(httpsServer); socketIOInitialized = true; httpsServer.listen(httpsPort, '0.0.0.0', () => { console.log(`🚀 HTTPS-Server für Socket.IO läuft auf Port ${httpsPort}`); console.log(` Socket.IO Endpoint: https://tt-tagebuch.de:${httpsPort}/socket.io/`); }); // Error-Handling für HTTPS-Server httpsServer.on('error', (err) => { console.error('❌ HTTPS-Server Error:', err.message); console.error(' Code:', err.code); }); httpsServer.on('clientError', (err, socket) => { if (socket && !socket.destroyed) { console.error('❌ HTTPS-Server Client Error:', err.message); socket.end('HTTP/1.1 400 Bad Request\r\n\r\n'); } }); } catch (err) { console.error('⚠️ HTTPS-Server konnte nicht gestartet werden:', err.message); console.error(' Stack:', err.stack); console.log(' → Socket.IO läuft auf HTTP-Server (nur für Entwicklung)'); } } else { console.log('ℹ️ SSL-Zertifikate nicht gefunden - Socket.IO läuft auf HTTP-Server (nur für Entwicklung)'); console.log(` Erwartete Pfade: ${sslKeyPath}, ${sslCertPath}`); } // Fallback: Socket.IO auf HTTP-Server (wenn noch nicht initialisiert) // WICHTIG: VOR dem httpServer.listen() initialisieren if (!socketIOInitialized) { initializeSocketIO(httpServer); console.log(' ✅ Socket.IO erfolgreich auf HTTP-Server initialisiert'); } // HTTP-Server starten NACH Socket.IO-Initialisierung httpServer.listen(port, () => { console.log(`🚀 HTTP-Server läuft auf Port ${port}`); }); } catch (err) { console.error('Unable to synchronize the database:', err); } })();