folx-tv/server/routes.ts

1090 lines
42 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, 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<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();
// ---- 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, "&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 = ["/", "/aktuelles", "/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) {
if (!isOwn(a)) continue;
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) => {
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 ` <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);
});
// 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<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.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(/<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;
}