Sangelo
97b4150a6a
All checks were successful
Build and Push Docker Image / docker-build (push) Successful in 11s
208 lines
6 KiB
JavaScript
208 lines
6 KiB
JavaScript
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}`),
|
|
);
|