plausible-pageviews-api/main.js
2024-10-21 15:13:13 +02:00

198 lines
5.8 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 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}`),
);