diff --git a/SEO-TODO.md b/SEO-TODO.md
new file mode 100644
index 0000000..11ac10c
--- /dev/null
+++ b/SEO-TODO.md
@@ -0,0 +1,69 @@
+# SEO TODO (offene Punkte)
+
+## 1) Host-/TLS-Konsistenz (Apex und www)
+
+- [x] App-Fallback-Redirect in Node aktiv (`ypchat.net` + HTTP -> `https://www.ypchat.net`).
+
+### Zertifikatserstellung (Let's Encrypt / Certbot, Apache)
+
+1. DNS pruefen
+ - `A/AAAA` fuer `ypchat.net` und `www.ypchat.net` muessen auf denselben Server zeigen.
+2. Certbot installieren (falls noch nicht vorhanden)
+ - Ubuntu: `sudo apt update && sudo apt install certbot python3-certbot-apache`
+3. Zertifikat fuer beide Hosts ausstellen
+ - `sudo certbot --apache -d ypchat.net -d www.ypchat.net`
+4. Auto-Renew testen
+ - `sudo certbot renew --dry-run`
+5. Apache neu laden
+ - `sudo systemctl reload apache2`
+
+### Apache-Redirects (kanonischer Host + HTTPS)
+
+Empfohlene Logik:
+- `http://ypchat.net/*` -> `https://www.ypchat.net/*` (301)
+- `http://www.ypchat.net/*` -> `https://www.ypchat.net/*` (301)
+- `https://ypchat.net/*` -> `https://www.ypchat.net/*` (301)
+- Nur `https://www.ypchat.net/*` liefert `200`
+
+Beispiel (VirtualHost fuer Port 80):
+
+```apache
+
+ ServerName ypchat.net
+ ServerAlias www.ypchat.net
+ RewriteEngine On
+ RewriteRule ^ https://www.ypchat.net%{REQUEST_URI} [R=301,L]
+
+```
+
+Beispiel (VirtualHost fuer `https://ypchat.net`):
+
+```apache
+
+ ServerName ypchat.net
+ SSLEngine on
+ SSLCertificateFile /etc/letsencrypt/live/ypchat.net/fullchain.pem
+ SSLCertificateKeyFile /etc/letsencrypt/live/ypchat.net/privkey.pem
+ RewriteEngine On
+ RewriteRule ^ https://www.ypchat.net%{REQUEST_URI} [R=301,L]
+
+```
+
+Verifikation:
+- `curl -I https://ypchat.net/` -> `301 Location: https://www.ypchat.net/`
+- `curl -I https://www.ypchat.net/` -> `200`
+- Browser ohne TLS-Warnung fuer beide Hosts
+
+## 3) Search Console / Reindexing
+
+- [ ] In Google Search Console `https://www.ypchat.net` als Hauptproperty nutzen.
+- [ ] Sitemap neu einreichen: `https://www.ypchat.net/sitemap.xml`.
+- [ ] Live-Tests ausfuehren fuer:
+ - [ ] `/`
+ - [ ] `/partners`
+ - [ ] `/feedback`
+- [ ] Fuer diese URLs "Indexierung beantragen".
+- [ ] Nach 7-14 Tagen kontrollieren:
+ - [ ] "Gecrawlt - zurzeit nicht indexiert"
+ - [ ] "Gefunden - zurzeit nicht indexiert"
+ - [ ] Impressionen/Klicks im Leistungsbericht.
diff --git a/server/index.js b/server/index.js
index 04eb3bd..d5bfff5 100644
--- a/server/index.js
+++ b/server/index.js
@@ -20,6 +20,8 @@ const server = createServer(app);
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.ypchat.net';
+const LEGACY_HOSTS = new Set(['ypchat.net']);
// CORS-Origins konfigurieren
const allowedOrigins = IS_PRODUCTION
@@ -83,6 +85,38 @@ app.use(session({
// 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();
+ });
}
// Statische Dateien aus docroot
@@ -102,6 +136,7 @@ setupBroadcast(io, __dirname);
// 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', '/mockup-redesign']);
app.use(express.static(distPath));
// Fallback für Vue Router (SPA) - muss am Ende stehen
app.get('*', (req, res) => {
@@ -114,8 +149,14 @@ if (IS_PRODUCTION) {
res.status(404).send('Not found');
return;
}
- // Nur für nicht-API und nicht-static Requests
- if (!req.path.startsWith('/api') && !req.path.startsWith('/static') && req.path !== '/robots.txt' && req.path !== '/sitemap.xml') {
+ // 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');
diff --git a/server/routes-seo.js b/server/routes-seo.js
index a584f18..9c0a414 100644
--- a/server/routes-seo.js
+++ b/server/routes-seo.js
@@ -75,6 +75,27 @@ const seoData = {
}
};
+function buildSitemapXml() {
+ const currentDate = new Date().toISOString().split('T')[0];
+ const urls = Object.entries(seoData)
+ .map(([route, meta]) => {
+ const priority = route === '/' ? '1.0' : '0.8';
+ const changefreq = route === '/' ? 'daily' : 'weekly';
+ return `
+ ${meta.ogUrl}
+ ${currentDate}
+ ${changefreq}
+ ${priority}
+ `;
+ })
+ .join('\n');
+
+ return `
+
+${urls}
+`;
+}
+
function escapeHtml(value = '') {
return String(value)
.replace(/&/g, '&')
@@ -221,25 +242,22 @@ Sitemap: ${SITE_URL}/sitemap.xml
});
app.get('/sitemap.xml', (req, res) => {
- const currentDate = new Date().toISOString().split('T')[0];
- const urls = Object.entries(seoData)
- .map(([route, meta]) => {
- const priority = route === '/' ? '1.0' : '0.8';
- const changefreq = route === '/' ? 'daily' : 'weekly';
- return `
- ${meta.ogUrl}
- ${currentDate}
- ${changefreq}
- ${priority}
- `;
- })
- .join('\n');
-
- const sitemap = `
+ try {
+ const sitemap = buildSitemapXml();
+ // Stabilere Auslieferung fuer Crawler und Reverse-Proxy-Caches.
+ res.set('Cache-Control', 'public, max-age=300, s-maxage=300, stale-while-revalidate=600');
+ res.type('application/xml');
+ res.status(200).send(sitemap);
+ } catch (error) {
+ console.error('[SEO] Fehler beim Generieren der sitemap.xml:', error);
+ // Fallback: niemals 500 fuer die Sitemap ausliefern.
+ const fallback = `
-${urls}
+ ${SITE_URL}/
`;
- res.type('application/xml');
- res.send(sitemap);
+ res.set('Cache-Control', 'no-store');
+ res.type('application/xml');
+ res.status(200).send(fallback);
+ }
});
}