const express = require("express"); const axios = require("axios"); const helmet = require("helmet"); const rateLimit = require("express-rate-limit"); const cors = require("cors"); const { check, validationResult } = require("express-validator"); const promClient = require("prom-client"); require("dotenv").config(); // Main application const app = express(); // Metrics application const metricsApp = express(); // Prometheus metrics const register = new promClient.Registry(); promClient.collectDefaultMetrics({ register }); const httpRequestDurationMicroseconds = new promClient.Histogram({ name: "http_request_duration_seconds", help: "Duration of HTTP requests in seconds", labelNames: ["method", "route", "code"], buckets: [0.1, 0.3, 0.5, 0.7, 1, 3, 5, 7, 10], }); register.registerMetric(httpRequestDurationMicroseconds); // Helmet configuration app.use( helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "'unsafe-inline'"], styleSrc: ["'self'", "'unsafe-inline'"], imgSrc: ["'self'", "data:", "https:"], }, }, referrerPolicy: { policy: "strict-origin-when-cross-origin", }, hsts: { maxAge: 31536000, includeSubDomains: true, preload: true, }, }), ); // Parse the ALLOWED_ORIGINS environment variable const allowedOrigins = process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(",") : ["http://localhost:3000"]; // CORS configuration app.use( cors({ origin: function (origin, callback) { // Allow requests with no origin (like mobile apps or curl requests) if (!origin) return callback(null, true); if (allowedOrigins.indexOf(origin) === -1) { var msg = "The CORS policy for this site does not allow access from the specified Origin."; return callback(new Error(msg), false); } return callback(null, true); }, methods: ["GET"], }), ); // Rate limiting const NUM_PROXY_TRUST = parseInt(process.env.NUM_PROXY_TRUST, 10) || 1; if (NUM_PROXY_TRUST > 0) { app.set("trust proxy", NUM_PROXY_TRUST); } else { // Do not set proxy trust if NUM_PROXY_TRUST is 0 return; } const RATE_LIMIT_MINUTES = parseInt(process.env.RATE_LIMIT_MINUTES, 10) || 15; const RATE_LIMIT_REQUESTS = parseInt(process.env.RATE_LIMIT_REQUESTS, 10) || 100; const limiter = rateLimit({ windowMs: RATE_LIMIT_MINUTES * 60 * 1000, max: RATE_LIMIT_REQUESTS, message: "Too many requests from this IP, please try again later.", }); app.use(limiter); const PLAUSIBLE_API_KEY = process.env.PLAUSIBLE_API_KEY; const PLAUSIBLE_DOMAIN = process.env.PLAUSIBLE_DOMAIN || "analytics.lunivity.com"; // Prometheus metrics endpoint (on separate app) metricsApp.get("/metrics", async (req, res) => { res.set("Content-Type", register.contentType); res.end(await register.metrics()); }); // Visitors app.get( "/api/visitors", [check("domain").notEmpty().isString(), check("path").optional().isString()], async (req, res) => { const end = httpRequestDurationMicroseconds.startTimer(); // Input validation const errors = validationResult(req); if (!errors.isEmpty()) { end({ method: req.method, route: req.path, code: 400 }); return res.status(400).json({ errors: errors.array() }); } let path = req.query.path || "/"; const siteDomain = req.query.domain; // Ensure the path starts with a slash if (!path.startsWith("/")) { path = "/" + path; } const apiUrl = `https://${PLAUSIBLE_DOMAIN}/api/v1/stats/aggregate`; try { const response = await axios.get(apiUrl, { headers: { Authorization: `Bearer ${PLAUSIBLE_API_KEY}`, }, params: { site_id: siteDomain, period: "custom", date: `2000-01-01,${new Date().toISOString().split("T")[0]}`, filters: `event:page==${path}`, metrics: "visitors", }, }); const visitors = response.data.results.visitors.value; end({ method: req.method, route: req.path, code: 200 }); res.json({ visitors }); } catch (error) { console.error("Error fetching page views:", error); end({ method: req.method, route: req.path, code: 500 }); res.status(500).json({ error: "Unable to fetch page views" }); } }, ); // Pageviews app.get( "/api/pageviews", [check("domain").notEmpty().isString(), check("path").optional().isString()], async (req, res) => { const end = httpRequestDurationMicroseconds.startTimer(); // Input validation const errors = validationResult(req); if (!errors.isEmpty()) { end({ method: req.method, route: req.path, code: 400 }); return res.status(400).json({ errors: errors.array() }); } let path = req.query.path || "/"; const siteDomain = req.query.domain; // Ensure the path starts with a slash if (!path.startsWith("/")) { path = "/" + path; } const apiUrl = `https://${PLAUSIBLE_DOMAIN}/api/v1/stats/aggregate`; try { const response = await axios.get(apiUrl, { headers: { Authorization: `Bearer ${PLAUSIBLE_API_KEY}`, }, params: { site_id: siteDomain, period: "custom", date: `2000-01-01,${new Date().toISOString().split("T")[0]}`, filters: `event:page==${path}`, metrics: "pageviews", }, }); const pageviews = response.data.results.pageviews.value; end({ method: req.method, route: req.path, code: 200 }); res.json({ pageviews }); } catch (error) { console.error("Error fetching pageviews:", error); end({ method: req.method, route: req.path, code: 500 }); res.status(500).json({ error: "Unable to fetch pageviews" }); } }, ); const PORT = process.env.PORT || 3000; const METRICS_PORT = process.env.METRICS_PORT || 9100; app.listen(PORT, () => console.log(`Main server running on port ${PORT}`)); metricsApp.listen(METRICS_PORT, () => console.log(`Metrics server running on port ${METRICS_PORT}`), );