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, getCosmicEventsForToday, generateCosmicEvents } from "./horoscope-generator"; 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(); storage.getArticles().then((articles) => { analyzeAllArticleImages(articles).catch(err => console.log("[focal-point] Analysis skipped:", err.message) ); }); // ---- SEO: robots.txt, sitemap.xml, news sitemap, RSS ---- 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") ); }); app.get("/sitemap.xml", async (_req, res) => { try { const articles = await storage.getArticles(); const staticPaths = ["/", "/news", "/video", "/galerie", "/horoskop", "/rezepte", "/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) { 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) => { 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); }); 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) }); }); app.post("/api/articles", 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})` }); } const article = await storage.createArticle(parsed.data); res.status(201).json(article); }); app.patch("/api/articles/:id", 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 article = await storage.updateArticle(id, partial.data); if (!article) { return res.status(404).json({ message: "Article not found" }); } res.json(article); }); app.delete("/api/articles/:id", async (req, res) => { const id = parseInt(req.params.id); await storage.deleteArticle(id); res.status(204).send(); }); app.post("/api/upload", 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.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 }); } }); // Cosmic events API app.get("/api/cosmic-events", async (_req, res) => { try { const events = await getCosmicEventsForToday(); res.json(events); } catch (err: any) { res.status(500).json({ message: err.message }); } }); app.post("/api/cosmic-events/generate", async (_req, res) => { try { await generateCosmicEvents(); const events = await getCosmicEventsForToday(); res.json({ generated: events.length, events }); } 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; }