Added user counter and analytics to the kst4contest page

This commit is contained in:
Marc Froehlich
2026-09-11 21:03:45 +02:00
parent 4ad1cf71dd
commit b23884bde1
23 changed files with 2863 additions and 7 deletions
+457
View File
@@ -0,0 +1,457 @@
# Server-side website statistics
This directory contains installation examples for the confirmed Ubuntu 24.04
server baseline and privacy-conscious traffic statistics. Nothing here
installs or activates the production service automatically.
The design has two separate outputs:
- private static GoAccess HTML and JSON reports for each registered project
subdomain and for all registered project subdomains combined;
- a small public `visitor-count.json` file for sites which explicitly enable
the counter.
The public number is the sum of daily approximate unique visits since the
configured activation date. GoAccess treats requests with the same IP address,
date and user agent as one visit. That is useful for a rough trend. It is not a
count of people.
## Data flow
```text
eligible page request
|
v
dedicated Nginx analytics log (14 days)
|
v
GoAccess with IP anonymisation
|
+--> private per-site and combined HTML/JSON reports (395 days)
|
v
durable daily counter state --> public visitor-count.json
```
Nginx writes a dedicated, reduced log. For each site, the generator gives the
existing, uncompressed `.1` rotation and then the current log directly to
GoAccess. GoAccess uses its persistent state to skip entries already
processed. The generator does not use an incremental shell pipeline or
decompress older rotations. Reports, database updates and public counter data
are first prepared in a staging directory. GoAccess output is validated
before any published report changes. The last valid report therefore survives
a failed GoAccess run.
The counter state is deliberately separate from the 395-day report database.
Each day is replaced with the latest value reported by GoAccess instead of
being added again. This makes repeated runs idempotent. Values older than 395
days remain in the counter state and continue to contribute to the public
total.
## Files
- `generate-reports.js` validates configuration and state, runs GoAccess and
publishes outputs atomically.
- `sites.example.json` is the registry template.
- `goaccess.conf.template` is rendered per report with a private database path
and the configured GeoIP2 Country database.
- `nginx/` contains the reduced log format, request filters, public endpoint
and protected report-vhost examples.
- `systemd/` contains a hardened oneshot service and hourly timer.
- `logrotate/` retains 14 daily analytics-log rotations.
## Prerequisites
- Node.js 18.19.1 or newer;
- GoAccess with GeoIP2/MMDB support;
- one GeoIP2 **Country** database, not a City database;
- Nginx;
- an unprivileged service account, shown as `hamradio-analytics` in the
examples.
No npm package is required by the generator. GoAccess is the only external
program it starts.
The production compatibility baseline is GoAccess 1.8.1 built with
`--enable-geoip=mmdb` and `--with-openssl`, but without `--with-zlib`. Zlib is
not required for the regular operating mode because it reads only
uncompressed files. The configuration check reports the detected build
features and explicitly accepts this combination.
The confirmed production baseline is Node.js 18.19.1. The generator and tests
must remain compatible with it; upgrading Node.js is not part of this setup.
The service account needs read access to the dedicated analytics logs and
`/var/lib/GeoIP/GeoLite2-Country.mmdb`. It needs write access only to its state,
report and public-output directories. Access is group-based. The setup does
not depend on ACLs or `setfacl`. Nginx receives read access to reports and the
public counter through the `www-data` group, but no write access.
## Installation and permissions
Do not trust Unix modes stored in a ZIP created on Windows. Install every
script, configuration and unit with an explicit owner, group and mode. The
following commands assume that the package has been unpacked into the current
directory. Create the dedicated, unprivileged service account once:
```sh
if ! getent passwd hamradio-analytics >/dev/null; then
sudo useradd --system --user-group --home-dir /nonexistent --no-create-home \
--shell /usr/sbin/nologin hamradio-analytics
fi
```
Then install the generator, configuration and units:
```sh
sudo install -d -o root -g hamradio-analytics -m 0750 \
/opt/hamradioonline-analytics /etc/hamradioonline-analytics
sudo install -o root -g hamradio-analytics -m 0750 \
website/ops/analytics/generate-reports.js \
/opt/hamradioonline-analytics/generate-reports.js
sudo install -o root -g hamradio-analytics -m 0640 \
website/ops/analytics/goaccess.conf.template \
/etc/hamradioonline-analytics/goaccess.conf.template
sudo install -o root -g hamradio-analytics -m 0640 \
website/ops/analytics/sites.example.json \
/etc/hamradioonline-analytics/sites.json
sudo install -o root -g root -m 0644 \
website/ops/analytics/systemd/hamradioonline-analytics.service.example \
/etc/systemd/system/hamradioonline-analytics.service
sudo install -o root -g root -m 0644 \
website/ops/analytics/systemd/hamradioonline-analytics.timer.example \
/etc/systemd/system/hamradioonline-analytics.timer
sudo install -o root -g root -m 0644 \
website/ops/analytics/logrotate/hamradioonline-analytics.example \
/etc/logrotate.d/hamradioonline-analytics
```
Create the writable tree deliberately. The state root is traversable but not
readable by Nginx. Only the report and public branches use the `www-data`
group and the set-group-ID bit:
```sh
sudo install -d -o hamradio-analytics -g hamradio-analytics -m 0711 \
/var/lib/hamradioonline-analytics
sudo install -d -o hamradio-analytics -g hamradio-analytics -m 0750 \
/var/lib/hamradioonline-analytics/db
sudo install -d -o hamradio-analytics -g www-data -m 2750 \
/var/lib/hamradioonline-analytics/reports \
/var/lib/hamradioonline-analytics/reports/combined \
/var/lib/hamradioonline-analytics/reports/kst4contest \
/var/lib/hamradioonline-analytics/public \
/var/lib/hamradioonline-analytics/public/kst4contest
```
Generated HTML and JSON reports use mode `0640`. The public
`visitor-count.json` uses `0644`. Private GoAccess databases and
`public-counter-state.json` remain owned by `hamradio-analytics` and unreadable
by Nginx. The service unit uses `StateDirectoryMode=0711` to retain this
boundary after systemd has prepared the state directory.
Create the analytics log only when it does not already exist. Running
`install /dev/null` unconditionally would empty an existing log:
```sh
if [ ! -e /var/log/nginx/kst4contest-analytics.log ]; then
sudo install -o www-data -g hamradio-analytics -m 0640 /dev/null \
/var/log/nginx/kst4contest-analytics.log
fi
sudo stat -c '%U:%G %a %n' /var/log/nginx/kst4contest-analytics.log
```
The resulting log owner and mode must be
`www-data:hamradio-analytics 640`. Logrotate preserves that ownership. The
generator's configuration check fails clearly if required output directories
are missing or if the executing user cannot read an analytics log or the
Country database.
## Registry
Copy `sites.example.json` outside the checkout and adjust it to the private
server layout. Each site entry contains:
- a stable `id` used for state and the GoAccess database;
- its exact `hostname`;
- the current, uncompressed `analyticsLog` path;
- the `activatedOn` date used by the public counter;
- a `publicCounter` switch;
- the private `reportOutputDirectory`;
- a `publicJsonPath` when the public counter is enabled.
The top-level `combined.reportOutputDirectory` receives the combined report.
Only registered sites are included. The generator rejects
`stats.hamradioonline.de`, so the report host cannot accidentally become part
of the project statistics.
To add another project subdomain later, add one registry entry and one matching
dedicated `access_log` line to its Nginx server block. Do not enable a public
counter unless that site should publish one.
Treat `activatedOn` as persistent data. Once counting has started, changing it
would change the meaning of the total. The generator refuses to combine a new
activation date with existing counter state.
The generator derives the optional `.1` path from `analyticsLog`. It is valid
for `.1` not to exist before the first rotation. Do not enter a rotation or a
compressed `.gz` file in the registry.
## Nginx logging
Install the log-format and filter maps from `nginx/` in the `http` context.
Then add a dedicated analytics `access_log` to every registered project server
block. Keep the existing operational access log unless its replacement has
been reviewed separately. If the operational log is inherited from the
`http` context, repeat its directive in the server block before adding the
analytics log; an `access_log` at a lower level changes inheritance.
Install Nginx snippets explicitly as `root:root` with mode `0644`; do not copy
the modes from the ZIP. Nginx `map` exact-string keys use the path itself, for
example `/visitor-count.json`, without the location-modifier prefix `=`.
```sh
sudo install -o root -g root -m 0644 \
website/ops/analytics/nginx/analytics-filters.conf.example \
/etc/nginx/snippets/hamradioonline-analytics-filters.conf
sudo install -o root -g root -m 0644 \
website/ops/analytics/nginx/analytics-log.conf.example \
/etc/nginx/conf.d/hamradioonline-analytics-log.conf
sudo install -o root -g root -m 0644 \
website/ops/analytics/nginx/public-counter.conf.example \
/etc/nginx/snippets/kst4contest-public-counter.conf
```
Prepare the public-counter include without enabling it in the active site yet.
Likewise, keep the statistics vhost disabled until the certificate bootstrap
step below.
The analytics format contains only:
- server name;
- client IP address;
- timestamp;
- method;
- normalized path without query string;
- protocol;
- status;
- transferred body size;
- user agent.
It does not contain a referrer, query string or authenticated user name. The
filter accepts only eligible page `GET` requests. It excludes the update feed,
public counter, sitemap, robots file, favicons, CSS, JavaScript, images, fonts,
source maps, manual assets and the listed monitoring paths. Known crawler user
agents are rejected before logging. GoAccess applies its own crawler list as a
second layer and treats unknown browsers or operating systems as crawlers.
Review the monitoring-path list against the real server before activation.
When a new health endpoint or asset family is added, update the filter first.
Test the complete Nginx configuration before reloading it:
```sh
sudo nginx -t
```
## GoAccess reports
The template enables IP anonymisation before persistent aggregation, ignores
crawlers, keeps 395 days, and uses a separate persistent database for every
site and the combined report. It leaves only the panels needed here: visits by
day, requested pages, countries, HTTP status codes and virtual hosts. Host,
remote-user, referrer, keyphrase, operating-system, browser and other detailed
panels are disabled.
The Country database is provided through the registry at
`/var/lib/GeoIP/GeoLite2-Country.mmdb`. A file whose name contains `City` is
rejected. Do not replace it with a City database merely because one happens to
be available.
The generator supplies `--persist`, conditionally supplies `--restore`, and
uses an isolated `--db-path` through the rendered template. It passes the
uncompressed `.1` rotation, when present, and then the current log as direct
GoAccess arguments. This chronological order also covers entries appended
shortly before rotation. GoAccess tracks the processed files in its persistent
state and processes only new entries on later runs. The first successful run
creates each database. Later runs copy the last valid database into staging,
restore it and persist the updated result only after all reports have
succeeded.
Logrotate must use `delaycompress`, as shown in the example. This leaves `.1`
uncompressed for one rotation cycle. Older `.gz` files are not part of the
regular hourly run, and importing them is a separate maintenance task outside
this repository workflow. Do not add an unstable decompression pipeline to
the timer service.
If the generator is unavailable for longer than the uncompressed rotation
window, the regular run cannot recover entries found only in older `.gz`
files. Preserve those files under the raw-log retention policy and plan any
necessary historical import separately before resuming normal processing.
## Checking and running
Validate paths, registry values, the template contract and GoAccess
availability without producing reports:
```sh
sudo -u hamradio-analytics /usr/bin/node \
/opt/hamradioonline-analytics/generate-reports.js \
--registry /etc/hamradioonline-analytics/sites.json \
--config-template /etc/hamradioonline-analytics/goaccess.conf.template \
--check
```
The check prints the detected GoAccess version and whether GeoIP2/MMDB,
OpenSSL and Zlib build options are present. Missing GeoIP2/MMDB support is a
configuration error with exit code 2. OpenSSL remains informational. Missing
Zlib is supported for this operating mode and does not make the check fail.
The same message explains that only the current log and optional uncompressed
`.1` are processed and that older `.gz` files are not imported.
Exercise the complete GoAccess and output-validation path without changing
published reports, databases or counter state:
```sh
sudo -u hamradio-analytics /usr/bin/node \
/opt/hamradioonline-analytics/generate-reports.js \
--registry /etc/hamradioonline-analytics/sites.json \
--config-template /etc/hamradioonline-analytics/goaccess.conf.template \
--dry-run
```
Run without either flag to publish. A lock prevents concurrent production
runs. A dry-run uses a temporary working directory and deliberately neither
needs nor creates the production lock below `/run`. Configuration errors use
exit code 2, an active production lock uses exit code 3, and generation or
publication errors use exit code 1.
Before the first production run, seed any earlier daily values which must be
preserved into `public-counter-state.json`. There is no honest way to recreate
history which is no longer present in the raw logs. Back up this state file: it
is the durable source for public totals older than the detailed retention
window.
## Scheduling and report access
Install the systemd files as local units after adapting paths and permissions.
The timer runs hourly, catches up after downtime and adds a small random delay.
The service has no network access and only the documented read/write paths.
If the installed GoAccess build unexpectedly requires network access, find the
reason before weakening that restriction; local log processing and a local
Country database do not require it.
The statistics vhost serves static files over HTTPS and protects the complete
host with HTTP Basic Authentication. This includes `/`, its redirect to
`/combined/`, and every individual report. Store the password file outside
this repository. Prepare it so only root and the Nginx group can access it:
```sh
sudo install -d -o root -g www-data -m 0750 /etc/nginx/htpasswd
if [ -e /etc/nginx/htpasswd/hamradioonline-analytics ]; then
sudo htpasswd /etc/nginx/htpasswd/hamradioonline-analytics stats-reader
else
sudo htpasswd -c /etc/nginx/htpasswd/hamradioonline-analytics stats-reader
fi
sudo chown root:www-data /etc/nginx/htpasswd/hamradioonline-analytics
sudo chmod 0640 /etc/nginx/htpasswd/hamradioonline-analytics
```
Choose the account name locally and enter the password interactively. Never
store the resulting password hash in this repository or the installation ZIP.
The example opens no GoAccess WebSocket and no additional GoAccess port. Its
own access log is disabled and responses use a private, no-store cache policy.
Do not activate the final HTTPS vhost before its certificate files exist.
First install the temporary HTTP bootstrap without changing the parallel apt
Certbot installation or either renewal timer:
```sh
sudo install -d -o root -g root -m 0755 /var/lib/letsencrypt
sudo install -o root -g root -m 0644 \
website/ops/analytics/nginx/stats-vhost-http-bootstrap.conf.example \
/etc/nginx/sites-available/stats.hamradioonline.de
if [ ! -e /etc/nginx/sites-enabled/stats.hamradioonline.de ] && \
[ ! -L /etc/nginx/sites-enabled/stats.hamradioonline.de ]; then
sudo ln -s /etc/nginx/sites-available/stats.hamradioonline.de \
/etc/nginx/sites-enabled/stats.hamradioonline.de
fi
sudo nginx -t
sudo systemctl reload nginx
sudo /snap/bin/certbot certonly --webroot \
--webroot-path /var/lib/letsencrypt \
-d stats.hamradioonline.de
```
Only after Certbot has created the certificate, replace the bootstrap with the
final vhost. This does not remove HTTP completely. The final file keeps an
IPv4 port 80 block for `/.well-known/acme-challenge/` so the certificate issued
with `--webroot` can be renewed automatically. Every other HTTP request is
redirected permanently to the same URI on HTTPS. The HTTPS block uses the
existing Ubuntu/Certbot TLS files
`/etc/letsencrypt/options-ssl-nginx.conf` and
`/etc/letsencrypt/ssl-dhparams.pem`:
```sh
sudo install -o root -g root -m 0644 \
website/ops/analytics/nginx/stats-vhost.conf.example \
/etc/nginx/sites-available/stats.hamradioonline.de
sudo nginx -t
sudo systemctl reload nginx
```
After activation, verify both authentication and renewal from the server:
```sh
curl -I https://stats.hamradioonline.de/
curl -I -u stats-reader https://stats.hamradioonline.de/
sudo /snap/bin/certbot renew --dry-run \
--cert-name stats.hamradioonline.de
```
The first HTTPS request must return `401`. The authenticated request must
return the redirect to `/combined/`. Enter the Basic Auth password
interactively; do not put it on the command line. The Certbot dry-run must
complete while the final vhost is active.
The templates listen on IPv4 only. Add an IPv6 listener later, after the AAAA
record for `stats.hamradioonline.de` has been confirmed and tested.
The public counter location serves only `visitor-count.json` through an exact
Nginx `alias`; it does not modify the deployed Eleventy release directory. Its
own access log is disabled. Responses use
`Cache-Control: public, max-age=3600` and
`X-Content-Type-Options: nosniff`. The file contains the schema version, total,
activation date and update time. It contains no IP address, user agent,
hostname or per-day detail.
Use this rollout order:
1. Install the server files with explicit modes. Prepare directories, log
ownership, filters and still-inactive Nginx and systemd configuration.
2. Push the website changes, including the Privacy Policy, and let the existing
deployment cron job publish them. Until the JSON endpoint exists, the
visitor count remains hidden automatically.
3. Verify the published Privacy Policy. Only then activate analytics logging,
the report generator and timer, the public counter location and the
protected statistics vhost.
4. Run `nginx -t` before every Nginx reload and perform the real `--check` and
`--dry-run` on the server before the first production generation.
A short period in which the Privacy Policy is already visible but logging is
not yet active is acceptable. Starting analytics logging before publishing the
updated policy is not.
## Retention and recovery
- Dedicated analytics raw logs: 14 days through the Logrotate example.
- Anonymised detailed GoAccess aggregates: rolling 395 days.
- Public daily counter values: retained from activation onward.
Back up the counter state and, if fast report recovery matters, the GoAccess
database directories. Reports themselves are derived output. To recover, stop
the timer, restore the state and database directories with their ownership,
run `--check`, then run `--dry-run` before publishing again.
The repository contains no password, password hash, MaxMind download key,
server IP address, TLS private key or private backup destination. Keep it that
way.
+874
View File
@@ -0,0 +1,874 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const STATE_SCHEMA_VERSION = 1;
const PUBLIC_SCHEMA_VERSION = 1;
const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER;
const RESERVED_STATS_HOST = "stats.hamradioonline.de";
class ConfigurationError extends Error {
constructor(message) {
super(message);
this.name = "ConfigurationError";
this.exitCode = 2;
}
}
class LockError extends Error {
constructor(message) {
super(message);
this.name = "LockError";
this.exitCode = 3;
}
}
function readJson(filePath, label) {
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch (error) {
throw new ConfigurationError(`${label} is not valid JSON: ${error.message}`);
}
return parsed;
}
function parseIsoDate(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year
|| date.getUTCMonth() !== month - 1
|| date.getUTCDate() !== day) {
return null;
}
return `${match[1]}-${match[2]}-${match[3]}`;
}
function requireAbsolutePath(value, label) {
if (typeof value !== "string" || /[\r\n\0]/.test(value) || !path.isAbsolute(value)) {
throw new ConfigurationError(`${label} must be an absolute path`);
}
return path.normalize(value);
}
function isWithin(parent, candidate) {
const relative = path.relative(parent, candidate);
return relative !== ""
&& relative !== ".."
&& !relative.startsWith(`..${path.sep}`)
&& !path.isAbsolute(relative);
}
function validateRegistry(registry) {
if (!registry || typeof registry !== "object" || Array.isArray(registry)) {
throw new ConfigurationError("registry must be an object");
}
if (registry.schemaVersion !== 1) {
throw new ConfigurationError("registry schemaVersion must be 1");
}
if (!Array.isArray(registry.sites) || registry.sites.length === 0) {
throw new ConfigurationError("registry.sites must contain at least one site");
}
const stateDirectory = requireAbsolutePath(
registry.stateDirectory,
"registry.stateDirectory"
);
const counterStatePath = requireAbsolutePath(
registry.counterStatePath,
"registry.counterStatePath"
);
if (!isWithin(stateDirectory, counterStatePath)) {
throw new ConfigurationError("counterStatePath must be below stateDirectory");
}
const ids = new Set();
const hostnames = new Set();
const analyticsLogPaths = new Set();
const outputDirectories = new Set();
const publicJsonPaths = new Set();
const sites = registry.sites.map((site, index) => {
const label = `registry.sites[${index}]`;
if (!site || typeof site !== "object" || Array.isArray(site)) {
throw new ConfigurationError(`${label} must be an object`);
}
if (typeof site.id !== "string" || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(site.id)) {
throw new ConfigurationError(`${label}.id is invalid`);
}
if (site.id === "combined") {
throw new ConfigurationError(`${label}.id is reserved`);
}
if (ids.has(site.id)) {
throw new ConfigurationError(`duplicate site id: ${site.id}`);
}
ids.add(site.id);
if (typeof site.hostname !== "string"
|| !/^[a-z0-9.-]+$/.test(site.hostname)
|| site.hostname.includes("..")) {
throw new ConfigurationError(`${label}.hostname is invalid`);
}
const hostname = site.hostname.toLowerCase();
if (hostname === RESERVED_STATS_HOST) {
throw new ConfigurationError(`${RESERVED_STATS_HOST} must not be registered`);
}
if (hostnames.has(hostname)) {
throw new ConfigurationError(`duplicate hostname: ${hostname}`);
}
hostnames.add(hostname);
const activatedOn = parseIsoDate(site.activatedOn);
if (!activatedOn) {
throw new ConfigurationError(`${label}.activatedOn must be a valid ISO date`);
}
if (typeof site.publicCounter !== "boolean") {
throw new ConfigurationError(`${label}.publicCounter must be boolean`);
}
const analyticsLog = requireAbsolutePath(
site.analyticsLog,
`${label}.analyticsLog`
);
if (/\.(?:\d+|gz)$/i.test(path.basename(analyticsLog))) {
throw new ConfigurationError(
`${label}.analyticsLog must identify the current uncompressed log`
);
}
if (analyticsLogPaths.has(analyticsLog)) {
throw new ConfigurationError(`analytics log is registered more than once: ${analyticsLog}`);
}
analyticsLogPaths.add(analyticsLog);
const reportOutputDirectory = requireAbsolutePath(
site.reportOutputDirectory,
`${label}.reportOutputDirectory`
);
if (!isWithin(stateDirectory, reportOutputDirectory)) {
throw new ConfigurationError(`${label}.reportOutputDirectory must be below stateDirectory`);
}
if (outputDirectories.has(reportOutputDirectory)) {
throw new ConfigurationError(`duplicate report output directory: ${reportOutputDirectory}`);
}
outputDirectories.add(reportOutputDirectory);
const publicJsonPath = site.publicCounter
? requireAbsolutePath(site.publicJsonPath, `${label}.publicJsonPath`)
: null;
if (publicJsonPath && !isWithin(stateDirectory, publicJsonPath)) {
throw new ConfigurationError(`${label}.publicJsonPath must be below stateDirectory`);
}
if (publicJsonPath === counterStatePath) {
throw new ConfigurationError(`${label}.publicJsonPath conflicts with counterStatePath`);
}
if (publicJsonPath && publicJsonPaths.has(publicJsonPath)) {
throw new ConfigurationError(`duplicate public JSON path: ${publicJsonPath}`);
}
if (publicJsonPath) {
publicJsonPaths.add(publicJsonPath);
}
return {
id: site.id,
hostname,
activatedOn,
publicCounter: site.publicCounter,
analyticsLog,
reportOutputDirectory,
publicJsonPath
};
});
if (!registry.combined || typeof registry.combined !== "object") {
throw new ConfigurationError("registry.combined must be an object");
}
const combinedReportOutputDirectory = requireAbsolutePath(
registry.combined.reportOutputDirectory,
"registry.combined.reportOutputDirectory"
);
if (!isWithin(stateDirectory, combinedReportOutputDirectory)) {
throw new ConfigurationError(
"registry.combined.reportOutputDirectory must be below stateDirectory"
);
}
if (outputDirectories.has(combinedReportOutputDirectory)) {
throw new ConfigurationError(
`duplicate report output directory: ${combinedReportOutputDirectory}`
);
}
return {
schemaVersion: 1,
stateDirectory,
counterStatePath,
lockFile: requireAbsolutePath(registry.lockFile, "registry.lockFile"),
geoIpCountryDatabase: requireAbsolutePath(
registry.geoIpCountryDatabase,
"registry.geoIpCountryDatabase"
),
combined: {
reportOutputDirectory: combinedReportOutputDirectory
},
sites
};
}
function resolveAnalyticsLogs(site) {
const logs = [];
const candidates = [
{ path: `${site.analyticsLog}.1`, required: false },
{ path: site.analyticsLog, required: true }
];
for (const candidate of candidates) {
let stats;
try {
stats = fs.statSync(candidate.path);
} catch (error) {
if (!candidate.required && error.code === "ENOENT") {
continue;
}
throw new ConfigurationError(`analytics log is not readable: ${candidate.path}`);
}
if (!stats.isFile()) {
throw new ConfigurationError(`analytics log is not a file: ${candidate.path}`);
}
try {
fs.accessSync(candidate.path, fs.constants.R_OK);
} catch (error) {
throw new ConfigurationError(
`analytics log is not readable by the current user: ${candidate.path}`
);
}
logs.push(candidate.path);
}
return logs;
}
function requireWritableDirectory(directory, label) {
let stats;
try {
stats = fs.statSync(directory);
} catch (error) {
throw new ConfigurationError(`${label} does not exist: ${directory}`);
}
if (!stats.isDirectory()) {
throw new ConfigurationError(`${label} is not a directory: ${directory}`);
}
try {
fs.accessSync(directory, fs.constants.W_OK | fs.constants.X_OK);
} catch (error) {
throw new ConfigurationError(
`${label} is not writable by the current user: ${directory}`
);
}
}
function validateInputs(registry, configTemplate) {
const requiredConfigParts = [
"{{DB_PATH}}",
"{{RESTORE_DIRECTIVE}}",
"{{GEOIP_COUNTRY_DATABASE}}",
"persist true",
"anonymize-ip true",
"ignore-crawlers true",
"unknowns-as-crawlers true",
"keep-last 395"
];
for (const required of requiredConfigParts) {
if (!configTemplate.includes(required)) {
throw new ConfigurationError(`GoAccess template is missing: ${required}`);
}
}
const analyticsLogsBySite = new Map(registry.sites.map(site => [
site.id,
resolveAnalyticsLogs(site)
]));
let geoStats;
try {
geoStats = fs.statSync(registry.geoIpCountryDatabase);
} catch (error) {
throw new ConfigurationError(
`GeoIP Country database is not readable: ${registry.geoIpCountryDatabase}`
);
}
if (!geoStats.isFile() || /city/i.test(path.basename(registry.geoIpCountryDatabase))) {
throw new ConfigurationError("geoIpCountryDatabase must be a Country database file");
}
try {
fs.accessSync(registry.geoIpCountryDatabase, fs.constants.R_OK);
} catch (error) {
throw new ConfigurationError(
`GeoIP Country database is not readable by the current user: `
+ registry.geoIpCountryDatabase
);
}
requireWritableDirectory(registry.stateDirectory, "stateDirectory");
requireWritableDirectory(
registry.combined.reportOutputDirectory,
"combined report output directory"
);
for (const site of registry.sites) {
requireWritableDirectory(
site.reportOutputDirectory,
`report output directory for ${site.id}`
);
if (site.publicCounter) {
requireWritableDirectory(
path.dirname(site.publicJsonPath),
`public output directory for ${site.id}`
);
}
}
return analyticsLogsBySite;
}
function renderGoAccessConfig(template, dbPath, restore, geoIpCountryDatabase) {
const rendered = template
.replaceAll("{{DB_PATH}}", dbPath)
.replaceAll(
"{{RESTORE_DIRECTIVE}}",
restore ? "restore true" : "# restore is disabled until a database exists"
)
.replaceAll("{{GEOIP_COUNTRY_DATABASE}}", geoIpCountryDatabase);
if (/{{[A-Z_]+}}/.test(rendered)) {
throw new ConfigurationError("GoAccess template contains an unknown placeholder");
}
return rendered;
}
function defaultRunGoAccess({ binary, args }) {
const result = spawnSync(binary, args, {
encoding: "utf8",
maxBuffer: 1024 * 1024,
timeout: 30 * 60 * 1000,
windowsHide: true
});
if (result.error) {
throw new Error(`could not start GoAccess: ${result.error.message}`);
}
if (result.status !== 0) {
const detail = (result.stderr || result.stdout || "no diagnostic output").trim();
throw new Error(`GoAccess failed with exit code ${result.status}: ${detail}`);
}
}
function defaultCheckGoAccess(binary) {
const result = spawnSync(binary, ["--version"], {
encoding: "utf8",
timeout: 10000,
windowsHide: true
});
if (result.error || result.status !== 0) {
const detail = result.error
? result.error.message
: (result.stderr || result.stdout || "no diagnostic output").trim();
throw new ConfigurationError(`GoAccess is not available: ${detail}`);
}
return parseGoAccessVersion(`${result.stdout || ""}\n${result.stderr || ""}`);
}
function parseGoAccessVersion(output) {
const versionMatch = /GoAccess\s+-\s+([0-9]+(?:\.[0-9]+)+)/i.exec(output);
return {
version: versionMatch ? versionMatch[1] : "unknown",
geoIpMmdb: /--enable-geoip=mmdb\b/i.test(output),
openSsl: /--with-openssl\b/i.test(output),
zlib: /--with-zlib\b/i.test(output)
};
}
function formatGoAccessCheck(capabilities) {
const detected = [
`GeoIP2/MMDB ${capabilities.geoIpMmdb ? "enabled" : "not detected"}`,
`OpenSSL ${capabilities.openSsl ? "enabled" : "not detected"}`,
`Zlib ${capabilities.zlib ? "enabled" : "not detected"}`
].join(", ");
const summary = `GoAccess ${capabilities.version}: ${detected}.`;
if (capabilities.zlib) {
return summary;
}
return `${summary} This build has no Zlib support. That is valid for regular mode: `
+ "the current analytics log and its optional uncompressed .1 rotation are read "
+ "directly; older .gz logs are not imported.";
}
function parseGoAccessDate(value) {
if (typeof value !== "string") {
return null;
}
if (parseIsoDate(value)) {
return value;
}
if (/^\d{8}$/.test(value)) {
return parseIsoDate(`${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`);
}
const match = /^(\d{2})\/([A-Za-z]{3})\/(\d{4})$/.exec(value);
const months = {
Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06",
Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12"
};
return match && months[match[2]]
? parseIsoDate(`${match[3]}-${months[match[2]]}-${match[1]}`)
: null;
}
function validateReport(report, combined) {
if (!report || typeof report !== "object" || Array.isArray(report)) {
throw new Error("GoAccess JSON report must be an object");
}
const requiredPanels = ["visitors", "requests", "status_codes", "geo_location"];
if (combined) {
requiredPanels.push("virtual_hosts");
}
if (!report.general || typeof report.general !== "object") {
throw new Error("GoAccess JSON report has no general summary");
}
for (const panel of requiredPanels) {
if (!report[panel] || !Array.isArray(report[panel].data)) {
throw new Error(`GoAccess JSON report has no ${panel} panel`);
}
}
extractDailyVisits(report);
return report;
}
function readAndValidateReport(jsonPath, htmlPath, combined) {
let report;
try {
report = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
} catch (error) {
throw new Error(`GoAccess JSON output is invalid: ${error.message}`);
}
validateReport(report, combined);
const html = fs.readFileSync(htmlPath, "utf8");
if (html.length < 100 || !/<html(?:\s|>)/i.test(html)) {
throw new Error("GoAccess HTML output is missing or implausibly small");
}
return report;
}
function extractDailyVisits(report) {
const daily = {};
for (const row of report.visitors.data) {
const date = parseGoAccessDate(row && row.data);
const visits = row && row.visitors && row.visitors.count;
if (!date || !Number.isSafeInteger(visits) || visits < 0) {
throw new Error("GoAccess visitors panel contains invalid daily data");
}
if (Object.hasOwn(daily, date)) {
throw new Error(`GoAccess visitors panel contains duplicate date ${date}`);
}
daily[date] = visits;
}
return daily;
}
function loadCounterState(statePath) {
if (!fs.existsSync(statePath)) {
return { schemaVersion: STATE_SCHEMA_VERSION, sites: {} };
}
const state = readJson(statePath, "counter state");
if (state.schemaVersion !== STATE_SCHEMA_VERSION
|| !state.sites || typeof state.sites !== "object" || Array.isArray(state.sites)) {
throw new ConfigurationError("counter state has an unsupported structure");
}
for (const [siteId, site] of Object.entries(state.sites)) {
if (!site || typeof site !== "object" || !parseIsoDate(site.since)
|| !site.dailyVisits || typeof site.dailyVisits !== "object"
|| Array.isArray(site.dailyVisits)) {
throw new ConfigurationError(`counter state for ${siteId} is invalid`);
}
for (const [date, visits] of Object.entries(site.dailyVisits)) {
if (!parseIsoDate(date) || !Number.isSafeInteger(visits) || visits < 0) {
throw new ConfigurationError(`counter state value for ${siteId}/${date} is invalid`);
}
}
}
return state;
}
function updateCounterState(state, site, report) {
const current = state.sites[site.id];
if (current && current.since !== site.activatedOn) {
throw new ConfigurationError(
`activation date for ${site.id} differs from the existing counter state`
);
}
if (current && current.hostname !== site.hostname) {
throw new ConfigurationError(
`hostname for ${site.id} differs from the existing counter state`
);
}
const dailyVisits = current ? { ...current.dailyVisits } : {};
for (const [date, visits] of Object.entries(extractDailyVisits(report))) {
if (date >= site.activatedOn) {
dailyVisits[date] = visits;
}
}
state.sites[site.id] = {
hostname: site.hostname,
since: site.activatedOn,
dailyVisits
};
}
function publicPayload(state, site, now) {
const siteState = state.sites[site.id];
let visits = 0;
for (const [date, value] of Object.entries(siteState.dailyVisits)) {
if (date >= site.activatedOn) {
visits += value;
if (!Number.isSafeInteger(visits) || visits > MAX_SAFE_INTEGER) {
throw new Error(`public visit total for ${site.id} exceeds the safe integer range`);
}
}
}
return {
schemaVersion: PUBLIC_SCHEMA_VERSION,
visits,
since: site.activatedOn,
updatedAt: now.toISOString().replace(/\.\d{3}Z$/, "Z")
};
}
function atomicWriteFile(destination, content, mode = 0o640) {
const directory = path.dirname(destination);
const temporary = path.join(
directory,
`.${path.basename(destination)}.${process.pid}.${Date.now()}.tmp`
);
const handle = fs.openSync(temporary, "wx", mode);
try {
fs.writeFileSync(handle, content);
fs.fchmodSync(handle, mode);
fs.fsyncSync(handle);
} finally {
fs.closeSync(handle);
}
try {
fs.renameSync(temporary, destination);
} catch (error) {
fs.rmSync(temporary, { force: true });
throw error;
}
}
function atomicCopyFile(source, destination) {
atomicWriteFile(destination, fs.readFileSync(source));
}
function replaceDirectory(source, destination) {
fs.mkdirSync(path.dirname(destination), { recursive: true });
const backup = `${destination}.previous-${process.pid}`;
const hadDestination = fs.existsSync(destination);
if (fs.existsSync(backup)) {
throw new Error(`stale database backup blocks replacement: ${backup}`);
}
try {
if (hadDestination) {
fs.renameSync(destination, backup);
}
fs.renameSync(source, destination);
if (hadDestination) {
fs.rmSync(backup, { recursive: true, force: true });
}
} catch (error) {
if (!fs.existsSync(destination) && fs.existsSync(backup)) {
fs.renameSync(backup, destination);
}
throw error;
}
}
function acquireLock(lockPath) {
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
let descriptor;
try {
descriptor = fs.openSync(lockPath, "wx", 0o640);
fs.writeFileSync(descriptor, `${process.pid}\n`);
} catch (error) {
throw new LockError(`another analytics run is active (${lockPath})`);
}
return () => {
fs.closeSync(descriptor);
fs.rmSync(lockPath, { force: true });
};
}
function createReportJob(id, logs, outputDirectory, combined) {
return { id, logs, outputDirectory, combined };
}
function prepareReport(job, context) {
const jobDirectory = path.join(context.runDirectory, job.id);
const stagedDb = path.join(jobDirectory, "db");
const currentDb = path.join(context.registry.stateDirectory, "db", job.id);
const outputJson = path.join(jobDirectory, "report.json");
const outputHtml = path.join(jobDirectory, "report.html");
const runConfig = path.join(jobDirectory, "goaccess.conf");
let hasDatabase = false;
if (fs.existsSync(currentDb)) {
if (!fs.statSync(currentDb).isDirectory()) {
throw new Error(`GoAccess database path is not a directory: ${currentDb}`);
}
hasDatabase = fs.readdirSync(currentDb).length > 0;
}
fs.mkdirSync(jobDirectory, { recursive: true });
if (hasDatabase) {
fs.cpSync(currentDb, stagedDb, { recursive: true, errorOnExist: true });
} else {
fs.mkdirSync(stagedDb);
}
fs.writeFileSync(runConfig, renderGoAccessConfig(
context.configTemplate,
stagedDb,
hasDatabase,
context.registry.geoIpCountryDatabase
));
const args = [
...job.logs,
"--no-global-config",
"--config-file", runConfig,
"--output", outputJson,
"--output", outputHtml
];
context.runGoAccess({
binary: context.goaccessBinary,
args,
id: job.id,
combined: job.combined,
outputJson,
outputHtml,
dbPath: stagedDb
});
return {
...job,
report: readAndValidateReport(outputJson, outputHtml, job.combined),
outputJson,
outputHtml,
stagedDb,
currentDb
};
}
function generateReports(options, dependencies = {}) {
const registryPath = path.resolve(options.registryPath);
const configTemplatePath = path.resolve(options.configTemplatePath);
const registry = validateRegistry(readJson(registryPath, "site registry"));
const configTemplate = fs.readFileSync(configTemplatePath, "utf8");
const runGoAccess = dependencies.runGoAccess || defaultRunGoAccess;
const checkGoAccess = dependencies.checkGoAccess || defaultCheckGoAccess;
const now = dependencies.now ? dependencies.now() : new Date();
const analyticsLogsBySite = validateInputs(registry, configTemplate);
const goAccess = checkGoAccess(options.goaccessBinary || "goaccess");
if (!goAccess || !goAccess.geoIpMmdb) {
throw new ConfigurationError(
"GoAccess must be built with GeoIP2/MMDB support (--enable-geoip=mmdb)"
);
}
if (options.check) {
return { checked: true, sites: registry.sites.length, goAccess };
}
const releaseLock = options.dryRun || dependencies.skipLock
? () => {}
: acquireLock(registry.lockFile);
let runDirectory;
try {
runDirectory = fs.mkdtempSync(path.join(
options.dryRun ? os.tmpdir() : registry.stateDirectory,
".analytics-run-"
));
const jobs = registry.sites.map(site => createReportJob(
site.id,
analyticsLogsBySite.get(site.id),
site.reportOutputDirectory,
false
));
jobs.push(createReportJob(
"combined",
registry.sites.flatMap(site => analyticsLogsBySite.get(site.id)),
registry.combined.reportOutputDirectory,
true
));
const context = {
registry,
configTemplate,
goaccessBinary: options.goaccessBinary || "goaccess",
runGoAccess,
runDirectory
};
const prepared = jobs.map(job => prepareReport(job, context));
const state = loadCounterState(registry.counterStatePath);
for (const site of registry.sites.filter(entry => entry.publicCounter)) {
const generated = prepared.find(entry => entry.id === site.id);
updateCounterState(state, site, generated.report);
}
const publicFiles = registry.sites
.filter(site => site.publicCounter)
.map(site => ({
destination: site.publicJsonPath,
content: `${JSON.stringify(publicPayload(state, site, now), null, 2)}\n`
}));
if (!options.dryRun) {
for (const report of prepared) {
atomicCopyFile(
report.outputJson,
path.join(report.outputDirectory, "report.json")
);
atomicCopyFile(
report.outputHtml,
path.join(report.outputDirectory, "report.html")
);
}
for (const report of prepared) {
replaceDirectory(report.stagedDb, report.currentDb);
}
atomicWriteFile(
registry.counterStatePath,
`${JSON.stringify(state, null, 2)}\n`
);
for (const publicFile of publicFiles) {
atomicWriteFile(publicFile.destination, publicFile.content, 0o644);
}
}
return {
checked: false,
dryRun: Boolean(options.dryRun),
sites: registry.sites.length,
reports: prepared.length,
publicCounters: publicFiles.length
};
} finally {
if (runDirectory && fs.existsSync(runDirectory)) {
fs.rmSync(runDirectory, { recursive: true, force: true });
}
releaseLock();
}
}
function parseArguments(argv) {
const options = { check: false, dryRun: false, goaccessBinary: "goaccess" };
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--check") {
options.check = true;
} else if (argument === "--dry-run") {
options.dryRun = true;
} else if (["--registry", "--config-template", "--goaccess"].includes(argument)) {
const value = argv[index + 1];
if (!value) {
throw new ConfigurationError(`${argument} requires a value`);
}
index += 1;
if (argument === "--registry") options.registryPath = value;
if (argument === "--config-template") options.configTemplatePath = value;
if (argument === "--goaccess") options.goaccessBinary = value;
} else {
throw new ConfigurationError(`unknown argument: ${argument}`);
}
}
if (!options.registryPath || !options.configTemplatePath) {
throw new ConfigurationError(
"usage: generate-reports.js --registry FILE --config-template FILE "
+ "[--goaccess FILE] [--check|--dry-run]"
);
}
if (options.check && options.dryRun) {
throw new ConfigurationError("--check and --dry-run are mutually exclusive");
}
return options;
}
if (require.main === module) {
try {
const result = generateReports(parseArguments(process.argv.slice(2)));
const action = result.checked ? "Configuration check" : "Analytics generation";
if (result.checked) {
process.stdout.write(`${formatGoAccessCheck(result.goAccess)}\n`);
}
process.stdout.write(`${action} completed: ${JSON.stringify(result)}\n`);
} catch (error) {
process.stderr.write(`analytics: ${error.message}\n`);
process.exitCode = error.exitCode || 1;
}
}
module.exports = {
ConfigurationError,
extractDailyVisits,
formatGoAccessCheck,
generateReports,
parseGoAccessVersion,
parseArguments,
publicPayload,
updateCounterState,
validateRegistry,
validateReport
};
@@ -0,0 +1,43 @@
# Rendered by generate-reports.js. Do not use this file without replacing
# all {{...}} placeholders.
datetime-format %Y-%m-%dT%H:%M:%S%z
log-format %v\t%h\t%x\t%m\t%U\t%H\t%s\t%b\t"%u"
anonymize-ip true
anonymize-level 2
ignore-crawlers true
unknowns-as-crawlers true
keep-last 395
persist true
{{RESTORE_DIRECTIVE}}
db-path {{DB_PATH}}
geoip-database {{GEOIP_COUNTRY_DATABASE}}
json-pretty-print true
no-progress true
no-parsing-spinner true
no-color true
agent-list false
http-method true
http-protocol true
max-items 500
# The Nginx analytics log contains $uri rather than $request_uri, so query
# strings never reach GoAccess. These panels are deliberately unavailable.
ignore-panel HOSTS
ignore-panel OS
ignore-panel BROWSERS
ignore-panel REFERRERS
ignore-panel REFERRING_SITES
ignore-panel KEYPHRASES
ignore-panel REMOTE_USER
ignore-panel REQUESTS_STATIC
ignore-panel VISIT_TIMES
ignore-panel NOT_FOUND
ignore-panel ASN
ignore-panel MIME_TYPE
ignore-panel TLS_TYPE
ignore-panel CACHE_STATUS
@@ -0,0 +1,14 @@
/var/log/nginx/*-analytics.log {
daily
rotate 14
missingok
notifempty
compress
# Keep .1 uncompressed because the regular generator reads it directly.
delaycompress
create 0640 www-data hamradio-analytics
sharedscripts
postrotate
invoke-rc.d nginx rotate >/dev/null 2>&1
endscript
}
@@ -0,0 +1,36 @@
# Only page GET requests which are candidates for the reach statistics enter
# the dedicated analytics log. GoAccess performs the second bot-classification
# layer, including unknown browsers and operating systems.
map $request_method $hamradioonline_analytics_method {
default 0;
GET 1;
}
map $uri $hamradioonline_analytics_path {
default 1;
/visitor-count.json 0;
/kst4ContestVersionInfo.xml 0;
/sitemap.xml 0;
/robots.txt 0;
/favicon.ico 0;
/assets/favicon.svg 0;
/health 0;
/healthz 0;
/ping 0;
/status 0;
~*^/(?:assets|manual/assets)/ 0;
~*\.(?:css|js|mjs|map|json|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf|eot|xml|txt|pdf|zip|gz|wasm|mp4|webm)$ 0;
}
map $http_user_agent $hamradioonline_analytics_known_bot {
default 0;
~*(?:bot|crawler|spider|slurp|headless|monitor|healthcheck|uptime|wget|curl) 1;
}
map "$hamradioonline_analytics_method:$hamradioonline_analytics_path:$hamradioonline_analytics_known_bot"
$hamradioonline_analytics_loggable {
default 0;
"1:1:0" 1;
}
@@ -0,0 +1,15 @@
# Include in the Nginx http context. $uri is the normalized path and excludes
# the query string. The format intentionally omits referrer and remote user.
log_format hamradioonline_analytics
'$server_name\t$remote_addr\t$time_iso8601\t$request_method\t$uri\t'
'$server_protocol\t$status\t$body_bytes_sent\t"$http_user_agent"';
# Include the maps below in the Nginx http context as well.
include /etc/nginx/snippets/hamradioonline-analytics-filters.conf;
# Add this extra log to each registered project server block. Keep the
# existing operational access_log directive; do not replace it implicitly.
#
# access_log /var/log/nginx/kst4contest-analytics.log
# hamradioonline_analytics if=$hamradioonline_analytics_loggable;
@@ -0,0 +1,9 @@
# Include inside the kst4contest.hamradioonline.de HTTPS server block. Grant
# the Nginx worker read access to the file and directory, but no write access.
location = /visitor-count.json {
alias /var/lib/hamradioonline-analytics/public/kst4contest/visitor-count.json;
default_type application/json;
access_log off;
add_header Cache-Control "public, max-age=3600" always;
add_header X-Content-Type-Options "nosniff" always;
}
@@ -0,0 +1,17 @@
# Temporary HTTP-only virtual host for initial certificate provisioning.
# Replace it with stats-vhost.conf.example after Certbot has succeeded.
server {
listen 80;
server_name stats.hamradioonline.de;
access_log off;
location ^~ /.well-known/acme-challenge/ {
root /var/lib/letsencrypt;
default_type text/plain;
}
location / {
return 404;
}
}
@@ -0,0 +1,57 @@
# Keep this HTTP server active so Certbot can renew the webroot certificate.
server {
listen 80;
server_name stats.hamradioonline.de;
access_log off;
location ^~ /.well-known/acme-challenge/ {
root /var/lib/letsencrypt;
default_type text/plain;
}
location / {
return 301 https://$host$request_uri;
}
}
# This server exposes static reports only. Provision the certificate and the
# htpasswd file outside the repository. Do not add this host to sites.json.
server {
listen 443 ssl http2;
server_name stats.hamradioonline.de;
ssl_certificate /etc/letsencrypt/live/stats.hamradioonline.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/stats.hamradioonline.de/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
root /var/lib/hamradioonline-analytics/reports;
index report.html;
auth_basic "Private project statistics";
auth_basic_user_file /etc/nginx/htpasswd/hamradioonline-analytics;
access_log off;
add_header Cache-Control "private, no-store" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
location = / {
try_files /__no_report_at_root__ @combined_reports;
}
location @combined_reports {
return 302 /combined/;
}
location / {
try_files $uri $uri/ =404;
}
location ~ /\. {
deny all;
}
}
# IPv6 is deliberately omitted until the DNS AAAA record has been confirmed.
+21
View File
@@ -0,0 +1,21 @@
{
"schemaVersion": 1,
"stateDirectory": "/var/lib/hamradioonline-analytics",
"counterStatePath": "/var/lib/hamradioonline-analytics/public-counter-state.json",
"lockFile": "/run/hamradioonline-analytics/generator.lock",
"geoIpCountryDatabase": "/var/lib/GeoIP/GeoLite2-Country.mmdb",
"combined": {
"reportOutputDirectory": "/var/lib/hamradioonline-analytics/reports/combined"
},
"sites": [
{
"id": "kst4contest",
"hostname": "kst4contest.hamradioonline.de",
"analyticsLog": "/var/log/nginx/kst4contest-analytics.log",
"activatedOn": "2026-09-11",
"publicCounter": true,
"reportOutputDirectory": "/var/lib/hamradioonline-analytics/reports/kst4contest",
"publicJsonPath": "/var/lib/hamradioonline-analytics/public/kst4contest/visitor-count.json"
}
]
}
@@ -0,0 +1,34 @@
[Unit]
Description=Generate private GoAccess reports and public project counters
After=nginx.service
[Service]
Type=oneshot
User=hamradio-analytics
Group=hamradio-analytics
UMask=0027
Environment=LC_TIME=C
ExecStart=/usr/bin/node /opt/hamradioonline-analytics/generate-reports.js --registry /etc/hamradioonline-analytics/sites.json --config-template /etc/hamradioonline-analytics/goaccess.conf.template
NoNewPrivileges=true
PrivateDevices=true
PrivateNetwork=true
ProtectClock=true
ProtectControlGroups=true
ProtectHome=true
ProtectKernelLogs=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectSystem=strict
PrivateTmp=true
ProtectProc=invisible
ProcSubset=pid
RestrictAddressFamilies=AF_UNIX
ReadOnlyPaths=/etc/hamradioonline-analytics /opt/hamradioonline-analytics /var/log/nginx /var/lib/GeoIP/GeoLite2-Country.mmdb
ReadWritePaths=/var/lib/hamradioonline-analytics
StateDirectory=hamradioonline-analytics
StateDirectoryMode=0711
RuntimeDirectory=hamradioonline-analytics
RuntimeDirectoryMode=0750
RestrictSUIDSGID=true
LockPersonality=true
@@ -0,0 +1,11 @@
[Unit]
Description=Run hamradioonline analytics once per hour
[Timer]
OnCalendar=hourly
Persistent=true
RandomizedDelaySec=4m
AccuracySec=1m
[Install]
WantedBy=timers.target
+3
View File
@@ -7,6 +7,9 @@
"": {
"name": "kst4contest-website",
"version": "0.1.0",
"engines": {
"node": ">=18.19.1"
},
"dependencies": {
"markdown-it": "^14.3.0",
"markdown-it-anchor": "^9.2.0"
+3
View File
@@ -2,6 +2,9 @@
"name": "kst4contest-website",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=18.19.1"
},
"scripts": {
"start": "eleventy --serve",
"build": "eleventy && npm run validate:version-info",
+3
View File
@@ -81,5 +81,8 @@
{% if heroFx %}
<script src="/assets/js/hero-radio-fx.js?v={{ build.version }}" defer></script>
{% endif %}
{% if visitorCount %}
<script src="/assets/js/visitor-count.js?v={{ build.version }}" defer></script>
{% endif %}
</body>
</html>
+6
View File
@@ -659,6 +659,12 @@ a:hover {
padding: clamp(28px, 5vw, 60px);
}
.visitor-count {
margin: 24px 0 0;
color: var(--soft);
font-size: 0.9rem;
}
.manual-content {
max-width: 980px;
}
+147
View File
@@ -0,0 +1,147 @@
"use strict";
const VISITOR_COUNT_ENDPOINT = "/visitor-count.json";
const VISITOR_COUNT_TIMEOUT_MS = 2500;
function parseIsoDate(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year
|| date.getUTCMonth() !== month - 1
|| date.getUTCDate() !== day) {
return null;
}
return date;
}
function isIsoTimestamp(value) {
if (typeof value !== "string") {
return false;
}
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-](\d{2}):(\d{2}))$/.exec(value);
if (!match || !parseIsoDate(match[1])) {
return false;
}
const hour = Number(match[2]);
const minute = Number(match[3]);
const second = Number(match[4]);
const offsetHour = match[6] === undefined ? 0 : Number(match[6]);
const offsetMinute = match[7] === undefined ? 0 : Number(match[7]);
return hour <= 23
&& minute <= 59
&& second <= 59
&& offsetHour <= 23
&& offsetMinute <= 59
&& Number.isFinite(Date.parse(value));
}
function validateVisitorCount(data) {
if (!data || typeof data !== "object" || Array.isArray(data)) {
return null;
}
if (data.schemaVersion !== 1
|| !Number.isSafeInteger(data.visits)
|| data.visits < 0
|| !isIsoTimestamp(data.updatedAt)) {
return null;
}
const since = typeof data.since === "string"
? parseIsoDate(data.since)
: null;
return since ? { visits: data.visits, since } : null;
}
function formatVisitorCount(data) {
const valid = validateVisitorCount(data);
if (!valid) {
return null;
}
const date = new Intl.DateTimeFormat("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
timeZone: "UTC"
}).format(valid.since);
const visits = new Intl.NumberFormat("en-GB").format(valid.visits);
return `Visits since ${date}: ${visits}`;
}
async function loadVisitorCount({
element,
fetchImpl = globalThis.fetch,
AbortControllerImpl = globalThis.AbortController,
timeoutMs = VISITOR_COUNT_TIMEOUT_MS
}) {
if (!element || typeof fetchImpl !== "function"
|| typeof AbortControllerImpl !== "function") {
return false;
}
element.hidden = true;
element.textContent = "";
const controller = new AbortControllerImpl();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(VISITOR_COUNT_ENDPOINT, {
credentials: "omit",
signal: controller.signal
});
if (!response.ok) {
return false;
}
const text = formatVisitorCount(await response.json());
if (!text) {
return false;
}
element.textContent = text;
element.hidden = false;
return true;
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
if (typeof document !== "undefined") {
const element = document.querySelector("[data-visitor-count]");
if (element) {
loadVisitorCount({ element });
}
}
if (typeof module !== "undefined" && module.exports) {
module.exports = {
formatVisitorCount,
loadVisitorCount,
parseIsoDate,
validateVisitorCount
};
}
+4 -1
View File
@@ -3,6 +3,7 @@ layout: base.njk
title: KST4Contest ON4KST Contest Client for VHF, UHF and SHF
description: KST4Contest combines ON4KST chat, candidate prioritisation, sked planning, AirScout data and logger integration in one desktop client.
heroFx: true
visitorCount: true
---
<section class="hero hero-split">
@@ -150,6 +151,8 @@ heroFx: true
<a class="button secondary" href="/roadmap/">Development status</a>
<a class="button ghost" href="/support/">Support development</a>
</div>
<p class="visitor-count" data-visitor-count hidden aria-live="polite"></p>
</div>
</section>
@@ -165,4 +168,4 @@ heroFx: true
"description": "KST4Contest combines ON4KST chat, candidate prioritisation, sked planning, AirScout data and logger integration for VHF, UHF and SHF contest operation.",
"license": "https://github.com/praktimarc/kst4contest/blob/main/LICENSE"
}
</script>
</script>
+39 -5
View File
@@ -7,7 +7,7 @@ description: Privacy policy for the KST4Contest website.
<section class="hero">
<p class="badge">Privacy</p>
<h1>Privacy Policy</h1>
<p class="lead">Information about data processing on this static website.</p>
<p class="lead">Information about data processing on this website.</p>
</section>
<section class="section narrow">
@@ -30,12 +30,46 @@ description: Privacy policy for the KST4Contest website.
<h2>Legal basis</h2>
<p>
The legal basis is Art. 6(1)(f) GDPR: legitimate interest in secure and reliable operation
of the website.
of the website and in understanding the approximate use of its project pages.
</p>
<h2>Cookies and tracking</h2>
<h2>Server-side reach statistics</h2>
<p>
This website currently does not use cookies, analytics tracking, advertising pixels or user profiling.
The website uses server-side reach statistics to understand approximate demand and which
project pages are used. Measurement is based on a dedicated, reduced server access log. It
does not use cookies, an analytics or tracking script, advertising pixels, local storage,
third-party requests or user profiles.
</p>
<p>
The analytics log contains the server name, IP address, time, HTTP method, normalised path
without a query string, protocol, response status, transferred size and user agent. It does
not contain a referrer or authenticated user name. Known crawlers are filtered, and unknown
browsers or operating systems are treated as crawlers.
</p>
<p>
IP addresses are anonymised before data is stored in the statistics aggregates. The dedicated
analytics raw logs are retained for 14 days. Anonymised detailed aggregates are retained on a
rolling basis for 395 days. They include countries, but no city or host statistics. Countries
are derived with a Country database only.
</p>
<p>
A visit is an approximate daily value based on the combination of IP address, date and user
agent used by GoAccess. It must not be read as a number of unique people. Page views and visits
remain separate measures. Bots, monitoring requests, software update checks and static assets
are excluded as far as they can be identified.
</p>
<h2>Public visitor count</h2>
<p>
The home page displays an element containing the total number of these approximate daily visits
since the stated activation date. The display loads a same-origin JSON file and remains hidden
if that file is unavailable or invalid. This request is not included in the statistic.
</p>
<p>
The public file contains only the counter value, activation date, schema version and update time.
It contains no personal data or daily breakdown. The non-personal daily counter values are kept
permanently so that the total does not decrease when detailed aggregates reach the end of their
395-day retention period.
</p>
<h2>External links</h2>
@@ -71,4 +105,4 @@ description: Privacy policy for the KST4Contest website.
You have the right to lodge a complaint with a competent data protection supervisory authority.
</p>
</article>
</section>
</section>
+519
View File
@@ -0,0 +1,519 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const {
formatGoAccessCheck,
generateReports,
parseGoAccessVersion
} = require("../ops/analytics/generate-reports");
const GOACCESS_WITHOUT_ZLIB = {
version: "1.8.1",
geoIpMmdb: true,
openSsl: true,
zlib: false
};
function goAccessReport(dailyVisits, combined = false) {
const report = {
general: { total_requests: 1 },
visitors: {
data: Object.entries(dailyVisits).map(([date, count]) => ({
data: date.replaceAll("-", ""),
visitors: { count }
}))
},
requests: { data: [] },
status_codes: { data: [] },
geo_location: { data: [] }
};
if (combined) {
report.virtual_hosts = { data: [] };
}
return report;
}
function fixture(siteDefinitions) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "kst4-analytics-test-"));
const stateDirectory = path.join(root, "state");
const geoIpCountryDatabase = path.join(root, "GeoLite2-Country.mmdb");
const configTemplatePath = path.join(root, "goaccess.conf.template");
const registryPath = path.join(root, "sites.json");
fs.writeFileSync(geoIpCountryDatabase, "test database placeholder");
fs.copyFileSync(
path.join(__dirname, "../ops/analytics/goaccess.conf.template"),
configTemplatePath
);
const sites = siteDefinitions.map((definition, index) => {
const id = definition.id || `site-${index}`;
const log = path.join(root, `${id}.log`);
fs.writeFileSync(log, "example log line\n");
if (definition.rotated !== false) {
fs.writeFileSync(`${log}.1`, "rotated example log line\n");
}
if (definition.compressed) {
fs.writeFileSync(`${log}.2.gz`, "compressed placeholder\n");
}
return {
id,
hostname: definition.hostname || `${id}.example.test`,
analyticsLog: log,
activatedOn: definition.activatedOn || "2026-01-01",
publicCounter: definition.publicCounter,
reportOutputDirectory: path.join(stateDirectory, "reports", id),
...(definition.publicCounter
? { publicJsonPath: path.join(stateDirectory, "public", `${id}.json`) }
: {})
};
});
const registry = {
schemaVersion: 1,
stateDirectory,
counterStatePath: path.join(stateDirectory, "counter-state.json"),
lockFile: path.join(root, "run", "generator.lock"),
geoIpCountryDatabase,
combined: {
reportOutputDirectory: path.join(stateDirectory, "reports", "combined")
},
sites
};
fs.mkdirSync(stateDirectory, { recursive: true });
fs.mkdirSync(registry.combined.reportOutputDirectory, { recursive: true });
for (const site of sites) {
fs.mkdirSync(site.reportOutputDirectory, { recursive: true });
if (site.publicCounter) {
fs.mkdirSync(path.dirname(site.publicJsonPath), { recursive: true });
}
}
fs.writeFileSync(registryPath, JSON.stringify(registry));
return {
root,
registry,
registryPath,
configTemplatePath,
cleanup: () => fs.rmSync(root, { recursive: true, force: true })
};
}
function fakeGoAccess(reports, calls, failureId) {
return invocation => {
calls.push(invocation);
if (invocation.id === failureId) {
throw new Error("simulated GoAccess failure");
}
fs.writeFileSync(
invocation.outputJson,
JSON.stringify(reports[invocation.id])
);
fs.writeFileSync(
invocation.outputHtml,
`<!doctype html><html><body>${"report".repeat(30)}</body></html>`
);
fs.writeFileSync(path.join(invocation.dbPath, "persisted.db"), invocation.id);
};
}
function run(testFixture, reports, calls = [], failureId) {
return generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
goaccessBinary: "fake-goaccess"
}, {
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB,
runGoAccess: fakeGoAccess(reports, calls, failureId),
now: () => new Date("2026-09-11T07:00:00Z"),
skipLock: true
});
}
test("replaces repeated daily values and retains older public days", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
try {
fs.mkdirSync(path.dirname(testFixture.registry.counterStatePath), { recursive: true });
fs.writeFileSync(testFixture.registry.counterStatePath, JSON.stringify({
schemaVersion: 1,
sites: {
alpha: {
hostname: "alpha.example.test",
since: "2026-01-01",
dailyVisits: { "2026-01-01": 7 }
}
}
}));
run(testFixture, {
alpha: goAccessReport({ "2026-09-11": 3 }),
combined: goAccessReport({ "2026-09-11": 3 }, true)
});
run(testFixture, {
alpha: goAccessReport({ "2026-09-11": 5 }),
combined: goAccessReport({ "2026-09-11": 5 }, true)
});
const state = JSON.parse(fs.readFileSync(
testFixture.registry.counterStatePath,
"utf8"
));
const published = JSON.parse(fs.readFileSync(
testFixture.registry.sites[0].publicJsonPath,
"utf8"
));
assert.deepEqual(state.sites.alpha.dailyVisits, {
"2026-01-01": 7,
"2026-09-11": 5
});
assert.deepEqual(published, {
schemaVersion: 1,
visits: 12,
since: "2026-01-01",
updatedAt: "2026-09-11T07:00:00Z"
});
} finally {
testFixture.cleanup();
}
});
test("keeps valid outputs unchanged when a GoAccess job fails", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
try {
const reportDirectory = testFixture.registry.sites[0].reportOutputDirectory;
fs.mkdirSync(reportDirectory, { recursive: true });
fs.mkdirSync(path.dirname(testFixture.registry.counterStatePath), { recursive: true });
fs.mkdirSync(path.dirname(testFixture.registry.sites[0].publicJsonPath), { recursive: true });
fs.writeFileSync(path.join(reportDirectory, "report.json"), "old-json");
fs.writeFileSync(path.join(reportDirectory, "report.html"), "old-html");
fs.writeFileSync(testFixture.registry.counterStatePath, "old-state");
fs.writeFileSync(testFixture.registry.sites[0].publicJsonPath, "old-public");
assert.throws(() => run(testFixture, {
alpha: goAccessReport({ "2026-09-11": 3 }),
combined: goAccessReport({ "2026-09-11": 3 }, true)
}, [], "combined"), /simulated GoAccess failure/);
assert.equal(fs.readFileSync(path.join(reportDirectory, "report.json"), "utf8"), "old-json");
assert.equal(fs.readFileSync(path.join(reportDirectory, "report.html"), "utf8"), "old-html");
assert.equal(fs.readFileSync(testFixture.registry.counterStatePath, "utf8"), "old-state");
assert.equal(fs.readFileSync(testFixture.registry.sites[0].publicJsonPath, "utf8"), "old-public");
} finally {
testFixture.cleanup();
}
});
test("processes subdomains separately and together without publishing disabled counters", () => {
const testFixture = fixture([
{ id: "alpha", publicCounter: true, compressed: true },
{ id: "bravo", publicCounter: false, rotated: false, compressed: true }
]);
const calls = [];
try {
const result = run(testFixture, {
alpha: goAccessReport({ "2026-09-11": 3 }),
bravo: goAccessReport({ "2026-09-11": 4 }),
combined: goAccessReport({ "2026-09-11": 6 }, true)
}, calls);
assert.equal(result.reports, 3);
assert.deepEqual(calls.map(call => call.id), ["alpha", "bravo", "combined"]);
const alphaLogs = [
`${testFixture.registry.sites[0].analyticsLog}.1`,
testFixture.registry.sites[0].analyticsLog
];
const bravoLogs = [testFixture.registry.sites[1].analyticsLog];
assert.deepEqual(calls[0].args.slice(0, 2), alphaLogs);
assert.deepEqual(calls[1].args.slice(0, 1), bravoLogs);
assert.deepEqual(
calls[2].args.slice(0, 3),
[...alphaLogs, ...bravoLogs]
);
assert.equal(calls.some(call => call.args.some(argument => argument.endsWith(".gz"))), false);
assert.equal(fs.existsSync(path.join(
testFixture.registry.stateDirectory,
"public",
"bravo.json"
)), false);
assert.equal(fs.existsSync(path.join(
testFixture.registry.combined.reportOutputDirectory,
"report.html"
)), true);
} finally {
testFixture.cleanup();
}
});
test("dry-run validates generated data without changing production paths", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
try {
const blockedLockParent = path.join(testFixture.root, "blocked-lock-parent");
fs.writeFileSync(blockedLockParent, "not a directory");
testFixture.registry.lockFile = path.join(blockedLockParent, "generator.lock");
fs.writeFileSync(testFixture.registryPath, JSON.stringify(testFixture.registry));
const result = generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
goaccessBinary: "fake-goaccess",
dryRun: true
}, {
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB,
runGoAccess: fakeGoAccess({
alpha: goAccessReport({ "2026-09-11": 3 }),
combined: goAccessReport({ "2026-09-11": 3 }, true)
}, []),
now: () => new Date("2026-09-11T07:00:00Z")
});
assert.equal(result.dryRun, true);
assert.equal(fs.statSync(blockedLockParent).isFile(), true);
assert.equal(fs.readdirSync(
testFixture.registry.sites[0].reportOutputDirectory
).length, 0);
assert.equal(fs.existsSync(testFixture.registry.sites[0].publicJsonPath), false);
assert.equal(fs.existsSync(testFixture.registry.counterStatePath), false);
} finally {
testFixture.cleanup();
}
});
test("configuration check accepts and explains a GoAccess build without Zlib", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true, rotated: false }]);
try {
const versionOutput = [
"GoAccess - 1.8.1.",
"Build configure arguments:",
" --enable-geoip=mmdb",
" --with-openssl"
].join("\n");
const capabilities = parseGoAccessVersion(versionOutput);
const result = generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
goaccessBinary: "fake-goaccess",
check: true
}, {
checkGoAccess: () => capabilities,
runGoAccess: () => {
throw new Error("configuration check must not generate reports");
}
});
const message = formatGoAccessCheck(result.goAccess);
assert.deepEqual(capabilities, GOACCESS_WITHOUT_ZLIB);
assert.equal(result.checked, true);
assert.match(message, /Zlib not detected/);
assert.match(message, /no Zlib support/);
assert.match(message, /valid for regular mode/);
assert.match(message, /optional uncompressed \.1 rotation/);
assert.match(message, /older \.gz logs are not imported/);
} finally {
testFixture.cleanup();
}
});
test("detects Zlib support when it is present", () => {
const capabilities = parseGoAccessVersion([
"GoAccess - 1.8.1.",
"Build configure arguments:",
" --enable-geoip=mmdb --with-openssl --with-zlib"
].join("\n"));
assert.equal(capabilities.zlib, true);
assert.match(formatGoAccessCheck(capabilities), /Zlib enabled/);
assert.doesNotMatch(formatGoAccessCheck(capabilities), /older \.gz logs/);
});
test("configuration check rejects GoAccess without GeoIP2 MMDB support", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
try {
assert.throws(() => generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
check: true
}, {
checkGoAccess: () => ({
version: "1.8.1",
geoIpMmdb: false,
openSsl: true,
zlib: false
})
}), error => error.exitCode === 2
&& /must be built with GeoIP2\/MMDB support/.test(error.message));
} finally {
testFixture.cleanup();
}
});
test("configuration check verifies analytics-log readability", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true, rotated: false }]);
const originalAccessSync = fs.accessSync;
try {
fs.accessSync = (filePath, mode) => {
if (filePath === testFixture.registry.sites[0].analyticsLog
&& mode === fs.constants.R_OK) {
const error = new Error("permission denied");
error.code = "EACCES";
throw error;
}
return originalAccessSync(filePath, mode);
};
assert.throws(() => generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
check: true
}, {
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB
}), /analytics log is not readable by the current user/);
} finally {
fs.accessSync = originalAccessSync;
testFixture.cleanup();
}
});
test("configuration check reports missing output directories", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
try {
fs.rmSync(testFixture.registry.sites[0].reportOutputDirectory, {
recursive: true,
force: true
});
assert.throws(() => generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
check: true
}, {
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB
}), /report output directory for alpha does not exist/);
} finally {
testFixture.cleanup();
}
});
test("rejects a compressed log as the regular analytics input", () => {
const testFixture = fixture([{ id: "alpha", publicCounter: true }]);
try {
testFixture.registry.sites[0].analyticsLog += ".2.gz";
fs.writeFileSync(testFixture.registryPath, JSON.stringify(testFixture.registry));
assert.throws(() => generateReports({
registryPath: testFixture.registryPath,
configTemplatePath: testFixture.configTemplatePath,
check: true
}, {
checkGoAccess: () => GOACCESS_WITHOUT_ZLIB
}), /must identify the current uncompressed log/);
} finally {
testFixture.cleanup();
}
});
test("Nginx filters exclude non-page traffic before analytics logging", () => {
const source = fs.readFileSync(path.join(
__dirname,
"../ops/analytics/nginx/analytics-filters.conf.example"
), "utf8");
for (const excluded of [
"/visitor-count.json",
"/kst4ContestVersionInfo.xml",
"/sitemap.xml",
"/robots.txt",
"manual/assets",
"healthz"
]) {
assert.match(source, new RegExp(excluded.replaceAll("/", "\\/"), "i"));
}
assert.doesNotMatch(source, /^\s*=\//m);
assert.match(source, /^\s*\/visitor-count\.json\s+0;/m);
assert.match(source, /^\s*\/health\s+0;/m);
assert.match(source, /\|map\|/);
assert.match(source, /\$uri/);
assert.match(source, /known_bot/);
});
test("server templates use the production GeoIP path and permission model", () => {
const registry = JSON.parse(fs.readFileSync(path.join(
__dirname,
"../ops/analytics/sites.example.json"
), "utf8"));
const service = fs.readFileSync(path.join(
__dirname,
"../ops/analytics/systemd/hamradioonline-analytics.service.example"
), "utf8");
const generator = fs.readFileSync(path.join(
__dirname,
"../ops/analytics/generate-reports.js"
), "utf8");
assert.equal(registry.geoIpCountryDatabase, "/var/lib/GeoIP/GeoLite2-Country.mmdb");
assert.match(service, /\/var\/lib\/GeoIP\/GeoLite2-Country\.mmdb/);
assert.match(service, /^StateDirectoryMode=0711$/m);
assert.match(generator, /fs\.fchmodSync\(handle, mode\)/);
assert.match(generator, /publicFile\.destination, publicFile\.content, 0o644/);
});
test("public counter template disables logging and sets cache and type headers", () => {
const source = fs.readFileSync(path.join(
__dirname,
"../ops/analytics/nginx/public-counter.conf.example"
), "utf8");
assert.match(source, /^\s*access_log off;$/m);
assert.match(source, /Cache-Control "public, max-age=3600" always/);
assert.match(source, /X-Content-Type-Options "nosniff" always/);
});
test("statistics vhost preserves ACME renewal and protects HTTPS reports", () => {
const source = fs.readFileSync(path.join(
__dirname,
"../ops/analytics/nginx/stats-vhost.conf.example"
), "utf8");
const bootstrap = fs.readFileSync(path.join(
__dirname,
"../ops/analytics/nginx/stats-vhost-http-bootstrap.conf.example"
), "utf8");
const httpsOffset = source.search(/server\s*\{\s*listen 443 ssl http2;/);
assert.notEqual(httpsOffset, -1);
const httpSource = source.slice(0, httpsOffset);
const httpsSource = source.slice(httpsOffset);
assert.match(httpSource, /^\s*listen 80;$/m);
assert.match(httpSource, /^\s*access_log off;$/m);
assert.match(httpSource, /location \^~ \/\.well-known\/acme-challenge\//);
assert.match(httpSource, /root \/var\/lib\/letsencrypt;/);
assert.match(httpSource, /return 301 https:\/\/\$host\$request_uri;/);
assert.match(httpsSource, /^\s*listen 443 ssl http2;$/m);
assert.match(httpsSource, /^\s*auth_basic "Private project statistics";$/m);
assert.match(httpsSource, /^\s*access_log off;$/m);
assert.match(httpsSource, /location = \/[\s\S]*return 302 \/combined\/;/);
assert.match(httpsSource, /include \/etc\/letsencrypt\/options-ssl-nginx\.conf;/);
assert.match(httpsSource, /ssl_dhparam \/etc\/letsencrypt\/ssl-dhparams\.pem;/);
assert.doesNotMatch(source, /listen \[::\]/);
assert.match(bootstrap, /^\s*listen 80;$/m);
assert.doesNotMatch(bootstrap, /ssl_certificate/);
});
test("logrotate uses the Ubuntu Nginx rotation action", () => {
const source = fs.readFileSync(path.join(
__dirname,
"../ops/analytics/logrotate/hamradioonline-analytics.example"
), "utf8");
assert.match(source, /invoke-rc\.d nginx rotate >\/dev\/null 2>&1/);
assert.match(source, /^\s*delaycompress$/m);
assert.match(source, /^\s*rotate 14$/m);
assert.match(source, /^\s*create 0640 www-data hamradio-analytics$/m);
});
test("website package records the Node.js 18.19.1 baseline", () => {
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "../package.json")));
const packageLock = JSON.parse(fs.readFileSync(path.join(__dirname, "../package-lock.json")));
assert.equal(packageJson.engines.node, ">=18.19.1");
assert.equal(packageLock.packages[""].engines.node, ">=18.19.1");
});
+119
View File
@@ -0,0 +1,119 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const {
formatVisitorCount,
loadVisitorCount,
validateVisitorCount
} = require("../src/assets/js/visitor-count");
const VALID_DATA = {
schemaVersion: 1,
visits: 1234,
since: "2026-09-11",
updatedAt: "2026-09-11T07:00:00Z"
};
function element() {
return { hidden: true, textContent: "" };
}
test("formats valid data for the English website", async () => {
const target = element();
let request;
const shown = await loadVisitorCount({
element: target,
fetchImpl: async (url, options) => {
request = { url, options };
return { ok: true, json: async () => VALID_DATA };
}
});
assert.equal(shown, true);
assert.equal(target.hidden, false);
assert.equal(target.textContent, "Visits since 11 September 2026: 1,234");
assert.equal(request.url, "/visitor-count.json");
assert.equal(request.options.credentials, "omit");
});
test("rejects unsupported schemas and invalid visit counts", () => {
for (const changes of [
{ schemaVersion: 2 },
{ visits: -1 },
{ visits: 1.5 },
{ visits: Number.MAX_SAFE_INTEGER + 1 }
]) {
assert.equal(validateVisitorCount({ ...VALID_DATA, ...changes }), null);
}
});
test("rejects invalid dates and timestamps without timezone shifts", () => {
for (const changes of [
{ since: "2026-02-30" },
{ since: "11-09-2026" },
{ updatedAt: "2026-09-11" },
{ updatedAt: "2026-02-30T07:00:00Z" },
{ updatedAt: "2026-09-11T25:00:00Z" },
{ updatedAt: "not-a-timestamp" }
]) {
assert.equal(formatVisitorCount({ ...VALID_DATA, ...changes }), null);
}
});
test("keeps the element hidden for HTTP and fetch failures", async () => {
for (const fetchImpl of [
async () => ({ ok: false }),
async () => { throw new Error("network failure"); }
]) {
const target = element();
assert.equal(await loadVisitorCount({ element: target, fetchImpl }), false);
assert.equal(target.hidden, true);
assert.equal(target.textContent, "");
}
});
test("aborts a slow request and keeps the element hidden", async () => {
const target = element();
let aborted = false;
const fetchImpl = (url, options) => new Promise((resolve, reject) => {
options.signal.addEventListener("abort", () => {
aborted = true;
reject(new Error("aborted"));
});
});
assert.equal(await loadVisitorCount({
element: target,
fetchImpl,
timeoutMs: 5
}), false);
assert.equal(aborted, true);
assert.equal(target.hidden, true);
});
test("uses textContent and loads the script only for the home page", () => {
const script = fs.readFileSync(path.join(
__dirname,
"../src/assets/js/visitor-count.js"
), "utf8");
const home = fs.readFileSync(path.join(__dirname, "../src/index.njk"), "utf8");
const layout = fs.readFileSync(path.join(
__dirname,
"../src/_layouts/base.njk"
), "utf8");
const sourceRoot = path.join(__dirname, "../src");
const otherPages = fs.readdirSync(sourceRoot, { recursive: true })
.filter(entry => entry.endsWith(".njk") && entry !== "index.njk");
assert.doesNotMatch(script, /innerHTML/);
assert.match(script, /textContent/);
assert.match(home, /^visitorCount: true$/m);
assert.match(layout, /\{% if visitorCount %\}/);
assert.equal(
otherPages.some(entry => fs.readFileSync(path.join(sourceRoot, entry), "utf8")
.includes("visitorCount: true")),
false
);
});