tacilytics 2.0.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +12 -1
- package/README.md +96 -3
- package/auth.js +788 -0
- package/cli.js +36 -0
- package/importers.js +223 -0
- package/package.json +10 -2
- package/public/account.js +570 -0
- package/public/app.js +229 -41
- package/public/assets/icons.svg +6 -0
- package/public/i18n.js +242 -0
- package/public/index.html +194 -37
- package/public/style.css +140 -0
- package/public/tacilytics.js +41 -1
- package/public/theme.js +14 -0
- package/report.js +300 -0
- package/server.js +572 -110
- package/tz.js +96 -0
- package/webhook.js +109 -0
package/server.js
CHANGED
|
@@ -34,6 +34,8 @@ const INSIDE_NODE_MODULES = __dirname.split(path.sep).includes('node_modules');
|
|
|
34
34
|
const DB_PATH = process.env.DB_PATH || path.join(INSIDE_NODE_MODULES ? process.cwd() : __dirname, 'data', 'analytics.db');
|
|
35
35
|
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
|
36
36
|
if (!process.env.WEB_PASSWORD) console.warn('⚠ WEB_PASSWORD is not set — the dashboard uses the default passphrase "tacilytics". Set your own in .env.');
|
|
37
|
+
// Webhook for the daily digest and the default alerts (Discord/Slack-compatible).
|
|
38
|
+
const DIGEST_WEBHOOK_URL = /^(off|none|false|0)$/i.test(process.env.DIGEST_WEBHOOK_URL || '') ? '' : (process.env.DIGEST_WEBHOOK_URL || ''); // 'off' disables it (an empty value would be refilled from .env)
|
|
37
39
|
const RETENTION_DAYS = parseInt(process.env.DATA_RETENTION_DAYS, 10) || 0; // 0 = keep forever; >0 purges older raw events (data minimisation)
|
|
38
40
|
|
|
39
41
|
// ── DB ──────────────────────────────────────────────────────────────────────
|
|
@@ -101,11 +103,48 @@ db.exec(`
|
|
|
101
103
|
steps TEXT NOT NULL, -- JSON array of {type:'page'|'goal', value}
|
|
102
104
|
created INTEGER NOT NULL
|
|
103
105
|
);
|
|
106
|
+
-- imported history (Plausible / Google Analytics): daily aggregates, never visitor rows
|
|
107
|
+
CREATE TABLE IF NOT EXISTS imported (
|
|
108
|
+
import_id INTEGER NOT NULL,
|
|
109
|
+
site_id INTEGER NOT NULL,
|
|
110
|
+
day TEXT NOT NULL,
|
|
111
|
+
dim TEXT NOT NULL, -- '' totals · page · source · country · browser · os · device · event
|
|
112
|
+
name TEXT NOT NULL,
|
|
113
|
+
visitors INTEGER NOT NULL DEFAULT 0,
|
|
114
|
+
pageviews INTEGER NOT NULL DEFAULT 0,
|
|
115
|
+
visits INTEGER NOT NULL DEFAULT 0,
|
|
116
|
+
bounces INTEGER NOT NULL DEFAULT 0,
|
|
117
|
+
duration INTEGER NOT NULL DEFAULT 0
|
|
118
|
+
);
|
|
119
|
+
CREATE INDEX IF NOT EXISTS idx_imported_site_dim_day ON imported(site_id, dim, day);
|
|
120
|
+
CREATE TABLE IF NOT EXISTS imports (
|
|
121
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
122
|
+
site_id INTEGER NOT NULL,
|
|
123
|
+
source TEXT NOT NULL, -- 'plausible' | 'ga'
|
|
124
|
+
first_day TEXT, last_day TEXT,
|
|
125
|
+
rows INTEGER NOT NULL DEFAULT 0,
|
|
126
|
+
pageviews INTEGER NOT NULL DEFAULT 0,
|
|
127
|
+
files TEXT,
|
|
128
|
+
created_by TEXT,
|
|
129
|
+
created INTEGER NOT NULL
|
|
130
|
+
);
|
|
131
|
+
-- chart annotations ("Newsletter sent", "Release 2.0") — one calendar day each
|
|
132
|
+
CREATE TABLE IF NOT EXISTS annotations (
|
|
133
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
134
|
+
site_id INTEGER NOT NULL,
|
|
135
|
+
day TEXT NOT NULL, -- YYYY-MM-DD (UTC, like events.day)
|
|
136
|
+
text TEXT NOT NULL,
|
|
137
|
+
created_by TEXT,
|
|
138
|
+
created INTEGER NOT NULL
|
|
139
|
+
);
|
|
140
|
+
CREATE INDEX IF NOT EXISTS idx_annotations_site_day ON annotations(site_id, day);
|
|
104
141
|
`);
|
|
105
142
|
|
|
106
143
|
const norm = (d) => String(d || '').toLowerCase().replace(/^www\./, '').replace(/\/.*$/, '').trim();
|
|
144
|
+
// per-site time zone (Fabian): only query boundaries move, stored days stay UTC (Tyto). Old installs → 'UTC'.
|
|
145
|
+
if (!db.prepare('PRAGMA table_info(sites)').all().some((c) => c.name === 'timezone')) db.exec("ALTER TABLE sites ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC'");
|
|
107
146
|
const getSiteByDomain = db.prepare('SELECT * FROM sites WHERE domain = ?');
|
|
108
|
-
const insSite = db.prepare(
|
|
147
|
+
const insSite = db.prepare("INSERT OR IGNORE INTO sites (domain, name, created, timezone) VALUES (?, ?, ?, COALESCE(?, 'UTC'))");
|
|
109
148
|
const allSites = db.prepare('SELECT * FROM sites ORDER BY domain');
|
|
110
149
|
const insEvent = db.prepare(`INSERT INTO events (site_id, ts, day, path, source, referrer, country, tz, browser, os, device, lang, name, utm_medium, utm_campaign, dur, props, vh, platform, app_version)
|
|
111
150
|
VALUES (@site_id, @ts, @day, @path, @source, @referrer, @country, @tz, @browser, @os, @device, @lang, @name, @utm_medium, @utm_campaign, @dur, @props, @vh, @platform, @app_version)`);
|
|
@@ -216,16 +255,35 @@ function visitorHash(siteId, ip, ua, day) {
|
|
|
216
255
|
if (!salt) return crypto.randomBytes(12).toString('hex'); // unlinkable one-off visitor
|
|
217
256
|
return crypto.createHash('sha256').update(`${INGEST_SALT}|${salt}|${day}|${siteId}|${ip}|${ua}`).digest('hex').slice(0, 24);
|
|
218
257
|
}
|
|
258
|
+
// The client writes the FIRST X-Forwarded-For entry itself, so it must never be used for rate limits.
|
|
259
|
+
// req.ip honours 'trust proxy': with TRUST_PROXY=1 (default, one reverse proxy in front) it is the
|
|
260
|
+
// entry that proxy appended. Set TRUST_PROXY=0 when the server is reachable without a proxy.
|
|
219
261
|
function clientIp(req) {
|
|
220
|
-
|
|
221
|
-
return xff || req.socket.remoteAddress || '0.0.0.0';
|
|
262
|
+
return req.ip || req.socket.remoteAddress || '0.0.0.0';
|
|
222
263
|
}
|
|
223
264
|
|
|
224
265
|
// ── app ─────────────────────────────────────────────────────────────────────
|
|
225
266
|
const app = express();
|
|
226
|
-
|
|
267
|
+
const TRUST_PROXY = Number.isFinite(+process.env.TRUST_PROXY) && process.env.TRUST_PROXY !== '' && process.env.TRUST_PROXY != null ? +process.env.TRUST_PROXY : 1;
|
|
268
|
+
app.set('trust proxy', TRUST_PROXY);
|
|
269
|
+
// A real HTTP proxy always appends X-Forwarded-For. A request without it means something else
|
|
270
|
+
// (socat, ssh -L, a TCP forwarder) is in front: then the last XFF entry is client-written and the
|
|
271
|
+
// limits can be bypassed. Normal visitors never send XFF themselves, so they trigger this; an
|
|
272
|
+
// attacker who forges XFF does not, but the warning is already in the log by then. Warn once.
|
|
273
|
+
let warnedNoXff = false;
|
|
274
|
+
if (TRUST_PROXY > 0) {
|
|
275
|
+
app.use((req, _res, next) => {
|
|
276
|
+
if (!warnedNoXff && !req.headers['x-forwarded-for']) {
|
|
277
|
+
warnedNoXff = true;
|
|
278
|
+
console.warn(`⚠️ TRUST_PROXY=${TRUST_PROXY}, but a request arrived without X-Forwarded-For (from ${req.socket.remoteAddress}, ${req.method} ${req.path}, ${new Date().toISOString()}). Harmless for local health checks/CLI or an ssh -L to the dashboard. Only if ALL traffic comes through a TCP forwarder (socat, ssh -L), set TRUST_PROXY=0.`);
|
|
279
|
+
}
|
|
280
|
+
next();
|
|
281
|
+
});
|
|
282
|
+
}
|
|
227
283
|
// Accept both application/json (dashboard) and text/plain (cross-origin beacons).
|
|
228
|
-
|
|
284
|
+
// /api/imports takes raw files (handled there); everything else is JSON (text/plain = cross-origin beacons)
|
|
285
|
+
const jsonBody = express.json({ limit: '32kb', type: ['application/json', 'text/plain'] });
|
|
286
|
+
app.use((req, res, next) => (req.path === '/api/imports' ? next() : jsonBody(req, res, next)));
|
|
229
287
|
app.disable('x-powered-by');
|
|
230
288
|
|
|
231
289
|
// Lightweight in-memory rate limiter (fixed window per key). No external dep.
|
|
@@ -326,35 +384,16 @@ app.options('/api/collect', (_req, res) => { res.set('Access-Control-Allow-Origi
|
|
|
326
384
|
app.post('/api/collect', collect);
|
|
327
385
|
app.get('/api/collect', collect);
|
|
328
386
|
|
|
329
|
-
// ── auth
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
app
|
|
333
|
-
|
|
334
|
-
const pw = String((req.body && req.body.password) || '');
|
|
335
|
-
const ok = pw.length === WEB_PASSWORD.length && crypto.timingSafeEqual(Buffer.from(pw), Buffer.from(WEB_PASSWORD));
|
|
336
|
-
if (!ok) return res.status(401).json({ error: 'Wrong password' });
|
|
337
|
-
const token = newToken();
|
|
338
|
-
sessions.set(token, { expires: Date.now() + 7 * 864e5 });
|
|
339
|
-
res.json({ token });
|
|
387
|
+
// ── auth: accounts, team, optional 2FA + passkeys (auth.js) ───────────────────
|
|
388
|
+
const { setupAuth } = require('./auth');
|
|
389
|
+
const { auth, requireRole, siteAllowed, audit } = setupAuth({
|
|
390
|
+
app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp,
|
|
391
|
+
allSiteIds: () => db.prepare('SELECT id FROM sites').all().map((r) => r.id),
|
|
340
392
|
});
|
|
341
|
-
|
|
342
|
-
if (!API_KEY) return false;
|
|
343
|
-
const hdr = String(req.headers['authorization'] || '');
|
|
344
|
-
const key = hdr.startsWith('Bearer ') ? hdr.slice(7) : String(req.headers['x-api-key'] || '');
|
|
345
|
-
return key.length === API_KEY.length && crypto.timingSafeEqual(Buffer.from(key), Buffer.from(API_KEY));
|
|
346
|
-
}
|
|
347
|
-
function auth(req, res, next) {
|
|
348
|
-
if (checkApiKey(req)) return next(); // agents / CLI
|
|
349
|
-
const t = req.headers['x-session-token'];
|
|
350
|
-
const s = t && sessions.get(t);
|
|
351
|
-
if (s && s.expires > Date.now()) return next();
|
|
352
|
-
if (s) sessions.delete(t);
|
|
353
|
-
res.status(401).json({ error: 'Not authenticated' });
|
|
354
|
-
}
|
|
393
|
+
const adminOnly = requireRole('admin');
|
|
355
394
|
|
|
356
395
|
// share-token auth: read-only stats access scoped to exactly one site.
|
|
357
|
-
// Accepted as ?share=<token>; the handler must
|
|
396
|
+
// Accepted as ?share=<token>; the handler must check siteOk(req, site).
|
|
358
397
|
const getShare = db.prepare('SELECT * FROM shares WHERE token = ?');
|
|
359
398
|
function authOrShare(req, res, next) {
|
|
360
399
|
const tok = String(req.query.share || '');
|
|
@@ -364,10 +403,13 @@ function authOrShare(req, res, next) {
|
|
|
364
403
|
}
|
|
365
404
|
return auth(req, res, next);
|
|
366
405
|
}
|
|
367
|
-
|
|
406
|
+
// share links see exactly their site; viewers only the sites they were given
|
|
407
|
+
const siteOk = (req, site) => siteAllowed(req, site.id);
|
|
408
|
+
const NO_SITE = { error: 'You have no access to this site' };
|
|
409
|
+
function newToken() { return crypto.randomBytes(24).toString('hex'); }
|
|
368
410
|
|
|
369
411
|
// ── sites management ─────────────────────────────────────────────────────────
|
|
370
|
-
const sitesListHandler = (
|
|
412
|
+
const sitesListHandler = (req, res) => res.json(allSites.all().filter((st) => siteAllowed(req, st.id)));
|
|
371
413
|
const sitesCreateHandler = (req, res) => {
|
|
372
414
|
const rawDomain = norm(req.body && req.body.domain);
|
|
373
415
|
let domain = rawDomain;
|
|
@@ -378,14 +420,37 @@ const sitesCreateHandler = (req, res) => {
|
|
|
378
420
|
}
|
|
379
421
|
// Allow ASCII domains AND Punycode (xn--...). Unicode input was converted above.
|
|
380
422
|
if (!domain || !/^[a-z0-9.-]+\.[a-z0-9-]{2,}$/.test(domain)) return res.status(400).json({ error: 'Enter a valid domain, e.g. example.com' });
|
|
381
|
-
|
|
423
|
+
const rawTz = req.body && req.body.timezone;
|
|
424
|
+
const timezone = rawTz == null || rawTz === '' ? null : TZ.validTz(String(rawTz));
|
|
425
|
+
if (rawTz != null && rawTz !== '' && !timezone) return res.status(400).json({ error: 'Unknown time zone' });
|
|
426
|
+
insSite.run(domain, (req.body && req.body.name) || displayName, Date.now(), timezone);
|
|
427
|
+
audit(res, 'site.add', domain);
|
|
382
428
|
res.json(getSiteByDomain.get(domain));
|
|
383
429
|
};
|
|
384
430
|
app.get('/api/sites', auth, sitesListHandler);
|
|
385
|
-
app.post('/api/sites',
|
|
386
|
-
|
|
431
|
+
app.post('/api/sites', adminOnly, sitesCreateHandler);
|
|
432
|
+
// change a site's time zone (IANA names only — anything Intl rejects is a 400, never stored)
|
|
433
|
+
app.patch('/api/sites/:id', adminOnly, (req, res) => {
|
|
434
|
+
const id = Number(req.params.id);
|
|
435
|
+
if (!siteAllowed(req, id)) return res.status(403).json(NO_SITE);
|
|
436
|
+
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(id);
|
|
437
|
+
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
438
|
+
const timezone = TZ.validTz(String((req.body && req.body.timezone) || ''));
|
|
439
|
+
if (!timezone) return res.status(400).json({ error: 'Unknown time zone' });
|
|
440
|
+
db.prepare('UPDATE sites SET timezone = ? WHERE id = ?').run(timezone, id);
|
|
441
|
+
if (timezone !== site.timezone) audit(res, 'site.timezone', site.domain + ': ' + site.timezone + ' → ' + timezone);
|
|
442
|
+
res.json(db.prepare('SELECT * FROM sites WHERE id = ?').get(id));
|
|
443
|
+
});
|
|
444
|
+
app.delete('/api/sites/:id', adminOnly, (req, res) => {
|
|
387
445
|
const id = Number(req.params.id);
|
|
446
|
+
if (!siteAllowed(req, id)) return res.status(403).json(NO_SITE);
|
|
447
|
+
const gone = db.prepare('SELECT domain FROM sites WHERE id = ?').get(id);
|
|
448
|
+
audit(res, 'site.remove', gone ? gone.domain : '#' + id);
|
|
388
449
|
db.prepare('DELETE FROM events WHERE site_id = ?').run(id);
|
|
450
|
+
db.prepare('DELETE FROM annotations WHERE site_id = ?').run(id);
|
|
451
|
+
db.prepare('DELETE FROM alerts WHERE site_id = ?').run(id);
|
|
452
|
+
db.prepare('DELETE FROM imported WHERE site_id = ?').run(id);
|
|
453
|
+
db.prepare('DELETE FROM imports WHERE site_id = ?').run(id);
|
|
389
454
|
db.prepare('DELETE FROM sites WHERE id = ?').run(id);
|
|
390
455
|
res.json({ ok: true });
|
|
391
456
|
});
|
|
@@ -403,27 +468,33 @@ app.get('/api/v1', (_req, res) => res.json({
|
|
|
403
468
|
filters: 'add any of country,source,page,browser,os,device,lang,medium,campaign,goal to /stats to segment',
|
|
404
469
|
}));
|
|
405
470
|
app.get('/api/v1/sites', auth, sitesListHandler);
|
|
406
|
-
app.post('/api/v1/sites',
|
|
471
|
+
app.post('/api/v1/sites', adminOnly, sitesCreateHandler);
|
|
407
472
|
|
|
408
473
|
// ── stats ────────────────────────────────────────────────────────────────────
|
|
474
|
+
const TZ = require('./tz');
|
|
409
475
|
const PERIODS = { today: 1, '7d': 7, '30d': 30, '90d': 90, '12mo': 365 };
|
|
410
|
-
|
|
476
|
+
// Whole LOCAL days of the site's time zone: "7d" = today + the 6 full days before it, each day with
|
|
477
|
+
// its own boundaries (23/25 h on DST days). Starting at now - 7×24h would cut the oldest day off at the
|
|
478
|
+
// current clock time and undercount it.
|
|
479
|
+
function rangeFor(query, tz) {
|
|
480
|
+
tz = tz || 'UTC';
|
|
411
481
|
const now = Date.now();
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
482
|
+
const today = TZ.localDay(tz, now);
|
|
483
|
+
// custom range: from/to are local YYYY-MM-DD (inclusive)
|
|
484
|
+
const isDay = (d) => /^\d{4}-\d{2}-\d{2}$/.test(d || '') && !Number.isNaN(Date.parse(d + 'T00:00:00Z'));
|
|
485
|
+
if (isDay(query.from)) {
|
|
486
|
+
let fromDay = query.from, toDay = isDay(query.to) ? query.to : query.from;
|
|
487
|
+
if (toDay < fromDay) toDay = fromDay;
|
|
488
|
+
const start = TZ.dayStart(tz, fromDay);
|
|
489
|
+
const end = Math.max(start + 1, Math.min(now, TZ.dayStart(tz, TZ.addDays(toDay, 1))));
|
|
490
|
+
const days = Math.max(1, Math.round((Date.parse(toDay + 'T00:00:00Z') - Date.parse(fromDay + 'T00:00:00Z')) / 864e5) + 1);
|
|
491
|
+
const lastDay = TZ.localDay(tz, end - 1) < toDay ? TZ.localDay(tz, end - 1) : toDay;
|
|
492
|
+
return { start, end, days: Math.min(days, Math.round((Date.parse(lastDay + 'T00:00:00Z') - Date.parse(fromDay + 'T00:00:00Z')) / 864e5) + 1), period: 'custom', hourly: days <= 1, tz, fromDay, toDay: lastDay };
|
|
419
493
|
}
|
|
420
494
|
const period = PERIODS[query.period] ? query.period : '7d';
|
|
421
495
|
const days = PERIODS[period];
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
const today0 = Date.parse(new Date(now).toISOString().slice(0, 10) + 'T00:00:00Z');
|
|
425
|
-
const start = today0 - (days - 1) * 864e5;
|
|
426
|
-
return { start, end: now, days, period, hourly: period === 'today' };
|
|
496
|
+
const fromDay = TZ.addDays(today, -(days - 1));
|
|
497
|
+
return { start: TZ.dayStart(tz, fromDay), end: now, days, period, hourly: period === 'today', tz, fromDay, toDay: today };
|
|
427
498
|
}
|
|
428
499
|
|
|
429
500
|
// Whitelisted segmentation filters — click any breakdown row in the dashboard to drill in.
|
|
@@ -449,12 +520,51 @@ function eventPlatformFilter(query) {
|
|
|
449
520
|
return f;
|
|
450
521
|
}
|
|
451
522
|
|
|
523
|
+
// name shown as "created by" (same wording as the activity log)
|
|
524
|
+
function whoDid(req) {
|
|
525
|
+
const p = req.principal;
|
|
526
|
+
if (!p) return null;
|
|
527
|
+
if (p.kind === 'user') return p.user.username;
|
|
528
|
+
if (p.kind === 'userkey') return p.user.username + ' (API key)';
|
|
529
|
+
if (p.kind === 'apikey') return 'API key';
|
|
530
|
+
if (p.kind === 'legacy') return 'passphrase';
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ── imported history helpers ──
|
|
535
|
+
const firstNativeTs = db.prepare('SELECT MIN(ts) t FROM events WHERE site_id = ?');
|
|
536
|
+
// first LOCAL day with own data in the site's time zone (import boundary — shown in the import dialog too)
|
|
537
|
+
function firstOwnDay(site) { const t = firstNativeTs.get(site.id).t; return t == null ? null : TZ.localDay(site.timezone || 'UTC', t); }
|
|
538
|
+
function importedFor(siteId, start, end, filtered, tz) {
|
|
539
|
+
const exists = !!db.prepare('SELECT 1 FROM imports WHERE site_id = ? LIMIT 1').get(siteId);
|
|
540
|
+
const off = { exists, on: false, filtered: exists && filtered, tot: { pageviews: 0, visitors: 0 }, days: [], top: () => [], until: null };
|
|
541
|
+
if (!exists || filtered) return off;
|
|
542
|
+
// imported days count only before the first LOCAL day with own data (events.day is the UTC day)
|
|
543
|
+
const firstTs = firstNativeTs.get(siteId).t;
|
|
544
|
+
const first = firstTs == null ? null : TZ.localDay(tz, firstTs);
|
|
545
|
+
// imported rows are calendar days of the source; they are matched against the site's local days
|
|
546
|
+
const from = TZ.localDay(tz, start);
|
|
547
|
+
const lastDay = TZ.localDay(tz, end - 1);
|
|
548
|
+
const to = first && first <= lastDay ? TZ.addDays(first, -1) : lastDay;
|
|
549
|
+
if (to < from) return { ...off, until: first };
|
|
550
|
+
const days = db.prepare("SELECT day, SUM(visitors) visitors, SUM(pageviews) pageviews FROM imported WHERE site_id = ? AND dim = '' AND day >= ? AND day <= ? GROUP BY day").all(siteId, from, to);
|
|
551
|
+
const tot = days.reduce((a, r) => ({ pageviews: a.pageviews + r.pageviews, visitors: a.visitors + r.visitors }), { pageviews: 0, visitors: 0 });
|
|
552
|
+
const top = (dim) => db.prepare('SELECT name, SUM(pageviews) pageviews, SUM(visitors) visitors FROM imported WHERE site_id = ? AND dim = ? AND day >= ? AND day <= ? GROUP BY name ORDER BY visitors DESC, pageviews DESC LIMIT 200').all(siteId, dim, from, to);
|
|
553
|
+
return { exists, on: days.length > 0, filtered: false, tot, days, top, until: first };
|
|
554
|
+
}
|
|
555
|
+
function mergeTop(native, imported, limit) {
|
|
556
|
+
const m = new Map();
|
|
557
|
+
for (const r of native) m.set(r.name, { name: r.name, pageviews: r.pageviews, visitors: r.visitors });
|
|
558
|
+
for (const r of imported) { const e = m.get(r.name) || { name: r.name, pageviews: 0, visitors: 0 }; e.pageviews += r.pageviews; e.visitors += r.visitors; m.set(r.name, e); }
|
|
559
|
+
return [...m.values()].sort((a, b) => b.visitors - a.visitors || b.pageviews - a.pageviews).slice(0, limit);
|
|
560
|
+
}
|
|
561
|
+
|
|
452
562
|
function statsHandler(req, res) {
|
|
453
563
|
const site = getSiteByDomain.get(norm(req.query.site));
|
|
454
564
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
455
|
-
if (!
|
|
565
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
456
566
|
const sid = site.id;
|
|
457
|
-
const { start, end, days, period, hourly } = rangeFor(req.query);
|
|
567
|
+
const { start, end, days, period, hourly, tz, fromDay, toDay } = rangeFor(req.query, site.timezone);
|
|
458
568
|
const { clauses, params, goal } = buildFilters(req.query);
|
|
459
569
|
const evf = eventPlatformFilter(req.query); // platform/version also narrow event panels (revenue, search, 404)
|
|
460
570
|
|
|
@@ -466,6 +576,10 @@ function statsHandler(req, res) {
|
|
|
466
576
|
if (goal) { where += ' AND vh IN (SELECT vh FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name = ?)'; args.push(sid, start, end, goal); }
|
|
467
577
|
|
|
468
578
|
const totals = db.prepare(`SELECT COUNT(*) AS pageviews, COUNT(DISTINCT vh) AS visitors FROM events WHERE ${where}`).get(...args);
|
|
579
|
+
// imported history counts only for days before the site's first own event (no double counting)
|
|
580
|
+
// and only without segment filters (imported data is pre-aggregated per single dimension).
|
|
581
|
+
const imp = importedFor(sid, start, end, clauses.length > 0 || !!goal || !!evf, tz);
|
|
582
|
+
if (imp.on) { totals.pageviews += imp.tot.pageviews; totals.visitors += imp.tot.visitors; }
|
|
469
583
|
const bounceRow = db.prepare(`SELECT COUNT(*) AS bounced FROM (SELECT vh, COUNT(*) c FROM events WHERE ${where} GROUP BY vh HAVING c = 1)`).get(...args);
|
|
470
584
|
const bounce = totals.visitors ? Math.round((bounceRow.bounced / totals.visitors) * 100) : 0;
|
|
471
585
|
const viewsPerVisitor = totals.visitors ? +(totals.pageviews / totals.visitors).toFixed(1) : 0;
|
|
@@ -477,30 +591,37 @@ function statsHandler(req, res) {
|
|
|
477
591
|
const prevArgs = args.slice(); prevArgs[1] = start - len; prevArgs[2] = start;
|
|
478
592
|
if (goal) { prevArgs[prevArgs.length - 3] = start - len; prevArgs[prevArgs.length - 2] = start; }
|
|
479
593
|
const prev = db.prepare(`SELECT COUNT(*) pageviews, COUNT(DISTINCT vh) visitors FROM events WHERE ${where}`).get(...prevArgs);
|
|
594
|
+
if (imp.on) { const pi = importedFor(sid, start - len, start, false, tz); if (pi.on) { prev.pageviews += pi.tot.pageviews; prev.visitors += pi.tot.visitors; } }
|
|
480
595
|
const pct = (cur, was) => (was ? Math.round(((cur - was) / was) * 100) : null);
|
|
481
596
|
|
|
482
597
|
// time series (per hour for a single day, else per day)
|
|
483
598
|
let series;
|
|
484
599
|
if (hourly) {
|
|
485
|
-
|
|
600
|
+
// one bucket per real hour of the local day (23 or 25 on DST days), labelled with the local
|
|
601
|
+
// hour. Buckets start at local midnight, so +5:45 zones are not cut at UTC hours.
|
|
602
|
+
const dayEnd = TZ.dayStart(tz, TZ.addDays(fromDay, 1));
|
|
603
|
+
const rows = db.prepare(`SELECT CAST(((ts - ${Math.trunc(start)}) / 3600000) AS INT) AS bucket, COUNT(*) pv, COUNT(DISTINCT vh) v FROM events WHERE ${where} GROUP BY bucket`).all(...args);
|
|
486
604
|
const map = new Map(rows.map((r) => [r.bucket, r]));
|
|
487
|
-
const
|
|
488
|
-
series = Array.from({ length:
|
|
489
|
-
const r = map.get(
|
|
490
|
-
|
|
605
|
+
const n = Math.round((dayEnd - start) / 3600000);
|
|
606
|
+
series = Array.from({ length: n }, (_, i) => {
|
|
607
|
+
const r = map.get(i);
|
|
608
|
+
const t = start + i * 3600000;
|
|
609
|
+
const lab = new Date(t + TZ.offsetMin(tz, t) * 60000).toISOString().slice(11, 13);
|
|
610
|
+
return { label: lab, visitors: r ? r.v : 0, pageviews: r ? r.pv : 0 };
|
|
491
611
|
});
|
|
492
612
|
} else {
|
|
493
|
-
const rows = db.prepare(`SELECT
|
|
494
|
-
const map = new Map(rows.map((r) => [r.
|
|
613
|
+
const rows = db.prepare(`SELECT ${TZ.daySql(tz, start, end)} AS lday, COUNT(*) pv, COUNT(DISTINCT vh) v FROM events WHERE ${where} GROUP BY lday`).all(...args);
|
|
614
|
+
const map = new Map(rows.map((r) => [r.lday, r]));
|
|
615
|
+
if (imp.on) for (const r of imp.days) { const e = map.get(r.day) || { lday: r.day, pv: 0, v: 0 }; e.pv += r.pageviews; e.v += r.visitors; map.set(r.day, e); }
|
|
495
616
|
series = Array.from({ length: days }, (_, i) => {
|
|
496
|
-
const d =
|
|
617
|
+
const d = TZ.addDays(fromDay, i);
|
|
497
618
|
const r = map.get(d);
|
|
498
619
|
return { label: d.slice(5), day: d, visitors: r ? r.v : 0, pageviews: r ? r.pv : 0 };
|
|
499
620
|
});
|
|
500
621
|
}
|
|
501
622
|
|
|
502
623
|
// previous year: same window shifted by 364 days (same weekdays). Null until data reaches back that far.
|
|
503
|
-
const yoyStart =
|
|
624
|
+
const yoyStart = TZ.dayStart(tz, TZ.addDays(fromDay, -364)), yoyEnd = yoyStart + (end - start);
|
|
504
625
|
const firstTs = db.prepare('SELECT MIN(ts) t FROM events WHERE site_id = ?').get(sid).t;
|
|
505
626
|
let yoy = null;
|
|
506
627
|
if (firstTs != null && firstTs <= yoyStart) {
|
|
@@ -510,9 +631,13 @@ function statsHandler(req, res) {
|
|
|
510
631
|
yoy = { visitors: pct(totals.visitors, y.visitors), pageviews: pct(totals.pageviews, y.pageviews), was: y };
|
|
511
632
|
}
|
|
512
633
|
|
|
513
|
-
const
|
|
634
|
+
const topByRaw = (col, limit = 8) => db.prepare(
|
|
514
635
|
`SELECT ${col} AS name, COUNT(*) AS pageviews, COUNT(DISTINCT vh) AS visitors FROM events WHERE ${where} AND ${col} IS NOT NULL AND ${col} != '' GROUP BY ${col} ORDER BY visitors DESC, pageviews DESC LIMIT ${limit}`,
|
|
515
636
|
).all(...args);
|
|
637
|
+
const IMP_DIM = { path: 'page', country: 'country', browser: 'browser', os: 'os', device: 'device' };
|
|
638
|
+
// native + imported for the same dimension; visitors are summed per day-bucket (an approximation,
|
|
639
|
+
// like every imported breakdown — imported data has no visitor identity to deduplicate)
|
|
640
|
+
const topBy = (col, limit = 8) => (imp.on && IMP_DIM[col] ? mergeTop(topByRaw(col, 200), imp.top(IMP_DIM[col]), limit) : topByRaw(col, limit));
|
|
516
641
|
|
|
517
642
|
// entry / exit pages: first / last pageview of each visit (vh,day), counted as visits.
|
|
518
643
|
const edge = (dir, limit = 8) => db.prepare(
|
|
@@ -525,7 +650,8 @@ function statsHandler(req, res) {
|
|
|
525
650
|
const goalRows = db.prepare(
|
|
526
651
|
`SELECT name, COUNT(*) AS pageviews, COUNT(DISTINCT vh) AS visitors FROM events WHERE site_id = ? AND ts >= ? AND ts < ?${evf} AND name IS NOT NULL GROUP BY name ORDER BY visitors DESC, pageviews DESC LIMIT 12`,
|
|
527
652
|
).all(sid, start, end);
|
|
528
|
-
const
|
|
653
|
+
const goalRowsAll = imp.on ? mergeTop(goalRows, imp.top('event'), 12) : goalRows;
|
|
654
|
+
const goals = goalRowsAll.map((g) => ({ ...g, cr: totals.visitors ? +((g.visitors / totals.visitors) * 100).toFixed(1) : 0 }));
|
|
529
655
|
|
|
530
656
|
// properties: only meaningful when drilled into a single goal — top values per property key.
|
|
531
657
|
let properties = null;
|
|
@@ -551,6 +677,7 @@ function statsHandler(req, res) {
|
|
|
551
677
|
const srcAll = db.prepare(
|
|
552
678
|
`SELECT source AS name, COUNT(*) AS pageviews, COUNT(DISTINCT vh) AS visitors FROM events WHERE ${where} AND source IS NOT NULL AND source != '' GROUP BY source ORDER BY visitors DESC, pageviews DESC LIMIT 40`,
|
|
553
679
|
).all(...args);
|
|
680
|
+
if (imp.on) { const merged = mergeTop(srcAll, imp.top('source'), 40); srcAll.length = 0; srcAll.push(...merged); }
|
|
554
681
|
const sourcesClean = srcAll.filter((r) => !isPaymentHost(r.name)).slice(0, 8);
|
|
555
682
|
const paymentReturns = srcAll.filter((r) => isPaymentHost(r.name));
|
|
556
683
|
|
|
@@ -630,14 +757,17 @@ function statsHandler(req, res) {
|
|
|
630
757
|
|
|
631
758
|
res.json({
|
|
632
759
|
site: { domain: site.domain, name: site.name },
|
|
633
|
-
period, range: { from:
|
|
760
|
+
period, timezone: tz, range: { from: fromDay, to: toDay },
|
|
634
761
|
filters: Object.assign({}, ...Object.keys(FILTER_COLS).filter((k) => req.query[k]).map((k) => ({ [k]: req.query[k] })), goal ? { goal } : {}),
|
|
635
762
|
totals: { visitors: totals.visitors, pageviews: totals.pageviews, bounce, viewsPerVisitor, duration },
|
|
636
763
|
trend: { visitors: pct(totals.visitors, prev.visitors), pageviews: pct(totals.pageviews, prev.pageviews) },
|
|
637
764
|
previous: { visitors: prev.visitors, pageviews: prev.pageviews },
|
|
638
765
|
yoy,
|
|
639
|
-
dataSince: firstTs ?
|
|
766
|
+
dataSince: firstTs ? TZ.localDay(tz, firstTs) : null,
|
|
767
|
+
imported: imp.exists ? { included: imp.on, excludedByFilter: imp.filtered, until: imp.until } : null,
|
|
640
768
|
series,
|
|
769
|
+
annotations: db.prepare('SELECT id, day, text, created_by FROM annotations WHERE site_id = ? AND day >= ? AND day <= ? ORDER BY day, id')
|
|
770
|
+
.all(sid, fromDay, toDay),
|
|
641
771
|
pages: topBy('path', 10),
|
|
642
772
|
entryPages: edge('ASC', 8),
|
|
643
773
|
exitPages: edge('DESC', 8),
|
|
@@ -669,7 +799,7 @@ app.get('/api/v1/stats', authOrShare, statsHandler);
|
|
|
669
799
|
function realtimeHandler(req, res) {
|
|
670
800
|
const site = getSiteByDomain.get(norm(req.query.site));
|
|
671
801
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
672
|
-
if (!
|
|
802
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
673
803
|
const sid = site.id, now = Date.now(), since = now - 30 * 60000;
|
|
674
804
|
const online = db.prepare('SELECT COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND name IS NULL AND ts >= ?').get(sid, now - 5 * 60000).v;
|
|
675
805
|
const rows = db.prepare('SELECT CAST((ts/60000) AS INT) b, COUNT(*) pv, COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND name IS NULL AND ts >= ? GROUP BY b').all(sid, since);
|
|
@@ -683,18 +813,24 @@ app.get('/api/realtime', authOrShare, realtimeHandler);
|
|
|
683
813
|
app.get('/api/v1/realtime', authOrShare, realtimeHandler);
|
|
684
814
|
|
|
685
815
|
// ── share links: create / list / revoke (owner only) + resolve (public) ──────────
|
|
686
|
-
app.get('/api/shares',
|
|
687
|
-
const rows = db.prepare('SELECT s.token, s.created, st.domain FROM shares s JOIN sites st ON st.id = s.site_id ORDER BY s.created DESC').all()
|
|
816
|
+
app.get('/api/shares', adminOnly, (req, res) => {
|
|
817
|
+
const rows = db.prepare('SELECT s.token, s.created, s.site_id, st.domain FROM shares s JOIN sites st ON st.id = s.site_id ORDER BY s.created DESC').all()
|
|
818
|
+
.filter((r) => siteAllowed(req, r.site_id)).map(({ site_id, ...r }) => r);
|
|
688
819
|
res.json(rows);
|
|
689
820
|
});
|
|
690
|
-
app.post('/api/shares',
|
|
821
|
+
app.post('/api/shares', adminOnly, (req, res) => {
|
|
691
822
|
const site = getSiteByDomain.get(norm(req.body && req.body.site));
|
|
692
823
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
824
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
693
825
|
const token = newToken();
|
|
694
826
|
db.prepare('INSERT INTO shares (token, site_id, created) VALUES (?, ?, ?)').run(token, site.id, Date.now());
|
|
827
|
+
audit(res, 'share.add', site.domain);
|
|
695
828
|
res.json({ token, domain: site.domain, url: '/share/' + token });
|
|
696
829
|
});
|
|
697
|
-
app.delete('/api/shares/:token',
|
|
830
|
+
app.delete('/api/shares/:token', adminOnly, (req, res) => {
|
|
831
|
+
const sh = getShare.get(String(req.params.token));
|
|
832
|
+
if (sh && !siteAllowed(req, sh.site_id)) return res.status(403).json(NO_SITE);
|
|
833
|
+
if (sh) { const st = db.prepare('SELECT domain FROM sites WHERE id = ?').get(sh.site_id); audit(res, 'share.revoke', st ? st.domain : null); }
|
|
698
834
|
db.prepare('DELETE FROM shares WHERE token = ?').run(String(req.params.token));
|
|
699
835
|
res.json({ ok: true });
|
|
700
836
|
});
|
|
@@ -706,15 +842,94 @@ app.get('/api/share-info/:token', (req, res) => {
|
|
|
706
842
|
res.json({ domain: site.domain, name: site.name });
|
|
707
843
|
});
|
|
708
844
|
|
|
845
|
+
// ── import history: Plausible export ZIP / CSVs, Google Analytics 4 CSVs (owner/admin) ──
|
|
846
|
+
const { parseImport } = require('./importers');
|
|
847
|
+
app.post('/api/imports', adminOnly, express.raw({ type: ['application/zip', 'application/octet-stream', 'text/csv', 'text/plain'], limit: '60mb' }), async (req, res) => {
|
|
848
|
+
const site = getSiteByDomain.get(norm(req.query.site));
|
|
849
|
+
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
850
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
851
|
+
const source = String(req.query.source || '');
|
|
852
|
+
if (!['plausible', 'ga'].includes(source)) return res.status(400).json({ error: 'source must be plausible or ga' });
|
|
853
|
+
if (!Buffer.isBuffer(req.body) || !req.body.length) return res.status(400).json({ error: 'Upload a .zip or .csv file' });
|
|
854
|
+
const isZip = req.body.readUInt32LE(0) === 0x04034b50;
|
|
855
|
+
let parsed;
|
|
856
|
+
try { parsed = await parseImport(source, isZip ? { zip: req.body } : { files: [{ name: String(req.query.name || 'upload.csv').slice(0, 120), text: req.body.toString('utf8') }] }); }
|
|
857
|
+
catch (e) { return res.status(400).json({ error: e.message }); }
|
|
858
|
+
const rows = parsed.rows.filter((r) => r.visitors || r.pageviews);
|
|
859
|
+
if (!rows.length) return res.status(400).json({ error: 'The file contains no daily numbers' });
|
|
860
|
+
if (rows.length > 2e6) return res.status(400).json({ error: 'Too many rows' });
|
|
861
|
+
const daysTot = rows.filter((r) => r.dim === '').map((r) => r.day).sort();
|
|
862
|
+
const first = firstOwnDay(site);
|
|
863
|
+
const by = whoDid(req);
|
|
864
|
+
const id = db.transaction(() => {
|
|
865
|
+
const info = db.prepare('INSERT INTO imports (site_id, source, first_day, last_day, rows, pageviews, files, created_by, created) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
866
|
+
.run(site.id, source, daysTot[0] || null, daysTot[daysTot.length - 1] || null, rows.length, rows.filter((r) => r.dim === '').reduce((a, r) => a + r.pageviews, 0), parsed.files.join(', ').slice(0, 500), by, Date.now());
|
|
867
|
+
const ins = db.prepare('INSERT INTO imported (import_id, site_id, day, dim, name, visitors, pageviews, visits, bounces, duration) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
|
868
|
+
for (const r of rows) ins.run(info.lastInsertRowid, site.id, r.day, r.dim, r.name || '', r.visitors, r.pageviews, r.visits || 0, r.bounces || 0, r.duration || 0);
|
|
869
|
+
return info.lastInsertRowid;
|
|
870
|
+
})();
|
|
871
|
+
const imp = db.prepare('SELECT * FROM imports WHERE id = ?').get(id);
|
|
872
|
+
audit(res, 'import.add', `${site.domain}: ${source} ${imp.first_day || '?'} – ${imp.last_day || '?'} (${imp.pageviews} views)`);
|
|
873
|
+
res.json({ ...imp, countsUntil: first, note: first && imp.last_day && imp.last_day >= first ? `Days from ${first} on already have own data — imported numbers count only before that day.` : null });
|
|
874
|
+
});
|
|
875
|
+
app.get('/api/imports', auth, (req, res) => {
|
|
876
|
+
const site = getSiteByDomain.get(norm(req.query.site));
|
|
877
|
+
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
878
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
879
|
+
res.json({ countsUntil: firstOwnDay(site), imports: db.prepare('SELECT id, source, first_day, last_day, rows, pageviews, files, created_by, created FROM imports WHERE site_id = ? ORDER BY id DESC').all(site.id) });
|
|
880
|
+
});
|
|
881
|
+
app.delete('/api/imports/:id', adminOnly, (req, res) => {
|
|
882
|
+
const imp = db.prepare('SELECT * FROM imports WHERE id = ?').get(Number(req.params.id));
|
|
883
|
+
if (!imp) return res.json({ ok: true });
|
|
884
|
+
if (!siteAllowed(req, imp.site_id)) return res.status(403).json(NO_SITE);
|
|
885
|
+
db.transaction(() => { db.prepare('DELETE FROM imported WHERE import_id = ?').run(imp.id); db.prepare('DELETE FROM imports WHERE id = ?').run(imp.id); })();
|
|
886
|
+
audit(res, 'import.remove', `${imp.source} ${imp.first_day || ''} – ${imp.last_day || ''}`);
|
|
887
|
+
res.json({ ok: true });
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
// ── chart annotations: list for a site (any reader of the site), add/remove (admin) ──
|
|
891
|
+
app.get('/api/annotations', authOrShare, (req, res) => {
|
|
892
|
+
const site = getSiteByDomain.get(norm(req.query.site));
|
|
893
|
+
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
894
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
895
|
+
res.json(db.prepare('SELECT id, day, text, created_by, created FROM annotations WHERE site_id = ? ORDER BY day DESC, id DESC LIMIT 500').all(site.id));
|
|
896
|
+
});
|
|
897
|
+
app.post('/api/annotations', adminOnly, (req, res) => {
|
|
898
|
+
const b = req.body || {};
|
|
899
|
+
const site = getSiteByDomain.get(norm(b.site));
|
|
900
|
+
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
901
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
902
|
+
const day = String(b.day || '');
|
|
903
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || isNaN(Date.parse(day + 'T00:00:00Z')) || new Date(day + 'T00:00:00Z').toISOString().slice(0, 10) !== day) {
|
|
904
|
+
return res.status(400).json({ error: 'day must be a date like 2026-10-15' });
|
|
905
|
+
}
|
|
906
|
+
const text = String(b.text || '').replace(/\s+/g, ' ').trim().slice(0, 140);
|
|
907
|
+
if (!text) return res.status(400).json({ error: 'Write a short note' });
|
|
908
|
+
const by = whoDid(req);
|
|
909
|
+
const info = db.prepare('INSERT INTO annotations (site_id, day, text, created_by, created) VALUES (?, ?, ?, ?, ?)').run(site.id, day, text, by, Date.now());
|
|
910
|
+
audit(res, 'note.add', `${site.domain} ${day}: ${text}`);
|
|
911
|
+
res.json({ id: info.lastInsertRowid, day, text, created_by: by });
|
|
912
|
+
});
|
|
913
|
+
app.delete('/api/annotations/:id', adminOnly, (req, res) => {
|
|
914
|
+
const a = db.prepare('SELECT * FROM annotations WHERE id = ?').get(Number(req.params.id));
|
|
915
|
+
if (!a) return res.json({ ok: true });
|
|
916
|
+
if (!siteAllowed(req, a.site_id)) return res.status(403).json(NO_SITE);
|
|
917
|
+
db.prepare('DELETE FROM annotations WHERE id = ?').run(a.id);
|
|
918
|
+
audit(res, 'note.remove', `${a.day}: ${a.text}`);
|
|
919
|
+
res.json({ ok: true });
|
|
920
|
+
});
|
|
921
|
+
|
|
709
922
|
// ── funnels: ordered steps (pages or goals), conversion measured per visitor in-range ──
|
|
710
923
|
app.get('/api/funnels', auth, (req, res) => {
|
|
711
924
|
const site = getSiteByDomain.get(norm(req.query.site));
|
|
712
925
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
926
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
713
927
|
res.json(db.prepare('SELECT id, name, steps, created FROM funnels WHERE site_id = ? ORDER BY id').all(site.id).map((f) => ({ ...f, steps: JSON.parse(f.steps) })));
|
|
714
928
|
});
|
|
715
|
-
app.post('/api/funnels',
|
|
929
|
+
app.post('/api/funnels', adminOnly, (req, res) => {
|
|
716
930
|
const site = getSiteByDomain.get(norm(req.body && req.body.site));
|
|
717
931
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
932
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
718
933
|
const name = String((req.body && req.body.name) || '').slice(0, 80).trim();
|
|
719
934
|
const raw = (req.body && req.body.steps) || [];
|
|
720
935
|
if (!name) return res.status(400).json({ error: 'Funnel needs a name' });
|
|
@@ -726,9 +941,13 @@ app.post('/api/funnels', auth, (req, res) => {
|
|
|
726
941
|
}));
|
|
727
942
|
if (steps.some((s) => !s.value)) return res.status(400).json({ error: 'Every step needs a value' });
|
|
728
943
|
const info = db.prepare('INSERT INTO funnels (site_id, name, steps, created) VALUES (?, ?, ?, ?)').run(site.id, name, JSON.stringify(steps), Date.now());
|
|
944
|
+
audit(res, 'funnel.add', `${site.domain}: ${name}`);
|
|
729
945
|
res.json({ id: info.lastInsertRowid, name, steps });
|
|
730
946
|
});
|
|
731
|
-
app.delete('/api/funnels/:id',
|
|
947
|
+
app.delete('/api/funnels/:id', adminOnly, (req, res) => {
|
|
948
|
+
const f = db.prepare('SELECT site_id, name FROM funnels WHERE id = ?').get(Number(req.params.id));
|
|
949
|
+
if (f && !siteAllowed(req, f.site_id)) return res.status(403).json(NO_SITE);
|
|
950
|
+
if (f) audit(res, 'funnel.remove', f.name);
|
|
732
951
|
db.prepare('DELETE FROM funnels WHERE id = ?').run(Number(req.params.id));
|
|
733
952
|
res.json({ ok: true });
|
|
734
953
|
});
|
|
@@ -737,8 +956,8 @@ app.get('/api/funnels/:id/stats', authOrShare, (req, res) => {
|
|
|
737
956
|
const f = db.prepare('SELECT * FROM funnels WHERE id = ?').get(Number(req.params.id));
|
|
738
957
|
if (!f) return res.status(404).json({ error: 'Unknown funnel' });
|
|
739
958
|
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(f.site_id);
|
|
740
|
-
if (!
|
|
741
|
-
const { start, end } = rangeFor(req.query);
|
|
959
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
960
|
+
const { start, end, fromDay, toDay } = rangeFor(req.query, site.timezone);
|
|
742
961
|
const steps = JSON.parse(f.steps);
|
|
743
962
|
// one pass per visitor: fetch matching events sorted by time, walk the step ladder.
|
|
744
963
|
const conds = steps.map((s) => (s.type === 'goal' ? 'name = ?' : "(name IS NULL AND path = ?)"));
|
|
@@ -760,7 +979,7 @@ app.get('/api/funnels/:id/stats', authOrShare, (req, res) => {
|
|
|
760
979
|
pctOfFirst: counts[0] ? +((counts[i] / counts[0]) * 100).toFixed(1) : 0,
|
|
761
980
|
dropFromPrev: i > 0 && counts[i - 1] ? +((1 - counts[i] / counts[i - 1]) * 100).toFixed(1) : 0,
|
|
762
981
|
}));
|
|
763
|
-
res.json({ id: f.id, name: f.name, range: { from:
|
|
982
|
+
res.json({ id: f.id, name: f.name, range: { from: fromDay, to: toDay }, steps: out });
|
|
764
983
|
});
|
|
765
984
|
|
|
766
985
|
// ── static dashboard ─────────────────────────────────────────────────────────
|
|
@@ -771,8 +990,8 @@ app.use(express.static(path.join(__dirname, 'public')));
|
|
|
771
990
|
app.get('/api/journeys', authOrShare, (req, res) => {
|
|
772
991
|
const site = getSiteByDomain.get(norm(req.query.site));
|
|
773
992
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
774
|
-
if (!
|
|
775
|
-
const { start, end } = rangeFor(req.query);
|
|
993
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
994
|
+
const { start, end } = rangeFor(req.query, site.timezone);
|
|
776
995
|
const maxDepth = Math.min(5, Math.max(2, parseInt(req.query.depth, 10) || 4));
|
|
777
996
|
const GAP = 30 * 60000;
|
|
778
997
|
const rows = db.prepare('SELECT vh, ts, path FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name IS NULL ORDER BY vh, ts, id').all(site.id, start, end);
|
|
@@ -813,7 +1032,199 @@ app.get('/api/journeys', authOrShare, (req, res) => {
|
|
|
813
1032
|
});
|
|
814
1033
|
|
|
815
1034
|
|
|
816
|
-
|
|
1035
|
+
// ── alerts: per-site rules (traffic spike / drop / goal reached), delivered to a webhook ──────────
|
|
1036
|
+
// Rules live in the DB and are managed from the dashboard (admin+). Delivery: the rule's own webhook,
|
|
1037
|
+
// else DIGEST_WEBHOOK_URL. Discord / Slack / generic JSON. Each rule has a cooldown so a long spike
|
|
1038
|
+
// sends one message, not twelve.
|
|
1039
|
+
db.exec(`
|
|
1040
|
+
CREATE TABLE IF NOT EXISTS alerts (
|
|
1041
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1042
|
+
site_id INTEGER NOT NULL,
|
|
1043
|
+
kind TEXT NOT NULL, -- 'spike' | 'drop' | 'goal'
|
|
1044
|
+
goal TEXT, -- event name for kind = goal
|
|
1045
|
+
threshold INTEGER, -- goal: events within the window; spike/drop: unused (baseline-based)
|
|
1046
|
+
window_hours INTEGER NOT NULL DEFAULT 24,
|
|
1047
|
+
webhook TEXT, -- NULL = DIGEST_WEBHOOK_URL
|
|
1048
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
1049
|
+
last_fired INTEGER,
|
|
1050
|
+
created INTEGER NOT NULL
|
|
1051
|
+
);
|
|
1052
|
+
`);
|
|
1053
|
+
const ALERT_KINDS = ['spike', 'drop', 'goal'];
|
|
1054
|
+
// SSRF guard (webhook.js): https only, public addresses only — checked when saved AND at
|
|
1055
|
+
// connect time (the dialled IP itself), so DNS rebinding between check and send does not help.
|
|
1056
|
+
const { webhookProblem, resolvesPublic: webhookResolvesPublic, postJson: postWebhook } = require('./webhook');
|
|
1057
|
+
// tests only: a local http receiver (never set this in production)
|
|
1058
|
+
const TEST_WEBHOOK_BASE = process.env.TACILYTICS_TEST_HOOKS === '1' ? (process.env.TACILYTICS_TEST_WEBHOOK_BASE || '') : '';
|
|
1059
|
+
const isTestWebhook = (u) => !!TEST_WEBHOOK_BASE && String(u).startsWith(TEST_WEBHOOK_BASE);
|
|
1060
|
+
// The operator's DIGEST_WEBHOOK_URL (.env) is trusted as before (may be internal, e.g. ntfy on localhost).
|
|
1061
|
+
// Webhooks typed into the dashboard are checked, because a teammate must not reach internal services.
|
|
1062
|
+
// tests only: a fake resolver (TACILYTICS_TEST_DNS='{"name":["1.2.3.4","127.0.0.1"]}' → answers in turn)
|
|
1063
|
+
let webhookLookup = require('dns').lookup;
|
|
1064
|
+
if (process.env.TACILYTICS_TEST_HOOKS === '1' && process.env.TACILYTICS_TEST_DNS) {
|
|
1065
|
+
const table = JSON.parse(process.env.TACILYTICS_TEST_DNS), seen = {};
|
|
1066
|
+
const real = webhookLookup;
|
|
1067
|
+
webhookLookup = (host, opts, cb) => {
|
|
1068
|
+
if (typeof opts === 'function') { cb = opts; opts = {}; }
|
|
1069
|
+
if (!table[host]) return real(host, opts, cb);
|
|
1070
|
+
const list = table[host], i = Math.min(seen[host] = (seen[host] || 0) + 1, list.length) - 1;
|
|
1071
|
+
const address = list[i], family = address.includes(':') ? 6 : 4;
|
|
1072
|
+
return opts.all ? cb(null, [{ address, family }]) : cb(null, address, family);
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
async function deliver(url, text) {
|
|
1076
|
+
const trusted = url && (url === DIGEST_WEBHOOK_URL || isTestWebhook(url));
|
|
1077
|
+
if (!url || (!trusted && (webhookProblem(url) || !(await webhookResolvesPublic(url, webhookLookup))))) { console.error('alert: webhook refused'); return false; }
|
|
1078
|
+
const isSlack = /hooks\.slack\.com/.test(url);
|
|
1079
|
+
const body = isSlack ? { text } : { username: 'Tacilytics', content: text, text };
|
|
1080
|
+
try {
|
|
1081
|
+
const r = trusted
|
|
1082
|
+
? await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(10000) })
|
|
1083
|
+
: await postWebhook(url, body, { lookup: webhookLookup }); // no redirects, IP checked on connect
|
|
1084
|
+
if (!r.ok) console.error('alert webhook failed:', r.status);
|
|
1085
|
+
return r.ok;
|
|
1086
|
+
} catch (e) { console.error(e.code === 'EWEBHOOKPRIVATE' ? 'alert: webhook refused (resolved to a private address)' : 'alert webhook error: ' + e.message); return false; }
|
|
1087
|
+
}
|
|
1088
|
+
function publicAlert(a, domain) {
|
|
1089
|
+
return { id: a.id, site: domain, kind: a.kind, goal: a.goal, threshold: a.threshold, window_hours: a.window_hours,
|
|
1090
|
+
webhook: a.webhook ? new URL(a.webhook).hostname : null, enabled: !!a.enabled, last_fired: a.last_fired };
|
|
1091
|
+
}
|
|
1092
|
+
app.get('/api/alerts', auth, (req, res) => {
|
|
1093
|
+
const site = getSiteByDomain.get(norm(req.query.site));
|
|
1094
|
+
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
1095
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
1096
|
+
res.json({ fallbackWebhook: !!DIGEST_WEBHOOK_URL, alerts: db.prepare('SELECT * FROM alerts WHERE site_id = ? ORDER BY id').all(site.id).map((a) => publicAlert(a, site.domain)) });
|
|
1097
|
+
});
|
|
1098
|
+
app.post('/api/alerts', adminOnly, async (req, res) => {
|
|
1099
|
+
const b = req.body || {};
|
|
1100
|
+
const site = getSiteByDomain.get(norm(b.site));
|
|
1101
|
+
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
1102
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
1103
|
+
if (!ALERT_KINDS.includes(b.kind)) return res.status(400).json({ error: 'kind must be spike, drop or goal' });
|
|
1104
|
+
let goal = null, threshold = null, windowHours = 1;
|
|
1105
|
+
if (b.kind === 'goal') {
|
|
1106
|
+
goal = String(b.goal || '').trim().slice(0, 80);
|
|
1107
|
+
threshold = Math.floor(Number(b.threshold));
|
|
1108
|
+
windowHours = Math.floor(Number(b.window_hours) || 24);
|
|
1109
|
+
if (!goal) return res.status(400).json({ error: 'Name the goal (event name), e.g. Purchase' });
|
|
1110
|
+
if (!(threshold >= 1 && threshold <= 1e6)) return res.status(400).json({ error: 'Threshold must be at least 1' });
|
|
1111
|
+
if (![1, 24, 168].includes(windowHours)) return res.status(400).json({ error: 'Window must be 1, 24 or 168 hours' });
|
|
1112
|
+
}
|
|
1113
|
+
let webhook = null;
|
|
1114
|
+
if (b.webhook && isTestWebhook(b.webhook)) {
|
|
1115
|
+
webhook = String(b.webhook);
|
|
1116
|
+
} else if (b.webhook) {
|
|
1117
|
+
const prob = webhookProblem(b.webhook);
|
|
1118
|
+
if (prob) return res.status(400).json({ error: prob });
|
|
1119
|
+
if (!(await webhookResolvesPublic(b.webhook, webhookLookup))) return res.status(400).json({ error: 'Webhook must point to a public address' });
|
|
1120
|
+
webhook = String(b.webhook);
|
|
1121
|
+
} else if (!DIGEST_WEBHOOK_URL) return res.status(400).json({ error: 'Add a webhook URL (no default DIGEST_WEBHOOK_URL is set)' });
|
|
1122
|
+
if (db.prepare('SELECT COUNT(*) n FROM alerts WHERE site_id = ?').get(site.id).n >= 20) return res.status(400).json({ error: 'At most 20 alerts per site' });
|
|
1123
|
+
const info = db.prepare('INSERT INTO alerts (site_id, kind, goal, threshold, window_hours, webhook, created) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
1124
|
+
.run(site.id, b.kind, goal, threshold, windowHours, webhook, Date.now());
|
|
1125
|
+
audit(res, 'alert.add', `${site.domain}: ${b.kind}${goal ? ' ' + goal + ' ≥ ' + threshold + '/' + windowHours + 'h' : ''}`);
|
|
1126
|
+
res.json(publicAlert(db.prepare('SELECT * FROM alerts WHERE id = ?').get(info.lastInsertRowid), site.domain));
|
|
1127
|
+
});
|
|
1128
|
+
app.patch('/api/alerts/:id', adminOnly, (req, res) => {
|
|
1129
|
+
const a = db.prepare('SELECT * FROM alerts WHERE id = ?').get(Number(req.params.id));
|
|
1130
|
+
if (!a) return res.status(404).json({ error: 'Unknown alert' });
|
|
1131
|
+
if (!siteAllowed(req, a.site_id)) return res.status(403).json(NO_SITE);
|
|
1132
|
+
db.prepare('UPDATE alerts SET enabled = ? WHERE id = ?').run((req.body || {}).enabled ? 1 : 0, a.id);
|
|
1133
|
+
audit(res, (req.body || {}).enabled ? 'alert.on' : 'alert.off', `${a.kind}${a.goal ? ' ' + a.goal : ''}`);
|
|
1134
|
+
res.json({ ok: true });
|
|
1135
|
+
});
|
|
1136
|
+
app.delete('/api/alerts/:id', adminOnly, (req, res) => {
|
|
1137
|
+
const a = db.prepare('SELECT * FROM alerts WHERE id = ?').get(Number(req.params.id));
|
|
1138
|
+
if (!a) return res.json({ ok: true });
|
|
1139
|
+
if (!siteAllowed(req, a.site_id)) return res.status(403).json(NO_SITE);
|
|
1140
|
+
db.prepare('DELETE FROM alerts WHERE id = ?').run(a.id);
|
|
1141
|
+
audit(res, 'alert.remove', `${a.kind}${a.goal ? ' ' + a.goal : ''}`);
|
|
1142
|
+
res.json({ ok: true });
|
|
1143
|
+
});
|
|
1144
|
+
app.post('/api/alerts/:id/test', adminOnly, async (req, res) => {
|
|
1145
|
+
const a = db.prepare('SELECT * FROM alerts WHERE id = ?').get(Number(req.params.id));
|
|
1146
|
+
if (!a) return res.status(404).json({ error: 'Unknown alert' });
|
|
1147
|
+
if (!siteAllowed(req, a.site_id)) return res.status(403).json(NO_SITE);
|
|
1148
|
+
if (rateLimited('alert-test:' + a.id, 5, 60 * 60000)) return res.status(429).json({ error: 'Max. 5 test messages per hour' });
|
|
1149
|
+
const site = db.prepare('SELECT domain FROM sites WHERE id = ?').get(a.site_id);
|
|
1150
|
+
const ok = await deliver(a.webhook || DIGEST_WEBHOOK_URL, `🔔 **${site.domain}** test message from Tacilytics — this alert works.`);
|
|
1151
|
+
res.status(ok ? 200 : 502).json(ok ? { ok: true } : { error: 'The webhook did not accept the message' });
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
// ── weekly report by e-mail ──────────────────────────────────────────────────────────────
|
|
1155
|
+
// Every account can opt in (Settings → Security) with an address; it gets one mail per week
|
|
1156
|
+
// (Monday, REPORT_HOUR_UTC) covering the sites it can see: last 7 days vs. the 7 before.
|
|
1157
|
+
// Needs SMTP_URL (smtp://user:pass@host:587 or smtps://…:465) and REPORT_FROM in .env.
|
|
1158
|
+
const SMTP_URL = process.env.SMTP_URL || '';
|
|
1159
|
+
const REPORT_FROM = process.env.REPORT_FROM || '';
|
|
1160
|
+
const REPORT_HOUR_UTC = Math.min(23, Math.max(0, parseInt(process.env.REPORT_HOUR_UTC, 10) || 6));
|
|
1161
|
+
const PUBLIC_URL = (process.env.TACILYTICS_ORIGIN || '').replace(/\/$/, '');
|
|
1162
|
+
for (const col of ['report_email TEXT', 'report_weekly INTEGER NOT NULL DEFAULT 0', 'report_last INTEGER', 'report_lang TEXT']) {
|
|
1163
|
+
const name = col.split(' ')[0];
|
|
1164
|
+
if (!db.prepare('PRAGMA table_info(users)').all().some((c) => c.name === name)) db.exec(`ALTER TABLE users ADD COLUMN ${col}`);
|
|
1165
|
+
}
|
|
1166
|
+
let mailer = null;
|
|
1167
|
+
function getMailer() {
|
|
1168
|
+
if (!SMTP_URL || !REPORT_FROM) return null;
|
|
1169
|
+
if (!mailer) mailer = require('nodemailer').createTransport(SMTP_URL);
|
|
1170
|
+
return mailer;
|
|
1171
|
+
}
|
|
1172
|
+
const validEmail = (e) => /^[^\s@<>",;]{1,64}@[^\s@<>",;]{1,190}\.[A-Za-z]{2,}$/.test(e);
|
|
1173
|
+
const { createReport, REPORT_LANGS } = require('./report');
|
|
1174
|
+
const reporter = createReport(db);
|
|
1175
|
+
function buildReport(user, now) {
|
|
1176
|
+
const sites = allSites.all().filter((st) => !user.sites || JSON.parse(user.sites).includes(st.id));
|
|
1177
|
+
return reporter.build(user, sites, now, { publicUrl: PUBLIC_URL, lang: REPORT_LANGS.includes(user.report_lang) ? user.report_lang : 'en' });
|
|
1178
|
+
}
|
|
1179
|
+
async function sendReport(user, now = Date.now()) {
|
|
1180
|
+
const m = getMailer();
|
|
1181
|
+
if (!m) throw new Error('E-mail is not configured on this server (SMTP_URL, REPORT_FROM)');
|
|
1182
|
+
const r = buildReport(user, now);
|
|
1183
|
+
if (!r) return { skipped: 'no traffic' };
|
|
1184
|
+
await m.sendMail({ from: REPORT_FROM, to: user.report_email, subject: r.subject, text: r.text, html: r.html });
|
|
1185
|
+
return { sent: true };
|
|
1186
|
+
}
|
|
1187
|
+
async function reportTick(now = Date.now()) {
|
|
1188
|
+
const d = new Date(now);
|
|
1189
|
+
if (d.getUTCDay() !== 1 || d.getUTCHours() !== REPORT_HOUR_UTC || !getMailer()) return [];
|
|
1190
|
+
const due = db.prepare('SELECT * FROM users WHERE report_weekly = 1 AND report_email IS NOT NULL AND (report_last IS NULL OR report_last < ?)').all(now - 6 * 864e5);
|
|
1191
|
+
const out = [];
|
|
1192
|
+
for (const u of due) {
|
|
1193
|
+
db.prepare('UPDATE users SET report_last = ? WHERE id = ?').run(now, u.id); // first: a crash must not mean two mails
|
|
1194
|
+
try { out.push({ user: u.username, ...(await sendReport(u, now)) }); } catch (e) { console.error('weekly report:', e.message); out.push({ user: u.username, error: e.message }); }
|
|
1195
|
+
}
|
|
1196
|
+
return out;
|
|
1197
|
+
}
|
|
1198
|
+
if (process.env.TACILYTICS_ALERT_TICK !== 'off') setInterval(() => { reportTick().catch((e) => console.error('reports:', e.message)); }, 10 * 60000).unref();
|
|
1199
|
+
|
|
1200
|
+
app.get('/api/me/report', auth, (req, res) => {
|
|
1201
|
+
if (!req.principal.user || req.principal.kind !== 'user') return res.status(400).json({ error: 'Sign in with an account' });
|
|
1202
|
+
const u = db.prepare('SELECT report_email, report_weekly, report_last, report_lang FROM users WHERE id = ?').get(req.principal.user.id);
|
|
1203
|
+
res.json({ configured: !!getMailer(), email: u.report_email, weekly: !!u.report_weekly, last: u.report_last, lang: u.report_lang || null });
|
|
1204
|
+
});
|
|
1205
|
+
app.post('/api/me/report', auth, (req, res) => {
|
|
1206
|
+
if (!req.principal.user || req.principal.kind !== 'user') return res.status(400).json({ error: 'Sign in with an account' });
|
|
1207
|
+
const b = req.body || {};
|
|
1208
|
+
const email = String(b.email || '').trim();
|
|
1209
|
+
if (b.weekly && !validEmail(email)) return res.status(400).json({ error: 'Enter a valid e-mail address' });
|
|
1210
|
+
if (email && !validEmail(email)) return res.status(400).json({ error: 'Enter a valid e-mail address' });
|
|
1211
|
+
if (b.lang != null && !REPORT_LANGS.includes(b.lang)) return res.status(400).json({ error: 'Unknown language' });
|
|
1212
|
+
db.prepare('UPDATE users SET report_email = ?, report_weekly = ?, report_lang = COALESCE(?, report_lang) WHERE id = ?').run(email || null, b.weekly ? 1 : 0, b.lang || null, req.principal.user.id);
|
|
1213
|
+
audit(res, b.weekly ? 'me.report.on' : 'me.report.off');
|
|
1214
|
+
res.json({ ok: true });
|
|
1215
|
+
});
|
|
1216
|
+
app.post('/api/me/report/send', auth, async (req, res) => {
|
|
1217
|
+
if (!req.principal.user || req.principal.kind !== 'user') return res.status(400).json({ error: 'Sign in with an account' });
|
|
1218
|
+
if (rateLimited('report-now:' + req.principal.user.id, 3, 60 * 60000)) return res.status(429).json({ error: 'Max. 3 test reports per hour' });
|
|
1219
|
+
const u = db.prepare('SELECT * FROM users WHERE id = ?').get(req.principal.user.id);
|
|
1220
|
+
if (!u.report_email) return res.status(400).json({ error: 'Save an e-mail address first' });
|
|
1221
|
+
try { const r = await sendReport(u); res.json(r.skipped ? { error: 'No traffic in the last two weeks — nothing to report' } : { ok: true }); }
|
|
1222
|
+
catch (e) { res.status(502).json({ error: e.message.startsWith('E-mail is not') ? e.message : 'Sending failed: ' + e.message.slice(0, 120) }); }
|
|
1223
|
+
});
|
|
1224
|
+
if (process.env.TACILYTICS_TEST_HOOKS === '1') {
|
|
1225
|
+
app.post('/api/_test/report-tick', requireRole('owner'), async (req, res) => res.json(await reportTick(Number((req.body || {}).now) || Date.now())));
|
|
1226
|
+
}
|
|
1227
|
+
|
|
817
1228
|
|
|
818
1229
|
// ── data retention (privacy by design: minimise what we keep) ─────────────────
|
|
819
1230
|
// When DATA_RETENTION_DAYS > 0, raw events older than the window are purged daily.
|
|
@@ -829,24 +1240,31 @@ if (RETENTION_DAYS > 0) { purgeOld(); setInterval(purgeOld, 24 * 3600 * 1000).un
|
|
|
829
1240
|
|
|
830
1241
|
// ── daily digest: one webhook message per day summarising every site (yesterday vs the day before).
|
|
831
1242
|
// Configure DIGEST_WEBHOOK_URL (Discord-compatible) and optionally DIGEST_HOUR_UTC (default 7).
|
|
832
|
-
const DIGEST_WEBHOOK_URL = process.env.DIGEST_WEBHOOK_URL || '';
|
|
833
1243
|
const DIGEST_HOUR_UTC = Math.min(23, Math.max(0, parseInt(process.env.DIGEST_HOUR_UTC, 10) || 7));
|
|
834
1244
|
let lastDigestDay = null;
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
const yEnd = yStart + 864e5, pStart = yStart - 864e5;
|
|
1245
|
+
function digestBody(now = Date.now()) {
|
|
1246
|
+
// "yesterday" per site, in the site's time zone (23/25 h on DST days)
|
|
838
1247
|
const lines = [];
|
|
1248
|
+
let label = null;
|
|
839
1249
|
for (const site of allSites.all()) {
|
|
1250
|
+
const tz = site.timezone || 'UTC';
|
|
1251
|
+
const yDay = TZ.addDays(TZ.localDay(tz, now), -1);
|
|
1252
|
+
const yStart = TZ.dayStart(tz, yDay), yEnd = TZ.dayStart(tz, TZ.addDays(yDay, 1)), pStart = TZ.dayStart(tz, TZ.addDays(yDay, -1));
|
|
1253
|
+
if (!label) label = yDay;
|
|
840
1254
|
const cur = db.prepare('SELECT COUNT(*) pv, COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name IS NULL').get(site.id, yStart, yEnd);
|
|
841
1255
|
if (!cur.pv) continue; // silent sites stay out of the report
|
|
842
1256
|
const was = db.prepare('SELECT COUNT(*) pv, COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name IS NULL').get(site.id, pStart, yStart);
|
|
843
1257
|
const top = db.prepare("SELECT path, COUNT(*) c FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name IS NULL GROUP BY path ORDER BY c DESC LIMIT 1").get(site.id, yStart, yEnd);
|
|
844
1258
|
const goals = db.prepare('SELECT COUNT(*) c FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name IS NOT NULL').get(site.id, yStart, yEnd).c;
|
|
845
1259
|
const arrow = was.v ? (cur.v > was.v ? '⬆️' : cur.v < was.v ? '⬇️' : '➡️') : '✨';
|
|
846
|
-
lines.push(`**${site.domain}
|
|
1260
|
+
lines.push(`**${site.domain}**${yDay !== label ? ` (${yDay})` : ''} ${arrow} ${cur.v} visitors · ${cur.pv} views${goals ? ` · ${goals} events` : ''}${top ? ` · top \`${top.path}\`` : ''}`);
|
|
847
1261
|
}
|
|
848
|
-
if (!lines.length) return;
|
|
849
|
-
|
|
1262
|
+
if (!lines.length) return null;
|
|
1263
|
+
return { username: 'Tacilytics', content: `📊 **Daily digest — ${label}**\n` + lines.join('\n') };
|
|
1264
|
+
}
|
|
1265
|
+
async function sendDigest(now = Date.now()) {
|
|
1266
|
+
const body = digestBody(now);
|
|
1267
|
+
if (!body) return;
|
|
850
1268
|
try {
|
|
851
1269
|
const r = await fetch(DIGEST_WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
852
1270
|
if (!r.ok) console.error('digest webhook failed:', r.status);
|
|
@@ -860,37 +1278,81 @@ if (DIGEST_WEBHOOK_URL) {
|
|
|
860
1278
|
}, 5 * 60000).unref();
|
|
861
1279
|
}
|
|
862
1280
|
|
|
863
|
-
// ──
|
|
864
|
-
//
|
|
865
|
-
//
|
|
1281
|
+
// ── alert engine: every 10 min. Spike/drop compare the last hour with the median of the same LOCAL
|
|
1282
|
+
// hour (site time zone — after a DST switch that is 23/25 h away, not 24) over 14 days (avoids "every night is an incident"); goals count events in a rolling window.
|
|
1283
|
+
// Sites without own rules keep the old behaviour when DIGEST_WEBHOOK_URL is set: spike + drop.
|
|
866
1284
|
const WATCH_MIN_VISITORS = Math.max(1, parseInt(process.env.WATCH_MIN_VISITORS, 10) || 10);
|
|
867
|
-
const
|
|
1285
|
+
const legacyFired = new Map(); // "siteId|kind" → ts, for the implicit default rules
|
|
868
1286
|
function median(a) { const s = [...a].sort((x, y) => x - y); return s.length ? (s[s.length >> 1] + s[(s.length - 1) >> 1]) / 2 : 0; }
|
|
869
|
-
|
|
870
|
-
const now = Date.now();
|
|
1287
|
+
function trafficVerdict(siteId, now, tz) {
|
|
871
1288
|
const hourStart = now - 3600e3;
|
|
1289
|
+
const cur = db.prepare('SELECT COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name IS NULL').get(siteId, hourStart, now).v;
|
|
1290
|
+
const hist = [];
|
|
1291
|
+
for (let d = 1; d <= 14; d++) {
|
|
1292
|
+
const s0 = TZ.sameLocalTime(tz || 'UTC', hourStart, d);
|
|
1293
|
+
hist.push(db.prepare('SELECT COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name IS NULL').get(siteId, s0, s0 + 3600e3).v);
|
|
1294
|
+
}
|
|
1295
|
+
const base = median(hist);
|
|
1296
|
+
// abstain when the baseline is too thin to mean anything — a default is not a measurement
|
|
1297
|
+
if (base < WATCH_MIN_VISITORS && cur < WATCH_MIN_VISITORS) return { cur, base, kind: null };
|
|
1298
|
+
if (base >= 1 && cur >= Math.max(3 * base, base + WATCH_MIN_VISITORS)) return { cur, base, kind: 'spike' };
|
|
1299
|
+
if (base >= WATCH_MIN_VISITORS && cur <= base * 0.2) return { cur, base, kind: 'drop' };
|
|
1300
|
+
return { cur, base, kind: null };
|
|
1301
|
+
}
|
|
1302
|
+
const COOLDOWN = { spike: 6 * 3600e3, drop: 6 * 3600e3 };
|
|
1303
|
+
async function alertTick(now = Date.now()) {
|
|
1304
|
+
const fired = [];
|
|
872
1305
|
for (const site of allSites.all()) {
|
|
873
|
-
const
|
|
874
|
-
const
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
1306
|
+
const rules = db.prepare('SELECT * FROM alerts WHERE site_id = ? AND enabled = 1').all(site.id);
|
|
1307
|
+
const hasOwn = db.prepare('SELECT COUNT(*) n FROM alerts WHERE site_id = ?').get(site.id).n > 0;
|
|
1308
|
+
let verdict = null;
|
|
1309
|
+
const traffic = () => verdict || (verdict = trafficVerdict(site.id, now, site.timezone));
|
|
1310
|
+
const msgFor = (kind, v) => kind === 'spike'
|
|
1311
|
+
? `🚀 **${site.domain}** traffic spike: **${v.cur}** visitors in the last hour (usually ~${v.base})`
|
|
1312
|
+
: `📉 **${site.domain}** traffic drop: **${v.cur}** visitors in the last hour (usually ~${v.base}) — tracker offline?`;
|
|
1313
|
+
for (const r of rules) {
|
|
1314
|
+
let text = null;
|
|
1315
|
+
if (r.kind === 'goal') {
|
|
1316
|
+
// fire once per window: after firing, wait until the window has passed
|
|
1317
|
+
if (r.last_fired && now - r.last_fired < r.window_hours * 3600e3) continue;
|
|
1318
|
+
const n = db.prepare('SELECT COUNT(*) c FROM events WHERE site_id = ? AND name = ? AND ts >= ? AND ts < ?').get(site.id, r.goal, now - r.window_hours * 3600e3, now).c;
|
|
1319
|
+
if (n >= r.threshold) text = `🎯 **${site.domain}** goal reached: **${n}× ${r.goal}** in the last ${r.window_hours === 1 ? 'hour' : r.window_hours === 24 ? '24 hours' : '7 days'} (threshold ${r.threshold})`;
|
|
1320
|
+
} else {
|
|
1321
|
+
if (r.last_fired && now - r.last_fired < COOLDOWN[r.kind]) continue;
|
|
1322
|
+
const v = traffic();
|
|
1323
|
+
if (v.kind === r.kind) text = msgFor(r.kind, v);
|
|
1324
|
+
}
|
|
1325
|
+
if (!text) continue;
|
|
1326
|
+
db.prepare('UPDATE alerts SET last_fired = ? WHERE id = ?').run(now, r.id); // before sending: a slow webhook must not double-fire
|
|
1327
|
+
fired.push({ site: site.domain, kind: r.kind, text });
|
|
1328
|
+
await deliver(r.webhook || DIGEST_WEBHOOK_URL, text);
|
|
1329
|
+
}
|
|
1330
|
+
if (!hasOwn && DIGEST_WEBHOOK_URL) {
|
|
1331
|
+
const v = traffic();
|
|
1332
|
+
if (v.kind) {
|
|
1333
|
+
const key = site.id + '|' + v.kind;
|
|
1334
|
+
if (now - (legacyFired.get(key) || 0) >= COOLDOWN[v.kind]) {
|
|
1335
|
+
legacyFired.set(key, now);
|
|
1336
|
+
const text = msgFor(v.kind, v);
|
|
1337
|
+
fired.push({ site: site.domain, kind: v.kind, text, implicit: true });
|
|
1338
|
+
await deliver(DIGEST_WEBHOOK_URL, text);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
878
1341
|
}
|
|
879
|
-
const base = median(hist);
|
|
880
|
-
// abstain when the baseline is too thin to mean anything — a default is not a measurement
|
|
881
|
-
if (base < WATCH_MIN_VISITORS && cur < WATCH_MIN_VISITORS) continue;
|
|
882
|
-
let kind = null, emoji = '', text = '';
|
|
883
|
-
if (base >= 1 && cur >= Math.max(3 * base, base + WATCH_MIN_VISITORS)) { kind = 'spike'; emoji = '🚀'; text = `traffic spike: **${cur}** visitors last hour (usually ~${base})`; }
|
|
884
|
-
else if (base >= WATCH_MIN_VISITORS && cur <= base * 0.2) { kind = 'drop'; emoji = '📉'; text = `traffic drop: **${cur}** visitors last hour (usually ~${base}) — tracker offline?`; }
|
|
885
|
-
if (!kind) continue;
|
|
886
|
-
const key = site.id + '|' + kind;
|
|
887
|
-
if (now - (watchAlerted.get(key) || 0) < 6 * 3600e3) continue; // cooldown
|
|
888
|
-
watchAlerted.set(key, now);
|
|
889
|
-
try {
|
|
890
|
-
await fetch(DIGEST_WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'Tacilytics', content: `${emoji} **${site.domain}** ${text}` }) });
|
|
891
|
-
} catch (e) { console.error('watchdog webhook error:', e.message); }
|
|
892
1342
|
}
|
|
1343
|
+
return fired;
|
|
1344
|
+
}
|
|
1345
|
+
if (process.env.TACILYTICS_ALERT_TICK !== 'off') setInterval(() => { alertTick().catch((e) => console.error('alerts:', e.message)); }, 10 * 60000).unref();
|
|
1346
|
+
// test hook: run one evaluation on demand (only with the server API_KEY, only when enabled explicitly)
|
|
1347
|
+
if (process.env.TACILYTICS_TEST_HOOKS === '1') {
|
|
1348
|
+
app.post('/api/_test/digest', requireRole('owner'), (req, res) => res.json(digestBody(Number((req.body || {}).now) || Date.now())));
|
|
1349
|
+
app.post('/api/_test/alert-tick', requireRole('owner'), async (req, res) => res.json(await alertTick(Number((req.body || {}).now) || Date.now())));
|
|
893
1350
|
}
|
|
894
|
-
|
|
1351
|
+
|
|
1352
|
+
// ── fallbacks: LAST, after every route (a route registered below these would never be reached) ──
|
|
1353
|
+
// unknown API paths are a 404 in JSON — never the SPA page. A client that asks an older/newer server for a
|
|
1354
|
+
// route it doesn't have must get an error it can show, not HTML it would try to read as data (Kiro, 2.2.0).
|
|
1355
|
+
app.all(/^\/(api|auth)(\/|$)/, (_req, res) => res.status(404).json({ error: 'Not found' }));
|
|
1356
|
+
app.get('*', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
|
|
895
1357
|
|
|
896
1358
|
app.listen(PORT, '127.0.0.1', () => console.log(`Tacilytics on http://127.0.0.1:${PORT}${RETENTION_DAYS ? ` · retention ${RETENTION_DAYS}d` : ''}${DIGEST_WEBHOOK_URL ? ` · digest ${DIGEST_HOUR_UTC}:00 UTC` : ''}`));
|