folx-tv/server/routes.ts

1005 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 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<string, { data: any; timestamp: number }>();
function getCached<T>(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<T>(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<Server> {
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
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(` <url>\n <loc>${SITE}${p}</loc>\n <changefreq>daily</changefreq>\n <priority>${p === "/" ? "1.0" : "0.7"}</priority>\n </url>`);
}
for (const a of articles) {
const lastmod = a.publishedAt ? new Date(a.publishedAt as any).toISOString() : new Date().toISOString();
urls.push(` <url>\n <loc>${SITE}/article/${xmlEscape(a.slug)}</loc>\n <lastmod>${lastmod}</lastmod>\n <changefreq>weekly</changefreq>\n <priority>0.8</priority>\n </url>`);
}
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join("\n")}\n</urlset>`;
res.type("application/xml").send(xml);
} catch (e) {
res.status(500).type("application/xml").send('<?xml version="1.0"?><urlset/>');
}
});
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 ` <url>\n <loc>${SITE}/article/${xmlEscape(a.slug)}</loc>\n <news:news>\n <news:publication>\n <news:name>Folx Music Television</news:name>\n <news:language>de</news:language>\n </news:publication>\n <news:publication_date>${pub}</news:publication_date>\n <news:title>${xmlEscape(a.title)}</news:title>\n </news:news>\n </url>`;
});
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">\n${urls.join("\n")}\n</urlset>`;
res.type("application/xml").send(xml);
} catch (e) {
res.status(500).type("application/xml").send('<?xml version="1.0"?><urlset/>');
}
});
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 <enclosure url="${xmlEscape(img.replace(/\.webp$/, ".jpg"))}" type="image/jpeg" />` : "";
return ` <item>\n <title>${xmlEscape(a.title)}</title>\n <link>${link}</link>\n <guid isPermaLink="true">${link}</guid>\n <pubDate>${pub}</pubDate>\n <category>${xmlEscape(a.category || "News")}</category>\n <description>${xmlEscape(a.excerpt || "")}</description>${enclosure}\n </item>`;
});
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">\n <channel>\n <title>Folx Music Television News</title>\n <link>${SITE}</link>\n <atom:link href="${SITE}/rss.xml" rel="self" type="application/rss+xml" />\n <description>Aktuelle Nachrichten aus der Welt der Volksmusik und des Schlagers.</description>\n <language>de-DE</language>\n <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>\n${items.join("\n")}\n </channel>\n</rss>`;
res.type("application/rss+xml").send(xml);
} catch (e) {
res.status(500).type("application/rss+xml").send('<?xml version="1.0"?><rss/>');
}
});
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<any> {
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<any>(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<any>(`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("<html><body style='background:#111;color:#fff;font-family:sans-serif;text-align:center;padding:60px'><h1>Dropbox povezan!</h1><p>Lahko zaprete to stran.</p></body></html>");
} 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<string, { data: Buffer; timestamp: number }>();
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<string, { x: number; y: number }> = {};
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<string, { x: number; y: number }> = {};
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<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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(/<channel>[\s\S]*?<title>(.*?)<\/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;
}