diff --git a/client/src/App.tsx b/client/src/App.tsx index b41db14..6a6a814 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -10,6 +10,7 @@ import ArticlePage from "@/pages/article"; import CategoryPage from "@/pages/category"; import AktuellesPage from "@/pages/aktuelles"; import VideosPage from "@/pages/videos"; +import VideoDetailPage from "@/pages/video-detail"; import GalleryPageWrapper from "@/pages/gallery"; import HoroscopePage from "@/pages/horoscope"; import RecipesPage from "@/pages/recipes"; @@ -43,6 +44,7 @@ function Router() { + diff --git a/client/src/App.tsx.bak b/client/src/App.tsx.bak new file mode 100644 index 0000000..b41db14 --- /dev/null +++ b/client/src/App.tsx.bak @@ -0,0 +1,75 @@ +import { Switch, Route, useLocation } from "wouter"; +import { useEffect } from "react"; +import { queryClient } from "./lib/queryClient"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { Toaster } from "@/components/ui/toaster"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import NotFound from "@/pages/not-found"; +import Home from "@/pages/home"; +import ArticlePage from "@/pages/article"; +import CategoryPage from "@/pages/category"; +import AktuellesPage from "@/pages/aktuelles"; +import VideosPage from "@/pages/videos"; +import GalleryPageWrapper from "@/pages/gallery"; +import HoroscopePage from "@/pages/horoscope"; +import RecipesPage from "@/pages/recipes"; +import SearchPage from "@/pages/search"; +import EmpfangPage from "@/pages/empfang"; +import AboutPage from "@/pages/about"; +import ImpressumPage from "@/pages/impressum"; +import DatenschutzPage from "@/pages/datenschutz"; +import KontaktPage from "@/pages/kontakt"; +import AdminGalleryPage from "@/pages/admin-gallery"; +import AdminPushPage from "@/pages/admin-push"; +import CookieConsent from "@/components/cookie-consent"; +import MobileStickyAd from "@/components/mobile-sticky-ad"; + +function ScrollToTop() { + const [location] = useLocation(); + useEffect(() => { + window.scrollTo(0, 0); + }, [location]); + return null; +} + +function Router() { + return ( +
+ + + + + + + + + + + + + + + + + + + + + +
+ ); +} + +function App() { + return ( + + + + + + + + ); +} + +export default App; diff --git a/client/src/pages/video-detail.tsx b/client/src/pages/video-detail.tsx new file mode 100644 index 0000000..e9fbe13 --- /dev/null +++ b/client/src/pages/video-detail.tsx @@ -0,0 +1,165 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link, useRoute } from "wouter"; +import { ArrowLeft, Play, Clock, Youtube } from "lucide-react"; +import Header from "@/components/header"; +import Footer from "@/components/footer"; +import { PageSideAds, TopBannerAd, MultiplexAd } from "@/components/adsense"; +import { Skeleton } from "@/components/ui/skeleton"; +import { format } from "date-fns"; +import { de } from "date-fns/locale"; + +export interface YtVideo { + ytId: string; + slug: string; + title: string; + shortTitle: string; + rubrik: string; + publishedAt: string; + durationS: number; + teaser: string; + description: string; + chapters: { t: string; label: string }[]; + tags: string[]; + previewUrl: string; + previewS: number; + posterUrl: string; + youtubeUrl: string; +} + +export function dauer(s: number) { + const m = Math.floor(s / 60); + const r = s % 60; + return `${m}:${String(r).padStart(2, "0")}`; +} + +function RelatedCard({ v }: { v: YtVideo }) { + return ( + +
+
+ {v.shortTitle} + + {dauer(v.durationS)} + +
+
+

+ {v.shortTitle} +

+
+
+ + ); +} + +export default function VideoDetailPage() { + const [, params] = useRoute("/video/:slug"); + const slug = params?.slug || ""; + const { data, isLoading } = useQuery<{ item: YtVideo; related: YtVideo[] }>({ + queryKey: [`/api/yt-videos/${slug}`], + enabled: !!slug, + }); + const v = data?.item; + + return ( +
+
+ +
+ + + + + + {isLoading || !v ? ( +
+ + + +
+ ) : ( + <> +
+
+ +
+ {v.rubrik} + {dauer(v.durationS)} + {format(new Date(v.publishedAt), "d. MMMM yyyy", { locale: de })} + Vorschau {v.previewS} Sek. +
+ +

+ {v.shortTitle} +

+ + {v.teaser && ( +

+ {v.teaser} +

+ )} + + + + Ganzes Video auf YouTube ansehen + + + {v.chapters?.length > 0 && ( +
+

Kapitel

+
    + {v.chapters.map((c) => ( +
  • + {c.t} + {c.label} +
  • + ))} +
+
+ )} + + {v.description && ( +
+

Zum Video

+

+ {v.description.replace(/\n{3,}/g, "\n\n")} +

+
+ )} + + + + {data?.related?.length ? ( +
+

Mehr aus {v.rubrik}

+
+ {data.related.map((r) => )} +
+
+ ) : null} + + )} +
+
+
+ ); +} diff --git a/server/routes.ts b/server/routes.ts index 206e697..33dc331 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -391,6 +391,37 @@ export async function registerRoutes( const LIBRARY_ID = process.env.BUNNY_LIBRARY_ID || "476412"; const CDN_HOST = process.env.BUNNY_CDN_HOST || "vz-7982dfc4-cc8.b-cdn.net"; + // ---- FOLX YouTube video sekcija (napovednik na strani, cel video na YouTube) ---- + const ytIndexPath = path.join(uploadDir, "video", "index.json"); + function ytItems(): any[] { + try { + const raw = fs.readFileSync(ytIndexPath, "utf-8"); + const j = JSON.parse(raw); + return Array.isArray(j.items) ? j.items : []; + } catch { + return []; + } + } + app.get("/api/yt-videos", (req, res) => { + const rubrik = String(req.query.rubrik || "").trim(); + const page = Math.max(1, parseInt(String(req.query.page || "1"))); + const limit = Math.min(48, Math.max(1, parseInt(String(req.query.limit || "24")))); + let items = ytItems(); + if (rubrik && rubrik !== "Alle") items = items.filter((i) => i.rubrik === rubrik); + const total = items.length; + const slice = items.slice((page - 1) * limit, page * limit); + const rubriken = Array.from(new Set(ytItems().map((i) => i.rubrik))); + res.set("Cache-Control", "public, max-age=120"); + res.json({ items: slice, total, page, totalPages: Math.max(1, Math.ceil(total / limit)), rubriken }); + }); + app.get("/api/yt-videos/:slug", (req, res) => { + const it = ytItems().find((i) => i.slug === req.params.slug || i.ytId === req.params.slug); + if (!it) return res.status(404).json({ message: "Video nicht gefunden" }); + const same = ytItems().filter((x) => x.rubrik === it.rubrik && x.slug !== it.slug).slice(0, 4); + res.set("Cache-Control", "public, max-age=120"); + res.json({ item: it, related: same }); + }); + app.get("/api/videos", async (req, res) => { try { const page = parseInt(req.query.page as string) || 1; diff --git a/server/routes.ts.bak b/server/routes.ts.bak new file mode 100644 index 0000000..206e697 --- /dev/null +++ b/server/routes.ts.bak @@ -0,0 +1,1115 @@ +import type { Express } from "express"; +import { createServer, type Server } from "http"; +import { storage } from "./storage"; +import { insertArticleSchema } from "@shared/schema"; +import { seedDatabase } from "./seed"; +import { generateDailyHoroscopes, getHoroscopesForToday, getOrGenerateHoroscope, setHoroscopeImages } from "./horoscope-generator"; +import { getRecipeForToday, upsertRecipe, listRecipes } from "./daily-recipe"; +import { startDailyScheduler } from "./scheduler"; +import { analyzeAllArticleImages, getCachedFocalPoints } from "./focal-point"; +import { optimizeImage } from "./image-optimizer"; +import { getAuthUrl, exchangeCodeForTokens, isConnected, fetchGalleryFromDropbox, getValidAccessToken, migrateGalleryToCloudinary } from "./dropbox"; +import { listCloudinaryGalleryDetailed, deleteFromCloudinary } from "./cloudinary"; +import { sendContactEmail } from "./mailer"; +import multer from "multer"; +import path from "path"; +import fs from "fs"; +import https from "https"; +import webpush from "web-push"; + +const apiCache = new Map(); +function getCached(key: string, ttlMs: number): T | null { + const entry = apiCache.get(key); + if (entry && Date.now() - entry.timestamp < ttlMs) return entry.data as T; + return null; +} +function setCache(key: string, data: any) { + apiCache.set(key, { data, timestamp: Date.now() }); +} +function getStale(key: string): T | null { + const entry = apiCache.get(key); + return entry ? (entry.data as T) : null; +} + +const uploadDir = path.join(process.cwd(), "client/public/uploads"); +if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); +} + +const upload = multer({ + storage: multer.diskStorage({ + destination: (_req, _file, cb) => cb(null, uploadDir), + filename: (_req, file, cb) => { + const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9); + const ext = path.extname(file.originalname); + cb(null, uniqueSuffix + ext); + }, + }), + limits: { fileSize: 10 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + const allowed = /jpeg|jpg|png|gif|webp/; + const ext = allowed.test(path.extname(file.originalname).toLowerCase()); + const mime = allowed.test(file.mimetype); + cb(null, ext && mime); + }, +}); + +export async function registerRoutes( + httpServer: Server, + app: Express +): Promise { + try { + await seedDatabase(); + } catch (err: any) { + console.error("[seed] seedDatabase failed (server continues):", err?.message); + } + + startDailyScheduler(); + + // ---- API zascita za pisalne endpointe (boti posiljajo x-api-key) ---- + const FOLX_API_KEY = process.env.FOLX_API_KEY || ""; + const requireApiKey = (req: any, res: any, next: any) => { + if (!FOLX_API_KEY) return next(); // brez nastavljenega kljuca: ne zaklepamo (rollout varovalka) + const provided = req.get("x-api-key") || (req.get("authorization") || "").replace(/^Bearer\s+/i, ""); + if (provided && provided === FOLX_API_KEY) return next(); + return res.status(401).json({ message: "Unauthorized" }); + }; + + // Fokusne tocke naslovnic (kje je obraz) — brez njih object-cover obreze na + // sredino in odreze glave. Racunamo ob zagonu IN vsakih 20 minut, da jih + // dobijo tudi clanki, ki jih boti objavijo med tem. + const refreshFocalPoints = () => { + storage.getArticles().then((articles) => { + analyzeAllArticleImages(articles).catch(err => + console.log("[focal-point] Analysis skipped:", err.message) + ); + }).catch(err => console.log("[focal-point] getArticles failed:", err?.message)); + }; + refreshFocalPoints(); + setInterval(refreshFocalPoints, 20 * 60 * 1000); + + // ---- SEO: robots.txt, sitemap.xml, news sitemap, RSS ---- + // Rubrike z onemusic.news imajo isto besedilo kot tam, zato jih ne oddajamo + // Googlu. V bazi in v /api/articles ostanejo — sidebar na TV jih potrebuje. + const ONE_RUBRIKEN = new Set(["Politik", "Promis", "Sport", "Musik & Film", "Wetter", "Finanzen", "Aktuelles"]); + const isOwn = (a: any) => !ONE_RUBRIKEN.has(a?.category || ""); + + const SITE = "https://folx.tv"; + const xmlEscape = (s: string) => + String(s || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + + app.get("/robots.txt", (_req, res) => { + res.type("text/plain").send( + [ + "User-agent: *", + "Allow: /", + "", + `Sitemap: ${SITE}/sitemap.xml`, + `Sitemap: ${SITE}/news-sitemap.xml`, + "", + ].join("\n") + ); + }); + + // Stare oziroma logicne poti, ki jih React router ne pozna. Brez tega + // uporabnik in Google dobita 404 (npr. /empfang/, ki je bil v obtoku). + const LEGACY_REDIRECTS: Record = { + "/empfang": "/empfang-folx-tv", + "/empfang-folx": "/empfang-folx-tv", + "/news": "/category/News", + "/nachrichten": "/category/News", + "/video": "/videos", + "/galerie": "/gallery", + "/ueber": "/ueber-uns", + "/about": "/ueber-uns", + "/contact": "/kontakt", + "/rezept": "/rezepte", + "/horoscope": "/horoskop", + }; + app.get(Object.keys(LEGACY_REDIRECTS), (req, res) => { + const to = LEGACY_REDIRECTS[req.path.replace(/\/+$/, "") || "/"]; + if (to) return res.redirect(301, to); + return res.redirect(301, "/"); + }); + + app.get("/sitemap.xml", async (_req, res) => { + try { + const articles = await storage.getArticles(); + // POZOR: tu smejo biti samo poti, ki jih pozna React router v App.tsx. + // Prej so bile "/news", "/video", "/galerie" — teh poti ni, zato je Google + // dobival soft 404. Prave so "/category/News", "/videos", "/gallery". + const staticPaths = ["/", "/aktuelles", "/category/News", "/videos", "/gallery", + "/horoskop", "/rezepte", "/empfang-folx-tv", "/ueber-uns", + "/kontakt", "/impressum", "/datenschutz"]; + const urls: string[] = []; + for (const p of staticPaths) { + urls.push(` \n ${SITE}${p}\n daily\n ${p === "/" ? "1.0" : "0.7"}\n `); + } + for (const a of articles) { + if (!isOwn(a)) continue; + const lastmod = a.publishedAt ? new Date(a.publishedAt as any).toISOString() : new Date().toISOString(); + urls.push(` \n ${SITE}/article/${xmlEscape(a.slug)}\n ${lastmod}\n weekly\n 0.8\n `); + } + const xml = `\n\n${urls.join("\n")}\n`; + res.type("application/xml").send(xml); + } catch (e) { + res.status(500).type("application/xml").send(''); + } + }); + + app.get("/news-sitemap.xml", async (_req, res) => { + try { + const articles = await storage.getArticles(); + const now = Date.now(); + const twoDays = 48 * 60 * 60 * 1000; + const recent = articles.filter((a) => { + if (!isOwn(a)) return false; + const t = a.publishedAt ? new Date(a.publishedAt as any).getTime() : now; + return now - t <= twoDays; + }); + const urls = recent.map((a) => { + const pub = a.publishedAt ? new Date(a.publishedAt as any).toISOString() : new Date().toISOString(); + return ` \n ${SITE}/article/${xmlEscape(a.slug)}\n \n \n Folx Music Television\n de\n \n ${pub}\n ${xmlEscape(a.title)}\n \n `; + }); + const xml = `\n\n${urls.join("\n")}\n`; + res.type("application/xml").send(xml); + } catch (e) { + res.status(500).type("application/xml").send(''); + } + }); + + app.get(["/rss.xml", "/feed", "/feed.xml"], async (_req, res) => { + try { + const articles = (await storage.getArticles()).slice(0, 30); + const items = articles.map((a) => { + const pub = a.publishedAt ? new Date(a.publishedAt as any).toUTCString() : new Date().toUTCString(); + const link = `${SITE}/article/${xmlEscape(a.slug)}`; + const img = a.coverImage ? (a.coverImage.startsWith("http") ? a.coverImage : SITE + a.coverImage) : ""; + const enclosure = img ? `\n ` : ""; + return ` \n ${xmlEscape(a.title)}\n ${link}\n ${link}\n ${pub}\n ${xmlEscape(a.category || "News")}\n ${xmlEscape(a.excerpt || "")}${enclosure}\n `; + }); + const xml = `\n\n \n Folx Music Television – News\n ${SITE}\n \n Aktuelle Nachrichten aus der Welt der Volksmusik und des Schlagers.\n de-DE\n ${new Date().toUTCString()}\n${items.join("\n")}\n \n`; + res.type("application/rss+xml").send(xml); + } catch (e) { + res.status(500).type("application/rss+xml").send(''); + } + }); + + app.get("/api/focal-points", (_req, res) => { + res.json(getCachedFocalPoints()); + }); + + app.get("/api/articles", async (_req, res) => { + const articles = await storage.getArticles(); + res.json(articles); + }); + + app.get("/api/articles/featured", async (_req, res) => { + const articles = await storage.getFeaturedArticles(); + res.json(articles); + }); + + app.get("/api/articles/popular", async (req, res) => { + const limit = parseInt(req.query.limit as string) || 6; + const articles = await storage.getPopularArticles(limit); + res.json(articles); + }); + + // Rubrike, ki skupaj sestavljajo /aktuelles. Horoskop, recepti in vreme so + // na folx.tv svoje strani, zato jih tu ni. + const AKTUELLES_RUBRIKEN = ["Politik", "Promis", "Sport", "Musik & Film", "Wetter", "Finanzen"]; + + app.get("/api/articles/aktuelles", async (req, res) => { + try { + const page = Math.max(1, parseInt(req.query.page as string) || 1); + const limit = Math.min(48, parseInt(req.query.limit as string) || 12); + const rubrik = (req.query.rubrik as string) || ""; + const wanted = rubrik && AKTUELLES_RUBRIKEN.includes(rubrik) ? [rubrik] : AKTUELLES_RUBRIKEN; + const all = (await storage.getArticles()).filter((a) => wanted.includes(a.category)); + const total = all.length; + const start = (page - 1) * limit; + res.json({ + articles: all.slice(start, start + limit), + total, + page, + totalPages: Math.ceil(total / limit), + hasMore: start + limit < total, + rubriken: AKTUELLES_RUBRIKEN, + }); + } catch (e: any) { + res.status(500).json({ message: e?.message || "error" }); + } + }); + + app.get("/api/articles/category/:category", async (req, res) => { + const page = parseInt(req.query.page as string); + const limit = parseInt(req.query.limit as string) || 12; + if (page && page > 0) { + const result = await storage.getArticlesByCategoryPaginated(req.params.category, page, limit); + const totalPages = Math.ceil(result.total / limit); + return res.json({ + articles: result.articles, + total: result.total, + page, + totalPages, + hasMore: page < totalPages, + }); + } + const articles = await storage.getArticlesByCategory(req.params.category); + res.json(articles); + }); + + app.get("/api/search", async (req, res) => { + const q = (req.query.q as string || "").trim().toLowerCase(); + if (!q || q.length < 2) return res.json({ articles: [], videos: [] }); + + try { + const articles = await storage.getArticles(); + const matchedArticles = articles.filter(a => + a.title.toLowerCase().includes(q) || + a.excerpt.toLowerCase().includes(q) || + a.content.replace(/<[^>]+>/g, "").toLowerCase().includes(q) || + a.category.toLowerCase().includes(q) + ).slice(0, 10); + + let matchedVideos: any[] = []; + try { + const data = await bunnyFetch(`/library/${LIBRARY_ID}/videos?page=1&itemsPerPage=20&search=${encodeURIComponent(q)}`); + matchedVideos = (data.items || []).map((v: any) => { + const descTag = (v.metaTags || []).find((t: any) => t.property === "description"); + return { + guid: v.guid, + title: (v.title || "").replace(/\.mp4$/i, ""), + description: descTag?.value || v.description || "", + thumbnail: `https://${CDN_HOST}/${v.guid}/${v.thumbnailFileName || "thumbnail.jpg"}`, + embedUrl: `https://player.mediadelivery.net/embed/${LIBRARY_ID}/${v.guid}`, + }; + }).slice(0, 10); + } catch {} + + res.json({ articles: matchedArticles, videos: matchedVideos }); + } catch (err: any) { + res.status(500).json({ message: err.message }); + } + }); + + app.get("/api/articles/:slug", async (req, res) => { + const article = await storage.getArticleBySlug(req.params.slug); + if (!article) { + return res.status(404).json({ message: "Article not found" }); + } + const ip = (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() || req.socket.remoteAddress || "unknown"; + const isNew = await storage.incrementViews(article.id, ip); + res.json({ ...article, views: article.views + (isNew ? 1 : 0) }); + }); + + function _parseDate(v: any): Date | null { + if (!v) return null; + const d = new Date(v); + return isNaN(d.getTime()) ? null : d; + } + + app.post("/api/articles", requireApiKey, async (req, res) => { + const parsed = insertArticleSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ message: "Invalid article data", errors: parsed.error.flatten() }); + } + const existing = await storage.getArticleBySlug(parsed.data.slug); + if (existing) { + return res.status(409).json({ message: `Artikel "${existing.title}" existiert bereits (ID: ${existing.id})` }); + } + // publishedAt iz zunanjega vira (uvoz z ONE): shema ga sicer izpusti, zato + // ga sprejmemo posebej - drugace bi vsi uvozeni clanki nosili danasnji datum. + const data: any = { ...parsed.data }; + const pub = _parseDate(req.body?.publishedAt); + if (pub) data.publishedAt = pub; + const article = await storage.createArticle(data); + res.status(201).json(article); + }); + + app.patch("/api/articles/:id", requireApiKey, async (req, res) => { + const id = parseInt(req.params.id); + const partial = insertArticleSchema.partial().safeParse(req.body); + if (!partial.success) { + return res.status(400).json({ message: "Invalid article data", errors: partial.error.flatten() }); + } + const patch: any = { ...partial.data }; + const pubP = _parseDate(req.body?.publishedAt); + if (pubP) patch.publishedAt = pubP; + const article = await storage.updateArticle(id, patch); + if (!article) { + return res.status(404).json({ message: "Article not found" }); + } + res.json(article); + }); + + app.delete("/api/articles/:id", requireApiKey, async (req, res) => { + const id = parseInt(req.params.id); + await storage.deleteArticle(id); + res.status(204).send(); + }); + + app.post("/api/upload", requireApiKey, upload.single("image"), async (req, res) => { + if (!req.file) { + return res.status(400).json({ message: "No file uploaded" }); + } + try { + const result = await optimizeImage(req.file.path); + res.json({ url: result.webp, thumb: result.thumb }); + } catch (err) { + const url = `/uploads/${req.file.filename}`; + console.log("[image-optimizer] Optimization failed, using original:", (err as Error).message); + res.json({ url }); + } + }); + + function bunnyFetch(path: string): Promise { + return new Promise((resolve, reject) => { + const options = { + hostname: "video.bunnycdn.com", + path, + method: "GET", + headers: { "AccessKey": process.env.BUNNY_API_KEY || "", "Accept": "application/json" }, + }; + const req = https.request(options, (res) => { + let body = ""; + res.on("data", (c: string) => (body += c)); + res.on("end", () => { + try { resolve(JSON.parse(body)); } catch { reject(new Error(body)); } + }); + }); + req.on("error", reject); + req.end(); + }); + } + + const LIBRARY_ID = process.env.BUNNY_LIBRARY_ID || "476412"; + const CDN_HOST = process.env.BUNNY_CDN_HOST || "vz-7982dfc4-cc8.b-cdn.net"; + + app.get("/api/videos", async (req, res) => { + try { + const page = parseInt(req.query.page as string) || 1; + const perPage = parseInt(req.query.perPage as string) || 20; + const search = req.query.search as string || ""; + const cacheKey = `videos_${page}_${perPage}_${search}`; + const cached = getCached(cacheKey, 30 * 60 * 1000); + if (cached) return res.json(cached); + + let path = `/library/${LIBRARY_ID}/videos?page=${page}&itemsPerPage=${perPage}&orderBy=date`; + if (search) path += `&search=${encodeURIComponent(search)}`; + const data = await bunnyFetch(path); + const videos = (data.items || []).map((v: any) => { + const descTag = (v.metaTags || []).find((t: any) => t.property === "description"); + return { + guid: v.guid, + title: (v.title || "").replace(/\.mp4$/i, ""), + description: descTag?.value || v.description || "", + length: v.length, + dateUploaded: v.dateUploaded, + thumbnail: `https://${CDN_HOST}/${v.guid}/${v.thumbnailFileName || "thumbnail.jpg"}`, + embedUrl: `https://player.mediadelivery.net/embed/${LIBRARY_ID}/${v.guid}`, + hlsUrl: `https://${CDN_HOST}/${v.guid}/playlist.m3u8`, + status: v.status, + category: v.category || "", + }; + }); + const result = { items: videos, totalItems: data.totalItems, currentPage: data.currentPage }; + setCache(cacheKey, result); + res.json(result); + } catch (err: any) { + const stale = getStale(`videos_${req.query.page || 1}_${req.query.perPage || 20}_${req.query.search || ""}`); + if (stale) return res.json(stale); + res.status(500).json({ message: err.message }); + } + }); + + app.get("/api/videos/:guid", async (req, res) => { + try { + const data = await bunnyFetch(`/library/${LIBRARY_ID}/videos/${req.params.guid}`); + const descTag = (data.metaTags || []).find((t: any) => t.property === "description"); + res.json({ + guid: data.guid, + title: (data.title || "").replace(/\.mp4$/i, ""), + description: descTag?.value || data.description || "", + length: data.length, + dateUploaded: data.dateUploaded, + thumbnail: `https://${CDN_HOST}/${data.guid}/${data.thumbnailFileName || "thumbnail.jpg"}`, + embedUrl: `https://player.mediadelivery.net/embed/${LIBRARY_ID}/${data.guid}`, + hlsUrl: `https://${CDN_HOST}/${data.guid}/playlist.m3u8`, + status: data.status, + category: data.category || "", + }); + } catch (err: any) { + res.status(500).json({ message: err.message }); + } + }); + + // Dropbox OAuth + app.get("/api/dropbox/auth", (_req, res) => { + const host = _req.headers.host || "news-folx-tv.replit.app"; + const protocol = _req.headers["x-forwarded-proto"] || "https"; + const redirectUri = `${protocol}://${host}/api/dropbox/callback`; + const url = getAuthUrl(redirectUri); + res.redirect(url); + }); + + app.get("/api/dropbox/callback", async (req, res) => { + const code = req.query.code as string; + if (!code) { + return res.status(400).send("Manjka avtorizacijska koda."); + } + try { + const host = req.headers.host || "news-folx-tv.replit.app"; + const protocol = req.headers["x-forwarded-proto"] || "https"; + const redirectUri = `${protocol}://${host}/api/dropbox/callback`; + await exchangeCodeForTokens(code, redirectUri); + res.send("

Dropbox povezan!

Lahko zaprete to stran.

"); + } catch (err: any) { + res.status(500).send(`Napaka: ${err.message}`); + } + }); + + app.get("/api/dropbox/status", (_req, res) => { + res.json({ connected: isConnected() }); + }); + + const thumbCache = new Map(); + const THUMB_CACHE_TTL = 30 * 60 * 1000; + + app.get("/api/gallery/thumb", async (req, res) => { + const src = req.query.src as string; + if (!src) return res.status(400).send("Missing src"); + + const cached = thumbCache.get(src); + if (cached && Date.now() - cached.timestamp < THUMB_CACHE_TTL) { + res.set("Content-Type", "image/jpeg"); + res.set("Cache-Control", "public, max-age=1800"); + return res.send(cached.data); + } + + try { + const resp = await fetch(src); + if (!resp.ok) return res.status(502).send("Upstream error"); + + const arrayBuf = await resp.arrayBuffer(); + const fullBuf = Buffer.from(arrayBuf); + + const sharp = (await import("sharp")).default; + const thumbBuf = await sharp(fullBuf) + .resize(400, 400, { fit: "cover", position: "attention" }) + .jpeg({ quality: 70 }) + .toBuffer(); + + thumbCache.set(src, { data: thumbBuf, timestamp: Date.now() }); + + if (thumbCache.size > 300) { + const oldest = [...thumbCache.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp); + for (let i = 0; i < 50; i++) thumbCache.delete(oldest[i][0]); + } + + res.set("Content-Type", "image/jpeg"); + res.set("Cache-Control", "public, max-age=1800"); + res.send(thumbBuf); + } catch (err: any) { + res.status(500).send(err.message); + } + }); + + app.post("/api/dropbox/refresh-gallery", async (_req, res) => { + try { + const images = await fetchGalleryFromDropbox(); + res.json({ count: images.length }); + } catch (err: any) { + res.status(500).json({ message: err.message }); + } + }); + + app.post("/api/gallery/migrate-to-cloudinary", async (_req, res) => { + try { + const result = await migrateGalleryToCloudinary(); + res.json(result); + } catch (err: any) { + res.status(500).json({ message: err.message }); + } + }); + + app.get("/api/gallery/focal-points", (_req, res) => { + try { + const fpPath = path.join(process.cwd(), "server/gallery-focal-points.json"); + if (fs.existsSync(fpPath)) { + res.json(JSON.parse(fs.readFileSync(fpPath, "utf-8"))); + } else { + res.json({}); + } + } catch { + res.json({}); + } + }); + + app.put("/api/gallery/focal-points/:fileName", (req, res) => { + try { + const { fileName } = req.params; + const { x, y } = req.body; + if (typeof x !== "number" || typeof y !== "number") { + return res.status(400).json({ message: "x and y must be numbers (0-100)" }); + } + const fpPath = path.join(process.cwd(), "server/gallery-focal-points.json"); + let data: Record = {}; + if (fs.existsSync(fpPath)) { + data = JSON.parse(fs.readFileSync(fpPath, "utf-8")); + } + data[fileName] = { x: Math.round(x), y: Math.round(y) }; + fs.writeFileSync(fpPath, JSON.stringify(data, null, 2)); + res.json({ ok: true, fileName, x: data[fileName].x, y: data[fileName].y }); + } catch (err: any) { + res.status(500).json({ message: err.message }); + } + }); + + app.delete("/api/gallery/focal-points/:fileName", (req, res) => { + try { + const { fileName } = req.params; + const fpPath = path.join(process.cwd(), "server/gallery-focal-points.json"); + let data: Record = {}; + if (fs.existsSync(fpPath)) { + data = JSON.parse(fs.readFileSync(fpPath, "utf-8")); + } + delete data[fileName]; + fs.writeFileSync(fpPath, JSON.stringify(data, null, 2)); + res.json({ ok: true }); + } catch (err: any) { + res.status(500).json({ message: err.message }); + } + }); + + app.get("/api/admin/cloudinary-gallery", async (_req, res) => { + try { + const images = await listCloudinaryGalleryDetailed(); + const aPath = path.join(process.cwd(), "server/gallery-artist-overrides.json"); + let artistOverrides: Record = {}; + if (fs.existsSync(aPath)) { + artistOverrides = JSON.parse(fs.readFileSync(aPath, "utf-8")); + } + const result = images.map((img) => { + const overriddenArtist = artistOverrides[img.fileName]; + return { + ...img, + artist: overriddenArtist || img.artist, + artistOverridden: !!overriddenArtist, + }; + }); + res.json(result); + } catch (err: any) { + res.status(500).json({ message: err.message }); + } + }); + + app.post("/api/admin/cloudinary-gallery/delete", async (req, res) => { + try { + const { publicId } = req.body; + if (!publicId) { + return res.status(400).json({ message: "publicId required" }); + } + const success = await deleteFromCloudinary(publicId); + if (!success) { + return res.status(500).json({ message: "Löschen fehlgeschlagen" }); + } + + const mapPath = path.join(process.cwd(), "server/cloudinary-gallery-map.json"); + if (fs.existsSync(mapPath)) { + const map = JSON.parse(fs.readFileSync(mapPath, "utf-8")); + for (const [key, val] of Object.entries(map)) { + if (val === publicId) { + delete map[key]; + } + } + fs.writeFileSync(mapPath, JSON.stringify(map, null, 2)); + } + + res.json({ ok: true, deleted: publicId }); + } catch (err: any) { + res.status(500).json({ message: err.message }); + } + }); + + app.post("/api/contact", async (req, res) => { + try { + const { name, email, subject, message } = req.body; + if (!name || !email || !message) { + return res.status(400).json({ message: "Name, E-Mail und Nachricht sind erforderlich." }); + } + const contactPath = path.join(process.cwd(), "server/contact-messages.json"); + let messages: any[] = []; + if (fs.existsSync(contactPath)) { + messages = JSON.parse(fs.readFileSync(contactPath, "utf-8")); + } + const entry = { + id: Date.now(), + name, + email, + subject: subject || "Keine Angabe", + message, + createdAt: new Date().toISOString(), + read: false, + }; + messages.push(entry); + fs.writeFileSync(contactPath, JSON.stringify(messages, null, 2)); + + const emailSent = await sendContactEmail({ name, email, subject: subject || "", message }); + console.log(`[contact] New message from ${name} <${email}>: ${subject || "No subject"} (email ${emailSent ? "sent" : "failed"})`); + res.json({ ok: true }); + } catch (err: any) { + res.status(500).json({ message: err.message }); + } + }); + + const vapidPublic = process.env.VAPID_PUBLIC_KEY || ""; + const vapidPrivate = process.env.VAPID_PRIVATE_KEY || ""; + if (vapidPublic && vapidPrivate) { + webpush.setVapidDetails("mailto:office@folx.tv", vapidPublic, vapidPrivate); + } + + app.get("/api/push/vapid-key", (_req, res) => { + res.json({ publicKey: vapidPublic }); + }); + + app.post("/api/push/subscribe", async (req, res) => { + try { + const { endpoint, keys } = req.body; + if (!endpoint || !keys?.p256dh || !keys?.auth) { + return res.status(400).json({ message: "Ungültige Subscription-Daten." }); + } + await storage.savePushSubscription({ endpoint, p256dh: keys.p256dh, auth: keys.auth }); + res.json({ ok: true }); + } catch (err: any) { + res.status(500).json({ message: err.message }); + } + }); + + app.post("/api/push/unsubscribe", async (req, res) => { + try { + const { endpoint } = req.body; + if (!endpoint) return res.status(400).json({ message: "Endpoint fehlt." }); + await storage.deletePushSubscription(endpoint); + res.json({ ok: true }); + } catch (err: any) { + res.status(500).json({ message: err.message }); + } + }); + + app.get("/api/push/count", async (_req, res) => { + try { + const count = await storage.getPushSubscriptionCount(); + res.json({ count }); + } catch { + res.json({ count: 0 }); + } + }); + + app.post("/api/admin/push/send", async (req, res) => { + try { + const { title, body, url } = req.body; + if (!title || !body) return res.status(400).json({ message: "Titel und Nachricht sind erforderlich." }); + if (typeof title !== "string" || title.length > 200) return res.status(400).json({ message: "Titel ungültig." }); + if (typeof body !== "string" || body.length > 500) return res.status(400).json({ message: "Nachricht ungültig." }); + let safeUrl = "/"; + if (url && typeof url === "string" && url.startsWith("/") && !url.startsWith("//")) { + safeUrl = url; + } + const subs = await storage.getAllPushSubscriptions(); + const payload = JSON.stringify({ title, body, url: safeUrl }); + let sent = 0; + let failed = 0; + for (const sub of subs) { + try { + await webpush.sendNotification( + { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, + payload + ); + sent++; + } catch (err: any) { + if (err.statusCode === 410 || err.statusCode === 404) { + await storage.deletePushSubscription(sub.endpoint); + } + failed++; + } + } + console.log(`[push] Sent to ${sent}, failed ${failed} of ${subs.length} subscribers`); + res.json({ ok: true, sent, failed, total: subs.length }); + } catch (err: any) { + res.status(500).json({ message: err.message }); + } + }); + + app.get("/api/gallery/artists", (_req, res) => { + try { + const aPath = path.join(process.cwd(), "server/gallery-artist-overrides.json"); + if (fs.existsSync(aPath)) { + res.json(JSON.parse(fs.readFileSync(aPath, "utf-8"))); + } else { + res.json({}); + } + } catch { + res.json({}); + } + }); + + app.put("/api/gallery/artists/:fileName", (req, res) => { + try { + const { fileName } = req.params; + const { artist } = req.body; + if (typeof artist !== "string") { + return res.status(400).json({ message: "artist must be a string" }); + } + const aPath = path.join(process.cwd(), "server/gallery-artist-overrides.json"); + let data: Record = {}; + if (fs.existsSync(aPath)) { + data = JSON.parse(fs.readFileSync(aPath, "utf-8")); + } + if (artist.trim() === "") { + delete data[fileName]; + } else { + data[fileName] = artist.trim(); + } + fs.writeFileSync(aPath, JSON.stringify(data, null, 2)); + res.json({ ok: true, fileName, artist: artist.trim() }); + } catch (err: any) { + res.status(500).json({ message: err.message }); + } + }); + + // Gallery API - serves optimized photos from Dropbox/Cloudinary + app.get("/api/gallery", async (req, res) => { + try { + let data: any[] = []; + + if (isConnected()) { + data = await fetchGalleryFromDropbox(); + } + + if (data.length === 0) { + const galleryPath = path.join(process.cwd(), "server/gallery-data.json"); + data = JSON.parse(fs.readFileSync(galleryPath, "utf-8")); + } + + const aPath = path.join(process.cwd(), "server/gallery-artist-overrides.json"); + let artistOverrides: Record = {}; + if (fs.existsSync(aPath)) { + artistOverrides = JSON.parse(fs.readFileSync(aPath, "utf-8")); + } + data = data.map((img: any) => { + if (artistOverrides[img.fileName]) { + return { ...img, artist: artistOverrides[img.fileName] }; + } + return img; + }); + + const shuffled = [...data]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + const limit = parseInt(req.query.limit as string) || 0; + res.json(limit > 0 ? shuffled.slice(0, limit) : shuffled); + } catch (err: any) { + res.json([]); + } + }); + + // Horoscope API + app.post("/api/horoscopes/images", requireApiKey, async (req, res) => { + try { + const { dateStr, images } = req.body || {}; + if (!dateStr || typeof images !== "object" || images === null) { + return res.status(400).json({ message: "dateStr und images sind erforderlich" }); + } + const updated = await setHoroscopeImages(dateStr, images); + res.json({ updated }); + } catch (err: any) { + res.status(500).json({ message: err.message }); + } + }); + + app.get("/api/recipes", async (_req, res) => { + try { + res.json(await listRecipes()); + } catch (err: any) { + res.status(500).json({ message: err.message }); + } + }); + + app.get("/api/recipes/today", async (_req, res) => { + try { + const recipe = await getRecipeForToday(); + res.json(recipe); + } catch (err: any) { + res.status(500).json({ message: err.message }); + } + }); + + app.post("/api/recipes", requireApiKey, async (req, res) => { + try { + const { name } = req.body || {}; + if (!name) return res.status(400).json({ message: "name ist erforderlich" }); + const row = await upsertRecipe(req.body); + res.json(row); + } catch (err: any) { + res.status(500).json({ message: err.message }); + } + }); + + app.get("/api/horoscopes/today", async (_req, res) => { + try { + const horoscopes = await getHoroscopesForToday(); + res.json(horoscopes); + } catch (err: any) { + res.status(500).json({ message: err.message }); + } + }); + + app.get("/api/horoscopes/sign/:index", async (req, res) => { + try { + const signIndex = parseInt(req.params.index); + if (isNaN(signIndex) || signIndex < 0 || signIndex > 11) { + return res.status(400).json({ message: "Invalid sign index" }); + } + const horoscope = await getOrGenerateHoroscope(signIndex); + res.json(horoscope); + } catch (err: any) { + res.status(500).json({ message: err.message }); + } + }); + + app.post("/api/horoscopes/generate", async (_req, res) => { + try { + await generateDailyHoroscopes(); + const horoscopes = await getHoroscopesForToday(); + res.json({ generated: horoscopes.length, horoscopes }); + } catch (err: any) { + res.status(500).json({ message: err.message }); + } + }); + + function parseRssItems(xml: string, maxAgeDays?: number): { title: string; link: string; source: string; pubDate: string }[] { + const channelTitle = xml.match(/[\s\S]*?(.*?)<\/title>/)?.[1]?.replace(/<!\[CDATA\[|\]\]>/g, "").trim() || ""; + const items: { title: string; link: string; source: string; pubDate: string }[] = []; + const itemRegex = /<item>([\s\S]*?)<\/item>/g; + let match; + while ((match = itemRegex.exec(xml)) !== null && items.length < 15) { + const block = match[1]; + const title = block.match(/<title>(.*?)<\/title>/)?.[1]?.replace(/<!\[CDATA\[|\]\]>/g, "").trim() || ""; + const link = block.match(/<link>(.*?)<\/link>/)?.[1]?.trim() || ""; + let source = block.match(/<source[^>]*>(.*?)<\/source>/)?.[1]?.replace(/<!\[CDATA\[|\]\]>/g, "").trim() || ""; + if (!source && block.includes("<dc:creator>")) { + source = block.match(/<dc:creator>(.*?)<\/dc:creator>/)?.[1]?.replace(/<!\[CDATA\[|\]\]>/g, "").trim() || ""; + } + if (!source) { + try { source = new URL(link).hostname.replace("www.", "").split(".")[0]; source = source.charAt(0).toUpperCase() + source.slice(1); } catch {} + } + if (!source) source = channelTitle; + const pubDateRaw = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1] || block.match(/<dc:date>(.*?)<\/dc:date>/)?.[1] || ""; + let pubDate = ""; + try { + const d = new Date(pubDateRaw); + if (isNaN(d.getTime())) continue; + if (maxAgeDays !== undefined) { + const ageMs = Date.now() - d.getTime(); + if (ageMs > maxAgeDays * 24 * 3600000) continue; + } + const diffH = Math.floor((Date.now() - d.getTime()) / 3600000); + if (diffH < 1) pubDate = "Gerade eben"; + else if (diffH < 24) pubDate = `vor ${diffH} Std.`; + else pubDate = `vor ${Math.floor(diffH / 24)} T.`; + } catch { pubDate = ""; } + if (title && link) items.push({ title, link, source, pubDate }); + } + return items; + } + + async function fetchRssFeed(rssUrl: string): Promise<string> { + const resp = await fetch(rssUrl, { + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "de-DE,de;q=0.9,en;q=0.5", + "Cache-Control": "no-cache", + }, + redirect: "follow", + }); + if (!resp.ok) throw new Error(`RSS fetch failed: ${resp.status}`); + return await resp.text(); + } + + app.get("/api/news-feed", async (_req, res) => { + const cacheKey = "news-feed"; + const cached = getCached<any[]>(cacheKey, 15 * 60 * 1000); + if (cached) return res.json(cached); + + try { + const musicFeeds = [ + "https://www.merkur.de/kultur/musik/rssfeed.rdf", + "https://www.stern.de/feed/standard/kultur/", + "https://www.spiegel.de/kultur/index.rss", + "https://rss.orf.at/oesterreich.xml", + ]; + let allItems: { title: string; link: string; source: string; pubDate: string }[] = []; + const results = await Promise.allSettled( + musicFeeds.map(async (url) => { + try { + const xml = await fetchRssFeed(url); + return parseRssItems(xml, 14); + } catch { return []; } + }) + ); + for (const r of results) { + if (r.status === "fulfilled") allItems.push(...r.value); + } + const musikKeywords = /musik|schlager|volksmusik|oberkrainer|konzert|sänger|sängerin|lied|album|hit|chor|festival|bühne|oper|melodie|band|pop|rock|charts|song|tour|tournee|rapper|hip.?hop|grammy|award|karneval|fasching|wiesn|oktoberfest|helene.fischer|andrea.berg|florian.silbereisen|kulturnacht|open.air/i; + let filtered = allItems.filter(i => musikKeywords.test(i.title)); + if (filtered.length < 3) filtered = allItems.slice(0, 10); + filtered = filtered.slice(0, 10); + if (filtered.length > 0) setCache(cacheKey, filtered); + res.json(filtered); + } catch (err: any) { + console.log("[news-feed] RSS fetch error:", err.message); + const stale = getStale<any[]>(cacheKey); + res.json(stale || []); + } + }); + + app.get("/api/breaking-news", async (_req, res) => { + const cacheKey = "breaking-news"; + const cached = getCached<any[]>(cacheKey, 15 * 60 * 1000); + if (cached) return res.json(cached); + + try { + const newsFeeds = [ + "https://www.tagesschau.de/xml/rss2_https/", + "https://www.n-tv.de/rss", + "https://rss.orf.at/oesterreich.xml", + "https://www.welt.de/feeds/latest.rss", + ]; + let allItems: { title: string; link: string; source: string; pubDate: string }[] = []; + const results = await Promise.allSettled( + newsFeeds.map(async (url) => { + try { + const xml = await fetchRssFeed(url); + return parseRssItems(xml, 1); + } catch { return []; } + }) + ); + for (const r of results) { + if (r.status === "fulfilled") allItems.push(...r.value); + } + allItems = allItems.slice(0, 10); + if (allItems.length > 0) setCache(cacheKey, allItems); + res.json(allItems); + } catch (err: any) { + console.log("[breaking-news] RSS fetch error:", err.message); + const stale = getStale<any[]>(cacheKey); + res.json(stale || []); + } + }); + + const ZODIAC_SIGNS = [ + "widder", "stier", "zwillinge", "krebs", "loewe", "jungfrau", + "waage", "skorpion", "schuetze", "steinbock", "wassermann", "fische" + ]; + + app.get("/sitemap.xml", async (_req, res) => { + try { + const baseUrl = "https://folx.tv"; + const today = new Date().toISOString().split("T")[0]; + const articles = await storage.getArticles(); + const categories = [...new Set(articles.map(a => a.category).filter(Boolean))]; + + const staticPages = [ + { loc: "/", priority: "1.0", changefreq: "daily" }, + { loc: "/videos", priority: "0.8", changefreq: "daily" }, + { loc: "/gallery", priority: "0.7", changefreq: "weekly" }, + { loc: "/horoskop", priority: "0.7", changefreq: "daily" }, + { loc: "/rezepte", priority: "0.6", changefreq: "monthly" }, + { loc: "/kontakt", priority: "0.5", changefreq: "yearly" }, + { loc: "/empfang-folx-tv", priority: "0.5", changefreq: "monthly" }, + { loc: "/ueber-uns", priority: "0.4", changefreq: "yearly" }, + { loc: "/impressum", priority: "0.3", changefreq: "yearly" }, + { loc: "/datenschutz", priority: "0.3", changefreq: "yearly" }, + ]; + + let xml = `<?xml version="1.0" encoding="UTF-8"?> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> +`; + for (const page of staticPages) { + xml += ` <url> + <loc>${baseUrl}${page.loc}</loc> + <lastmod>${today}</lastmod> + <changefreq>${page.changefreq}</changefreq> + <priority>${page.priority}</priority> + </url> +`; + } + + for (const cat of categories) { + xml += ` <url> + <loc>${baseUrl}/category/${encodeURIComponent(cat)}</loc> + <lastmod>${today}</lastmod> + <changefreq>daily</changefreq> + <priority>0.6</priority> + </url> +`; + } + + for (const sign of ZODIAC_SIGNS) { + xml += ` <url> + <loc>${baseUrl}/horoskop/${sign}</loc> + <lastmod>${today}</lastmod> + <changefreq>daily</changefreq> + <priority>0.5</priority> + </url> +`; + } + + for (const article of articles) { + const lastmod = article.publishedAt + ? new Date(article.publishedAt).toISOString().split("T")[0] + : today; + xml += ` <url> + <loc>${baseUrl}/article/${encodeURIComponent(article.slug)}</loc> + <lastmod>${lastmod}</lastmod> + <changefreq>monthly</changefreq> + <priority>0.7</priority> + </url> +`; + } + + xml += `</urlset>`; + + res.set("Content-Type", "application/xml"); + res.set("Cache-Control", "public, max-age=3600"); + res.send(xml); + } catch (err) { + res.status(500).send("Error generating sitemap"); + } + }); + + app.get("/robots.txt", (_req, res) => { + const robots = `User-agent: * +Allow: / +Disallow: /api/ +Disallow: /search +Disallow: /admin/ + +Sitemap: https://folx.tv/sitemap.xml +`; + res.set("Content-Type", "text/plain"); + res.set("Cache-Control", "public, max-age=86400"); + res.send(robots); + }); + + return httpServer; +} diff --git a/server/static.ts b/server/static.ts index abccaad..d40b38f 100644 --- a/server/static.ts +++ b/server/static.ts @@ -80,6 +80,86 @@ export function serveStatic(app: Express) { 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', + ].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.previewS / 60), secs = v.previewS % 60; + const videoLd = { + "@context": "https://schema.org", + "@type": "VideoObject", + "name": `${v.shortTitle} \u2013 Vorschau`, + "description": desc, + "thumbnailUrl": [poster], + "uploadDate": v.publishedAt, + "duration": `PT${mins}M${secs}S`, + "contentUrl": `${canonicalBase}${v.previewUrl}`, + "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 = [ + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + `${escapeHtml(v.shortTitle)} | ${escapeHtml(v.rubrik)} | Folx Music Television`, + ``, + ].join("\n "); + template = template.replace(/[^<]*<\/title>/, tags); + + const chapterHtml = (v.chapters || []).length + ? `<h2>Kapitel</h2><ul>${v.chapters.map((c: any) => `<li>${escapeHtml(c.t)} – ${escapeHtml(c.label)}</li>`).join("")}</ul>` + : ""; + const ssr = [ + '<article class="ssr-content">', + `<h1>${escapeHtml(v.shortTitle)}</h1>`, + `<video controls playsinline preload="metadata" poster="${escapeHtml(v.posterUrl)}" style="max-width:100%;height:auto;" src="${escapeHtml(v.previewUrl)}"></video>`, + `<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 { @@ -318,7 +398,7 @@ export function serveStatic(app: Express) { // z naslovnico). Seznam mora ustrezati potem v client/src/App.tsx. const KNOWN = [ /^\/$/, /^\/search\/?$/, /^\/article\//, /^\/category\//, /^\/aktuelles\/?$/, - /^\/videos\/?$/, /^\/gallery\/?$/, /^\/horoskop(\/|$)/, /^\/rezepte\/?$/, + /^\/videos\/?$/, /^\/video\/[^/]+\/?$/, /^\/gallery\/?$/, /^\/horoskop(\/|$)/, /^\/rezepte\/?$/, /^\/empfang-folx-tv\/?$/, /^\/ueber-uns\/?$/, /^\/impressum\/?$/, /^\/datenschutz\/?$/, /^\/kontakt\/?$/, /^\/admin(\/|$)/, ]; diff --git a/server/static.ts.bak b/server/static.ts.bak new file mode 100644 index 0000000..abccaad --- /dev/null +++ b/server/static.ts.bak @@ -0,0 +1,343 @@ +import express, { type Express } from "express"; +import fs from "fs"; +import path from "path"; +import { storage } from "./storage"; + +function escapeHtml(str: string): string { + return str.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">"); +} + +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}`; + + 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`; + + 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)}" />`, + `<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 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`, + jsonLdTag, + videoLdTag, + ].join("\n "); + + template = template.replace(/[^<]*<\/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', + ].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 = [ + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + `${escapeHtml(galTitle)}`, + ].join("\n "); + + template = template.replace(/[^<]*<\/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)}`, + ].join("\n "); + + template = template.replace(/[^<]*<\/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\/?$/, /^\/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', + '', + ].join("\n ") + ); + res.status(404).set({ "Content-Type": "text/html" }).end(template); + return; + } + res.status(200).set({ "Content-Type": "text/html" }).end(template); + }); +}