Files
folx-tv/server/static.ts
T

462 lines
22 KiB
TypeScript
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, { type Express } from "express";
import fs from "fs";
import path from "path";
import { storage } from "./storage";
import sharp from "sharp";
function escapeHtml(str: string): string {
return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// Dejanske dimenzije naslovnice. Prej sta bili sirina in visina v meta
// oznakah fiksno zapisani kot 1200x630, slike pa so v resnici 1200x900 —
// Facebook in Google sta dobila napacno vrednost. Ce dimenzij ne moremo
// prebrati, oznak raje ne zapisemo.
const ogDimCache = new Map<string, { w: number; h: number } | null>();
async function ogImageDims(imgUrl: string, publicDir: string) {
if (ogDimCache.has(imgUrl)) return ogDimCache.get(imgUrl) ?? null;
let dims: { w: number; h: number } | null = null;
try {
const m = /\/(uploads|images)\/([^/?#]+)$/.exec(imgUrl);
if (m) {
const cand = [
path.join(publicDir, m[1], m[2]),
path.join(publicDir, m[1], m[2].replace(/\.jpg$/, ".webp")),
];
for (const p of cand) {
if (!fs.existsSync(p)) continue;
const meta = await sharp(p).metadata();
if (meta.width && meta.height) { dims = { w: meta.width, h: meta.height }; break; }
}
}
} catch (e) {
dims = null;
}
ogDimCache.set(imgUrl, dims);
return dims;
}
function ogImageUrl(coverImage: string, baseUrl: string): string {
if (!coverImage) return "";
let imgPath = coverImage;
// Cloudinary: vsili FB razmerje 1200x630 z g_faces (da FB ne reze glav)
if (imgPath.includes("res.cloudinary.com")) {
imgPath = imgPath.replace(/([,&/?])h_\d+/i, "$1h_630").replace(/([,&/?])w_\d+/i, "$1w_1200");
// ce ni g_faces, ga dodaj
if (!/g_faces/.test(imgPath)) {
imgPath = imgPath.replace(/\/(image\/(upload|fetch))\//, "/$1/g_faces,");
}
return imgPath;
}
if (imgPath.endsWith(".webp")) {
imgPath = imgPath.replace(/\.webp$/, ".jpg");
}
return imgPath.startsWith("http") ? imgPath : `${baseUrl}${imgPath}`;
}
// Stari slugi clankov, ki so bili pozneje preimenovani. Brez tega vsak star
// link (FB objave, komentarji, go.folx.tv kratki linki, Googlov indeks) pade
// na trdi 404. Kljuc = star slug, vrednost = trenutni slug.
const ARTICLE_SLUG_REDIRECTS: Record<string, string> = {
"alexander-rier-verraet-das-ist-die-neue-frau-an-seiner-seite":
"alexander-rier-von-mann-zu-mann-immer-wieder-sonntags",
};
// Rubrike, ki jih pisemo enkrat in objavimo tudi na onemusic.news. Besedilo je
// isto na obeh domenah, zato jih na folx.tv drzimo zunaj Googlovega indeksa —
// v bazi in v /api/articles ostanejo, ker jih bere sidebar na TV kanalih.
const ONE_RUBRIKEN = new Set(["Politik", "Promis", "Sport", "Musik & Film", "Wetter", "Finanzen", "Aktuelles"]);
function setRobots(html: string, content: string): string {
const tag = `<meta name="robots" content="${content}" />`;
if (/<meta\s+name="robots"[^>]*>/i.test(html)) {
return html.replace(/<meta\s+name="robots"[^>]*>/i, tag);
}
return html.replace("</head>", ` ${tag}\n </head>`);
}
function stripExistingMeta(html: string): string {
html = html.replace(/<meta\s+property="og:[^"]*"[^>]*>\s*/gi, "");
html = html.replace(/<meta\s+name="twitter:[^"]*"[^>]*>\s*/gi, "");
html = html.replace(/<meta\s+name="description"[^>]*>\s*/gi, "");
html = html.replace(/<meta\s+name="keywords"[^>]*>\s*/gi, "");
html = html.replace(/<link\s+rel="canonical"[^>]*>\s*/gi, "");
return html;
}
export function serveStatic(app: Express) {
const distPath = path.resolve(__dirname, "public");
if (!fs.existsSync(distPath)) {
throw new Error(
`Could not find the build directory: ${distPath}, make sure to build the client first`,
);
}
app.use(express.static(distPath, {
setHeaders(res, filePath) {
if (filePath.endsWith("og-image.jpg") || filePath.includes("/uploads/") || filePath.includes("/images/")) {
res.setHeader("Cache-Control", "public, max-age=86400");
}
},
}));
app.use("/{*path}", async (req, res) => {
const url = req.originalUrl;
const indexPath = path.resolve(distPath, "index.html");
const canonicalBase = "https://folx.tv";
const host = req.get("host") || "folx.tv";
const protocol = req.get("x-forwarded-proto") || "https";
const baseUrl = `${protocol}://${host}`;
// ---- /video/<slug>: napovednik je predvajljiv na strani, cel video na YouTube ----
const ytVideoMatch = url.match(/^\/video\/([^?#\/]+)/);
if (ytVideoMatch) {
try {
const slug = decodeURIComponent(ytVideoMatch[1]);
const idxFile = path.resolve(process.cwd(), "client/public/uploads/video/index.json");
const list: any[] = JSON.parse(await fs.promises.readFile(idxFile, "utf-8")).items || [];
const v = list.find((i) => i.slug === slug || i.ytId === slug);
let template = await fs.promises.readFile(indexPath, "utf-8");
template = stripExistingMeta(template);
if (!v) {
template = template.replace(/<title>[^<]*<\/title>/, [
'<meta name="robots" content="noindex, follow" />',
'<title>Video nicht gefunden | Folx Music Television</title>',
].join("\n "));
res.status(404).set({ "Content-Type": "text/html" }).end(template);
return;
}
const pageUrl = `${canonicalBase}/video/${v.slug}`;
const poster = `${canonicalBase}${v.posterUrl}`;
const desc = (v.teaser || v.shortTitle || v.title).slice(0, 300);
const mins = Math.floor((v.durationS || 0) / 60), secs = (v.durationS || 0) % 60;
const embedUrl = v.embedUrl || `https://www.youtube-nocookie.com/embed/${v.ytId}`;
const videoLd = {
"@context": "https://schema.org",
"@type": "VideoObject",
"name": v.shortTitle,
"description": desc,
"thumbnailUrl": [poster],
"uploadDate": v.publishedAt,
"duration": `PT${mins}M${secs}S`,
"embedUrl": embedUrl,
"contentUrl": v.youtubeUrl,
"isFamilyFriendly": true,
"inLanguage": "de",
"publisher": {
"@type": "Organization",
"name": "Folx Music Television",
"logo": { "@type": "ImageObject", "url": `${canonicalBase}/og-image.jpg` },
},
"mainEntityOfPage": { "@type": "WebPage", "@id": pageUrl },
};
const tags = [
`<meta property="og:title" content="${escapeHtml(v.shortTitle)}" />`,
`<meta property="og:description" content="${escapeHtml(desc)}" />`,
`<meta property="og:type" content="video.other" />`,
`<meta property="og:url" content="${escapeHtml(pageUrl)}" />`,
`<meta property="og:image" content="${escapeHtml(poster)}" />`,
`<meta property="og:image:width" content="1280" />`,
`<meta property="og:image:height" content="720" />`,
`<meta property="og:site_name" content="Folx Music Television" />`,
`<meta property="og:locale" content="de_DE" />`,
`<meta name="twitter:card" content="summary_large_image" />`,
`<meta name="twitter:title" content="${escapeHtml(v.shortTitle)}" />`,
`<meta name="twitter:description" content="${escapeHtml(desc)}" />`,
`<meta name="twitter:image" content="${escapeHtml(poster)}" />`,
`<meta name="description" content="${escapeHtml(desc)}" />`,
`<link rel="canonical" href="${escapeHtml(pageUrl)}" />`,
`<title>${escapeHtml(v.shortTitle)} | ${escapeHtml(v.rubrik)} | Folx Music Television</title>`,
`<script type="application/ld+json">${JSON.stringify(videoLd).replace(/</g, "\\u003c")}</script>`,
].join("\n ");
template = template.replace(/<title>[^<]*<\/title>/, tags);
const chapterHtml = (v.chapters || []).length
? `<h2>Kapitel</h2><ul>${v.chapters.map((c: any) => `<li>${escapeHtml(c.t)} &ndash; ${escapeHtml(c.label)}</li>`).join("")}</ul>`
: "";
const ssr = [
'<article class="ssr-content">',
`<h1>${escapeHtml(v.shortTitle)}</h1>`,
`<iframe width="640" height="360" src="${escapeHtml(embedUrl)}" title="${escapeHtml(v.shortTitle)}" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen style="border:0;max-width:100%;"></iframe>`,
`<p><strong>${escapeHtml(desc)}</strong></p>`,
chapterHtml,
`<p><a href="${escapeHtml(v.youtubeUrl)}" rel="noopener">Das ganze Video auf YouTube ansehen</a></p>`,
"</article>",
].join("\n");
template = template.replace('<div id="root"></div>', () => `<div id="root">${ssr}</div>`);
res.status(200).set({ "Content-Type": "text/html" }).end(template);
return;
} catch (e) {
}
}
const articleMatch = url.match(/^\/article\/([^?#]+)/);
if (articleMatch) {
try {
const slug = decodeURIComponent(articleMatch[1]);
const article = await storage.getArticleBySlug(slug);
if (article && (article as any).targetUrl) {
res.redirect(302, (article as any).targetUrl);
return;
}
if (article) {
const articleUrl = `${canonicalBase}/article/${article.slug}`;
const imageUrl = ogImageUrl(article.coverImage || "", canonicalBase);
const finalImage = imageUrl || `${canonicalBase}/og-image.jpg`;
const articleImgDims = await ogImageDims(finalImage, distPath);
if (!imageUrl) {
console.warn(`[og] clanek brez naslovnice, pada na logotip: ${article.slug}`);
}
let template = await fs.promises.readFile(indexPath, "utf-8");
template = stripExistingMeta(template);
const pubDate = article.publishedAt
? new Date(article.publishedAt as any).toISOString()
: new Date().toISOString();
// dateModified: ce je clanek dopolnjen (enrich), preberi marker <!--folx-updated:ISO-->
const modDate = (() => {
const mm = /<!--folx-updated:([^>]+?)-->/.exec(article.content || "");
if (mm) { try { return new Date(mm[1]).toISOString(); } catch (e) { /* ignore */ } }
return pubDate;
})();
// VideoObject: ce je v clanku vgrajen YouTube video
const videoId = (() => {
const mm = /youtube(?:-nocookie)?\.com\/embed\/([A-Za-z0-9_-]{6,})/.exec(article.content || "");
return mm ? mm[1] : null;
})();
const jsonLd = {
"@context": "https://schema.org",
"@type": "NewsArticle",
"headline": article.title,
"description": article.excerpt,
"image": [finalImage],
"datePublished": pubDate,
"dateModified": modDate,
"articleSection": article.category || "News",
"author": {
"@type": "Organization",
"name": article.author || "Folx Music Television",
"url": canonicalBase,
},
"publisher": {
"@type": "Organization",
"name": "Folx Music Television",
"logo": {
"@type": "ImageObject",
"url": `${canonicalBase}/og-image.jpg`,
},
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": articleUrl,
},
};
const jsonLdTag = `<script type="application/ld+json">${JSON.stringify(jsonLd).replace(/</g, "\\u003c")}</script>`;
const videoLd = videoId ? {
"@context": "https://schema.org",
"@type": "VideoObject",
"name": article.title,
"description": article.excerpt,
"thumbnailUrl": [`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`],
"uploadDate": pubDate,
"embedUrl": `https://www.youtube.com/embed/${videoId}`,
"contentUrl": `https://www.youtube.com/watch?v=${videoId}`,
"publisher": {
"@type": "Organization",
"name": "Folx Music Television",
"logo": { "@type": "ImageObject", "url": `${canonicalBase}/og-image.jpg` },
},
} : null;
const videoLdTag = videoLd
? `<script type="application/ld+json">${JSON.stringify(videoLd).replace(/</g, "\\u003c")}</script>`
: "";
const ogTags = [
`<meta property="og:title" content="${escapeHtml(article.title)}" />`,
`<meta property="og:description" content="${escapeHtml(article.excerpt)}" />`,
`<meta property="og:type" content="article" />`,
`<meta property="og:url" content="${escapeHtml(articleUrl)}" />`,
`<meta property="og:image" content="${escapeHtml(finalImage)}" />`,
`<meta property="og:image:secure_url" content="${escapeHtml(finalImage)}" />`,
...(articleImgDims
? [`<meta property="og:image:width" content="${articleImgDims.w}" />`,
`<meta property="og:image:height" content="${articleImgDims.h}" />`]
: []),
`<meta property="og:image:type" content="image/jpeg" />`,
`<meta property="og:site_name" content="Folx Music Television" />`,
`<meta property="og:locale" content="de_DE" />`,
`<meta property="fb:pages" content="281200958640258" />`,
`<meta name="twitter:card" content="summary_large_image" />`,
`<meta name="twitter:title" content="${escapeHtml(article.title)}" />`,
`<meta name="twitter:description" content="${escapeHtml(article.excerpt)}" />`,
`<meta name="twitter:image" content="${escapeHtml(finalImage)}" />`,
`<meta name="description" content="${escapeHtml(article.excerpt)}" />`,
`<meta name="keywords" content="Volksmusik, Schlager, ${escapeHtml(article.title)}, ${escapeHtml(article.category || 'News')}, FOLX TV" />`,
`<link rel="canonical" href="${escapeHtml(articleUrl)}" />`,
`<title>${escapeHtml(article.title)} - Volksmusik & Schlager | Folx Music Television</title>`,
jsonLdTag,
videoLdTag,
].join("\n ");
template = template.replace(/<title>[^<]*<\/title>/, ogTags);
// SSR: vgradi vsebino clanka v HTML, da jo Google/Discover vidita brez JS
const ssrBlock = [
'<article class="ssr-content">',
`<h1>${escapeHtml(article.title)}</h1>`,
imageUrl ? `<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(article.title)}" style="max-width:100%;height:auto;" />` : "",
`<p><strong>${escapeHtml(article.excerpt)}</strong></p>`,
article.content || "",
"</article>",
].join("\n");
template = template.replace('<div id="root"></div>', () => `<div id="root">${ssrBlock}</div>`);
if (ONE_RUBRIKEN.has(article.category || "")) {
template = setRobots(template, "noindex, follow, max-image-preview:large");
}
res.status(200).set({ "Content-Type": "text/html" }).end(template);
return;
}
// Star slug preimenovanega clanka -> trajna preusmeritev na novega.
const renamedTo = ARTICLE_SLUG_REDIRECTS[slug];
if (renamedTo) {
res.redirect(301, `/article/${renamedTo}`);
return;
}
// Clanka s tem slugom ni. Brez tega bi SPA vrnil HTTP 200 z naslovnico
// in canonical na "/" — Google to bere kot podvojeno naslovnico (soft 404),
// izbrisani clanki pa ostanejo v indeksu in v Discovru.
{
let template = await fs.promises.readFile(indexPath, "utf-8");
template = stripExistingMeta(template);
template = template.replace(
/<title>[^<]*<\/title>/,
[
'<meta name="robots" content="noindex, follow" />',
'<meta name="description" content="Dieser Artikel ist nicht mehr verfuegbar." />',
'<title>Artikel nicht gefunden | Folx Music Television</title>',
].join("\n "),
);
res.status(404).set({ "Content-Type": "text/html" }).end(template);
return;
}
} catch (e) {
}
}
if (url.match(/^\/gallery(\/|$|\?)/)) {
let template = await fs.promises.readFile(indexPath, "utf-8");
template = stripExistingMeta(template);
const galTitle = "Fotogalerie \u2013 Einzigartige Momente von unseren Aufzeichnungen | FOLX TV";
const galDesc = "Spannende Schnappsch\u00fcsse und besondere Augenblicke, eingefangen bei unseren Aufzeichnungen. Authentische Bilder der Volksmusik- und Schlager-Stars in einzigartigen Momenten bei FOLX TV.";
const galUrl = `${canonicalBase}/gallery`;
const galImage = `${canonicalBase}/images/og-image.jpg`;
const galTags = [
`<meta property="og:title" content="${escapeHtml(galTitle)}" />`,
`<meta property="og:description" content="${escapeHtml(galDesc)}" />`,
`<meta property="og:type" content="website" />`,
`<meta property="og:url" content="${escapeHtml(galUrl)}" />`,
`<meta property="og:image" content="${escapeHtml(galImage)}" />`,
`<meta property="og:image:secure_url" content="${escapeHtml(galImage)}" />`,
`<meta property="og:image:width" content="1200" />`,
`<meta property="og:image:height" content="630" />`,
`<meta property="og:image:type" content="image/jpeg" />`,
`<meta property="og:site_name" content="Folx Music Television" />`,
`<meta property="og:locale" content="de_DE" />`,
`<meta name="twitter:card" content="summary_large_image" />`,
`<meta name="twitter:title" content="${escapeHtml(galTitle)}" />`,
`<meta name="twitter:description" content="${escapeHtml(galDesc)}" />`,
`<meta name="twitter:image" content="${escapeHtml(galImage)}" />`,
`<meta name="description" content="${escapeHtml(galDesc)}" />`,
`<meta name="keywords" content="Fotogalerie, Volksmusik Fotos, Schlager Bilder, Aufzeichnungen, Schnappschüsse, FOLX TV, Volksmusik Stars" />`,
`<link rel="canonical" href="${escapeHtml(galUrl)}" />`,
`<title>${escapeHtml(galTitle)}</title>`,
].join("\n ");
template = template.replace(/<title>[^<]*<\/title>/, galTags);
res.status(200).set({ "Content-Type": "text/html" }).end(template);
return;
}
if (url.match(/^\/horoskop(\/|$|\?)/)) {
let template = await fs.promises.readFile(indexPath, "utf-8");
template = stripExistingMeta(template);
const signMatch = url.match(/^\/horoskop\/([^?#]+)/);
const signName = signMatch ? decodeURIComponent(signMatch[1]) : null;
const capitalSign = signName ? signName.charAt(0).toUpperCase() + signName.slice(1) : null;
const title = capitalSign
? `${capitalSign} Horoskop – Tages-, Wochen- & Monatshoroskop | Folx Music Television`
: "Horoskop – Tages-, Wochen- & Monatshoroskop für alle Sternzeichen | Folx Music Television";
const desc = capitalSign
? `${capitalSign} Horoskop: Tägliches, wöchentliches und monatliches Horoskop. Liebe, Beruf & Gesundheit bei FOLX TV.`
: "Kostenloses Tageshoroskop, Wochenhoroskop & Monatshoroskop für alle 12 Sternzeichen. Liebe, Beruf, Gesundheit bei FOLX TV.";
const horoUrl = `${canonicalBase}${signName ? `/horoskop/${signName}` : "/horoskop"}`;
const horoImage = `${canonicalBase}/images/horoskop-og.jpg`;
const horoTags = [
`<meta property="og:title" content="${escapeHtml(title)}" />`,
`<meta property="og:description" content="${escapeHtml(desc)}" />`,
`<meta property="og:type" content="website" />`,
`<meta property="og:url" content="${escapeHtml(horoUrl)}" />`,
`<meta property="og:image" content="${escapeHtml(horoImage)}" />`,
`<meta property="og:image:secure_url" content="${escapeHtml(horoImage)}" />`,
`<meta property="og:image:width" content="1200" />`,
`<meta property="og:image:height" content="675" />`,
`<meta property="og:image:type" content="image/jpeg" />`,
`<meta property="og:site_name" content="Folx Music Television" />`,
`<meta property="og:locale" content="de_DE" />`,
`<meta name="twitter:card" content="summary_large_image" />`,
`<meta name="twitter:title" content="${escapeHtml(title)}" />`,
`<meta name="twitter:description" content="${escapeHtml(desc)}" />`,
`<meta name="twitter:image" content="${escapeHtml(horoImage)}" />`,
`<meta name="description" content="${escapeHtml(desc)}" />`,
`<meta name="keywords" content="Horoskop, Tageshoroskop, Wochenhoroskop, Monatshoroskop, Sternzeichen, Volksmusik, FOLX TV${capitalSign ? `, ${capitalSign}` : ""}" />`,
`<link rel="canonical" href="${escapeHtml(horoUrl)}" />`,
`<title>${escapeHtml(title)}</title>`,
].join("\n ");
template = template.replace(/<title>[^<]*<\/title>/, horoTags);
res.status(200).set({ "Content-Type": "text/html" }).end(template);
return;
}
// Neznana pot mora vrniti PRAVI 404, sicer Google dobi soft 404 (HTTP 200
// z naslovnico). Seznam mora ustrezati potem v client/src/App.tsx.
const KNOWN = [
/^\/$/, /^\/search\/?$/, /^\/article\//, /^\/category\//, /^\/aktuelles\/?$/,
/^\/videos\/?$/, /^\/video\/[^/]+\/?$/, /^\/gallery\/?$/, /^\/horoskop(\/|$)/, /^\/rezepte\/?$/,
/^\/empfang-folx-tv\/?$/, /^\/ueber-uns\/?$/, /^\/impressum\/?$/,
/^\/datenschutz\/?$/, /^\/kontakt\/?$/, /^\/admin(\/|$)/,
];
const cleanPath = url.split("?")[0].split("#")[0];
const known = KNOWN.some((re) => re.test(cleanPath));
let template = await fs.promises.readFile(indexPath, "utf-8");
if (!known) {
template = stripExistingMeta(template);
template = template.replace(
/<title>[^<]*<\/title>/,
[
'<title>Seite nicht gefunden | FOLX TV</title>',
'<meta name="robots" content="noindex, follow" />',
].join("\n ")
);
res.status(404).set({ "Content-Type": "text/html" }).end(template);
return;
}
res.status(200).set({ "Content-Type": "text/html" }).end(template);
});
}