1005 lines
38 KiB
TypeScript
1005 lines
38 KiB
TypeScript
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, "&")
|
||
.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(` <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;
|
||
}
|