- Implemented a new middleware to handle raw body parsing for specific content types, allowing for larger payloads up to 5mb. - Enhanced form body serialization to support Buffer input, improving compatibility with various data formats during proxy interactions.
435 lines
18 KiB
JavaScript
435 lines
18 KiB
JavaScript
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, 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 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);
|
||
|
||
// 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/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/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 canonicalUrl = `${protocol}://${canonicalHost}${req.path === '/' ? '' : req.path}`;
|
||
|
||
// Ersetze den kanonischen Tag
|
||
const updatedData = data.replace(
|
||
/<link rel="canonical" href="[^"]*" \/>/,
|
||
`<link rel="canonical" href="${canonicalUrl}" />`
|
||
);
|
||
|
||
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 {
|
||
console.log('📜 Lade SSL-Zertifikate...');
|
||
const httpsOptions = {
|
||
key: fs.readFileSync(sslKeyPath),
|
||
cert: fs.readFileSync(sslCertPath)
|
||
};
|
||
console.log('✅ SSL-Zertifikate erfolgreich geladen');
|
||
|
||
// 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;
|
||
|
||
// Prüfe, ob Port bereits belegt ist
|
||
httpsServer.on('error', (err) => {
|
||
if (err.code === 'EADDRINUSE') {
|
||
console.error(`❌ Port ${httpsPort} ist bereits belegt!`);
|
||
console.error(' → Möglicherweise läuft bereits ein anderer Server auf diesem Port');
|
||
console.error(' → Prüfe mit: lsof -i :3051 oder netstat -tlnp | grep 3051');
|
||
socketIOInitialized = false;
|
||
} else {
|
||
console.error('❌ HTTPS-Server Error:', err.message);
|
||
console.error(' Code:', err.code);
|
||
socketIOInitialized = false;
|
||
}
|
||
});
|
||
|
||
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');
|
||
}
|
||
});
|
||
|
||
// Starte HTTPS-Server
|
||
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/`);
|
||
console.log(` Prüfe mit: lsof -i :${httpsPort} oder netstat -tlnp | grep ${httpsPort}`);
|
||
});
|
||
|
||
// Prüfe nach kurzer Verzögerung, ob Server wirklich läuft
|
||
setTimeout(() => {
|
||
if (socketIOInitialized) {
|
||
exec(`lsof -i :${httpsPort} || netstat -tlnp 2>/dev/null | grep :${httpsPort} || echo "Port nicht gefunden"`, (error, stdout) => {
|
||
if (stdout && !stdout.includes('Port nicht gefunden')) {
|
||
console.log(`✅ Port ${httpsPort} ist aktiv und erreichbar`);
|
||
} else {
|
||
console.warn(`⚠️ Port ${httpsPort} scheint nicht aktiv zu sein - prüfe Server-Logs`);
|
||
}
|
||
});
|
||
}
|
||
}, 2000);
|
||
} 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)');
|
||
socketIOInitialized = false;
|
||
}
|
||
} else {
|
||
console.log('ℹ️ SSL-Zertifikate nicht gefunden - Socket.IO läuft auf HTTP-Server (nur für Entwicklung)');
|
||
console.log(` Erwartete Pfade: ${sslKeyPath}, ${sslCertPath}`);
|
||
console.log(` Prüfe mit: ls -la ${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);
|
||
}
|
||
})();
|