Files
singlechat/server/index.js
Torsten Schulz (local) 5bb9db2aad seo-improvements
2026-06-15 16:07:42 +02:00

243 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import express from 'express';
import { createServer } from 'http';
import { Server as SocketIOServer } from 'socket.io';
import cookieParser from 'cookie-parser';
import session from 'express-session';
import cors from 'cors';
import { fileURLToPath, URL as NodeURL } from 'url';
import { dirname, join } from 'path';
import { setupBroadcast } from './broadcast.js';
import { setupRoutes } from './routes.js';
import { setupSEORoutes } from './routes-seo.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const app = express();
const server = createServer(app);
// Umgebungsvariablen
const NODE_ENV = process.env.NODE_ENV || 'development';
const PORT = process.env.PORT || (NODE_ENV === 'production' ? 4000 : 3300);
const IS_PRODUCTION = NODE_ENV === 'production';
const PRIMARY_HOST = 'www.single-chat.net';
const LEGACY_HOSTS = new Set(['single-chat.net', 'ypchat.net', 'www.ypchat.net']);
// CORS-Origins konfigurieren
const allowedOrigins = IS_PRODUCTION
? ['https://single-chat.net', 'https://www.single-chat.net', 'https://ypchat.net', 'https://www.ypchat.net']
: ['http://localhost:5175', 'http://127.0.0.1:5175'];
// Socket.IO auf dem gleichen HTTP-Server wie Express
const io = new SocketIOServer(server, {
cors: {
origin: allowedOrigins,
credentials: true,
methods: ['GET', 'POST']
},
transports: ['websocket', 'polling'],
allowEIO3: true,
maxHttpBufferSize: 10 * 1024 * 1024, // 10MB für große Bilder (Base64-kodiert)
pingTimeout: 60000,
pingInterval: 25000
});
console.log('Socket.IO Server initialisiert auf Express-Server');
console.log('Umgebung:', NODE_ENV);
console.log('CORS erlaubt für:', allowedOrigins);
// CORS-Konfiguration
app.use(cors({
origin: (origin, callback) => {
// Erlaube Requests ohne Origin (z.B. Postman, mobile Apps)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin) || !IS_PRODUCTION) {
callback(null, true);
} else {
callback(new Error('Nicht erlaubt durch CORS'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
// Session-Konfiguration
const sessionSecret = process.env.SESSION_SECRET || 'singlechat-secret-key-change-in-production';
app.use(session({
secret: sessionSecret,
resave: false,
saveUninitialized: false,
cookie: {
secure: IS_PRODUCTION, // true für HTTPS in Production
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000, // 24 Stunden
sameSite: IS_PRODUCTION ? 'lax' : false // Lax für HTTPS, false für Development
}
}));
// Trust Proxy für Apache Reverse Proxy (muss vor Routes stehen)
if (IS_PRODUCTION) {
app.set('trust proxy', 1); // Vertraue dem ersten Proxy (Apache)
// SEO-Fallback: erzwinge kanonischen Host + HTTPS, falls der Proxy es nicht bereits tut.
app.use((req, res, next) => {
const forwardedHost = String(req.headers['x-forwarded-host'] || '')
.split(',')[0]
.trim()
.toLowerCase();
const rawHost = String(req.headers.host || '')
.split(':')[0]
.trim()
.toLowerCase();
const host = forwardedHost || rawHost;
const forwardedProto = String(req.headers['x-forwarded-proto'] || '')
.split(',')[0]
.trim()
.toLowerCase();
const isHttps = forwardedProto ? forwardedProto === 'https' : req.secure;
const isKnownPublicHost = host === PRIMARY_HOST || LEGACY_HOSTS.has(host);
if (!isKnownPublicHost) {
next();
return;
}
if (!isHttps || host !== PRIMARY_HOST) {
res.redirect(301, `https://${PRIMARY_HOST}${req.originalUrl || '/'}`);
return;
}
next();
});
// Tracking-/Session-Query entfernen (wtd, js=no), damit Google keine Duplikat-URLs indexiert.
// Suchkonsole: "Alternative Seite mit richtigem kanonischen Tag" fuer /?wtd=... verschwindet nach Neu-Crawl.
app.use((req, res, next) => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
next();
return;
}
if (req.path.startsWith('/api')) {
next();
return;
}
const host = req.get('host') || 'localhost';
const proto = req.get('x-forwarded-proto') || req.protocol || 'https';
const raw = req.originalUrl.split('#')[0];
let u;
try {
u = new NodeURL(raw, `${proto}://${host}`);
} catch {
next();
return;
}
const hadWtd = u.searchParams.has('wtd');
const hadJsNo = u.searchParams.get('js') === 'no';
if (!hadWtd && !hadJsNo) {
next();
return;
}
if (hadWtd) u.searchParams.delete('wtd');
if (hadJsNo) u.searchParams.delete('js');
const search = u.searchParams.toString();
const dest = u.pathname + (search ? `?${search}` : '');
if (dest !== raw) {
res.redirect(301, dest);
return;
}
next();
});
}
// Statische Dateien aus docroot
app.use('/static', express.static(join(__dirname, '../docroot')));
// Service Worker unter Root ausliefern (wird oft für AdProvider Verifikation verlangt)
app.get('/sw.js', (req, res) => {
try {
res.setHeader('Content-Type', 'application/javascript');
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.sendFile(join(__dirname, '../sw.js'));
} catch (err) {
res.status(404).send('Not found');
}
});
// SEO-Routes (robots.txt, sitemap.xml, Pre-Rendering)
// Müssen vor anderen Routes stehen, damit sie nicht vom SPA-Fallback abgefangen werden
setupSEORoutes(app, __dirname);
// API Routes (müssen vor SPA-Fallback stehen)
setupRoutes(app, __dirname);
// Socket.IO-Handling
setupBroadcast(io, __dirname);
// In Production: Serviere auch die gebauten Client-Dateien
// SPA-Fallback muss nach allen anderen Routen stehen
if (IS_PRODUCTION) {
const distPath = join(__dirname, '../docroot/dist');
const KNOWN_SPA_ROUTES = new Set([
'/',
'/partners',
'/feedback',
'/faq',
'/regeln',
'/sicherheit',
'/datenschutz',
'/ratgeber',
'/ratgeber/erste-nachricht',
'/ratgeber/profil-tipps',
'/ratgeber/sicher-chatten',
'/ratgeber/red-flags',
'/mockup-redesign'
]);
app.use(express.static(distPath));
// Fallback für Vue Router (SPA) - muss am Ende stehen
app.get('*', (req, res) => {
// Hinweis: SEO-Routen (/, /partners, …) werden bereits von setupSEORoutes registriert
// und greifen vor diesem Catch-All. Kein frühes return ohne res.* hier.
// In Production: /src/ Pfade sollten nicht existieren (404)
if (IS_PRODUCTION && req.path.startsWith('/src/')) {
res.status(404).send('Not found');
return;
}
// Nur bekannte SPA-Routen als index.html ausliefern, unbekannte Routen mit 404 beantworten
if (
!req.path.startsWith('/api') &&
!req.path.startsWith('/static') &&
req.path !== '/robots.txt' &&
req.path !== '/sitemap.xml' &&
KNOWN_SPA_ROUTES.has(req.path)
) {
res.sendFile(join(distPath, 'index.html'));
} else {
res.status(404).send('Not found');
}
});
}
// Server starten
// In Production laeuft Apache als Reverse Proxy auf localhost.
// In Development kann HOST=0.0.0.0 gesetzt werden, damit echte Android-Geraete
// im selben WLAN den lokalen Server erreichen.
const HOST = process.env.HOST || '127.0.0.1';
server.listen(PORT, HOST, () => {
console.log(`Server läuft auf http://${HOST}:${PORT}`);
console.log(`Umgebung: ${NODE_ENV}`);
console.log(`CORS erlaubt für: ${allowedOrigins.join(', ')}`);
if (IS_PRODUCTION) {
console.log('Production-Modus: HTTPS über Apache Reverse Proxy');
}
});