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); + } }); }