tacilytics 2.1.0 → 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 +5 -0
- package/README.md +66 -2
- package/auth.js +188 -8
- package/importers.js +223 -0
- package/package.json +6 -1
- package/public/account.js +245 -10
- package/public/app.js +216 -25
- package/public/assets/icons.svg +1 -0
- package/public/i18n.js +242 -0
- package/public/index.html +120 -6
- package/public/style.css +101 -0
- package/public/tacilytics.js +41 -1
- package/public/theme.js +14 -0
- package/report.js +300 -0
- package/server.js +540 -68
- 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)`);
|
|
@@ -225,9 +264,26 @@ function clientIp(req) {
|
|
|
225
264
|
|
|
226
265
|
// ── app ─────────────────────────────────────────────────────────────────────
|
|
227
266
|
const app = express();
|
|
228
|
-
|
|
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
|
+
}
|
|
229
283
|
// Accept both application/json (dashboard) and text/plain (cross-origin beacons).
|
|
230
|
-
|
|
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)));
|
|
231
287
|
app.disable('x-powered-by');
|
|
232
288
|
|
|
233
289
|
// Lightweight in-memory rate limiter (fixed window per key). No external dep.
|
|
@@ -330,7 +386,7 @@ app.get('/api/collect', collect);
|
|
|
330
386
|
|
|
331
387
|
// ── auth: accounts, team, optional 2FA + passkeys (auth.js) ───────────────────
|
|
332
388
|
const { setupAuth } = require('./auth');
|
|
333
|
-
const { auth, requireRole, siteAllowed } = setupAuth({
|
|
389
|
+
const { auth, requireRole, siteAllowed, audit } = setupAuth({
|
|
334
390
|
app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp,
|
|
335
391
|
allSiteIds: () => db.prepare('SELECT id FROM sites').all().map((r) => r.id),
|
|
336
392
|
});
|
|
@@ -364,14 +420,37 @@ const sitesCreateHandler = (req, res) => {
|
|
|
364
420
|
}
|
|
365
421
|
// Allow ASCII domains AND Punycode (xn--...). Unicode input was converted above.
|
|
366
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' });
|
|
367
|
-
|
|
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);
|
|
368
428
|
res.json(getSiteByDomain.get(domain));
|
|
369
429
|
};
|
|
370
430
|
app.get('/api/sites', auth, sitesListHandler);
|
|
371
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
|
+
});
|
|
372
444
|
app.delete('/api/sites/:id', adminOnly, (req, res) => {
|
|
373
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);
|
|
374
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);
|
|
375
454
|
db.prepare('DELETE FROM sites WHERE id = ?').run(id);
|
|
376
455
|
res.json({ ok: true });
|
|
377
456
|
});
|
|
@@ -392,24 +471,30 @@ app.get('/api/v1/sites', auth, sitesListHandler);
|
|
|
392
471
|
app.post('/api/v1/sites', adminOnly, sitesCreateHandler);
|
|
393
472
|
|
|
394
473
|
// ── stats ────────────────────────────────────────────────────────────────────
|
|
474
|
+
const TZ = require('./tz');
|
|
395
475
|
const PERIODS = { today: 1, '7d': 7, '30d': 30, '90d': 90, '12mo': 365 };
|
|
396
|
-
|
|
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';
|
|
397
481
|
const now = Date.now();
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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 };
|
|
405
493
|
}
|
|
406
494
|
const period = PERIODS[query.period] ? query.period : '7d';
|
|
407
495
|
const days = PERIODS[period];
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
const today0 = Date.parse(new Date(now).toISOString().slice(0, 10) + 'T00:00:00Z');
|
|
411
|
-
const start = today0 - (days - 1) * 864e5;
|
|
412
|
-
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 };
|
|
413
498
|
}
|
|
414
499
|
|
|
415
500
|
// Whitelisted segmentation filters — click any breakdown row in the dashboard to drill in.
|
|
@@ -435,12 +520,51 @@ function eventPlatformFilter(query) {
|
|
|
435
520
|
return f;
|
|
436
521
|
}
|
|
437
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
|
+
|
|
438
562
|
function statsHandler(req, res) {
|
|
439
563
|
const site = getSiteByDomain.get(norm(req.query.site));
|
|
440
564
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
441
565
|
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
442
566
|
const sid = site.id;
|
|
443
|
-
const { start, end, days, period, hourly } = rangeFor(req.query);
|
|
567
|
+
const { start, end, days, period, hourly, tz, fromDay, toDay } = rangeFor(req.query, site.timezone);
|
|
444
568
|
const { clauses, params, goal } = buildFilters(req.query);
|
|
445
569
|
const evf = eventPlatformFilter(req.query); // platform/version also narrow event panels (revenue, search, 404)
|
|
446
570
|
|
|
@@ -452,6 +576,10 @@ function statsHandler(req, res) {
|
|
|
452
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); }
|
|
453
577
|
|
|
454
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; }
|
|
455
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);
|
|
456
584
|
const bounce = totals.visitors ? Math.round((bounceRow.bounced / totals.visitors) * 100) : 0;
|
|
457
585
|
const viewsPerVisitor = totals.visitors ? +(totals.pageviews / totals.visitors).toFixed(1) : 0;
|
|
@@ -463,30 +591,37 @@ function statsHandler(req, res) {
|
|
|
463
591
|
const prevArgs = args.slice(); prevArgs[1] = start - len; prevArgs[2] = start;
|
|
464
592
|
if (goal) { prevArgs[prevArgs.length - 3] = start - len; prevArgs[prevArgs.length - 2] = start; }
|
|
465
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; } }
|
|
466
595
|
const pct = (cur, was) => (was ? Math.round(((cur - was) / was) * 100) : null);
|
|
467
596
|
|
|
468
597
|
// time series (per hour for a single day, else per day)
|
|
469
598
|
let series;
|
|
470
599
|
if (hourly) {
|
|
471
|
-
|
|
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);
|
|
472
604
|
const map = new Map(rows.map((r) => [r.bucket, r]));
|
|
473
|
-
const
|
|
474
|
-
series = Array.from({ length:
|
|
475
|
-
const r = map.get(
|
|
476
|
-
|
|
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 };
|
|
477
611
|
});
|
|
478
612
|
} else {
|
|
479
|
-
const rows = db.prepare(`SELECT
|
|
480
|
-
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); }
|
|
481
616
|
series = Array.from({ length: days }, (_, i) => {
|
|
482
|
-
const d =
|
|
617
|
+
const d = TZ.addDays(fromDay, i);
|
|
483
618
|
const r = map.get(d);
|
|
484
619
|
return { label: d.slice(5), day: d, visitors: r ? r.v : 0, pageviews: r ? r.pv : 0 };
|
|
485
620
|
});
|
|
486
621
|
}
|
|
487
622
|
|
|
488
623
|
// previous year: same window shifted by 364 days (same weekdays). Null until data reaches back that far.
|
|
489
|
-
const yoyStart =
|
|
624
|
+
const yoyStart = TZ.dayStart(tz, TZ.addDays(fromDay, -364)), yoyEnd = yoyStart + (end - start);
|
|
490
625
|
const firstTs = db.prepare('SELECT MIN(ts) t FROM events WHERE site_id = ?').get(sid).t;
|
|
491
626
|
let yoy = null;
|
|
492
627
|
if (firstTs != null && firstTs <= yoyStart) {
|
|
@@ -496,9 +631,13 @@ function statsHandler(req, res) {
|
|
|
496
631
|
yoy = { visitors: pct(totals.visitors, y.visitors), pageviews: pct(totals.pageviews, y.pageviews), was: y };
|
|
497
632
|
}
|
|
498
633
|
|
|
499
|
-
const
|
|
634
|
+
const topByRaw = (col, limit = 8) => db.prepare(
|
|
500
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}`,
|
|
501
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));
|
|
502
641
|
|
|
503
642
|
// entry / exit pages: first / last pageview of each visit (vh,day), counted as visits.
|
|
504
643
|
const edge = (dir, limit = 8) => db.prepare(
|
|
@@ -511,7 +650,8 @@ function statsHandler(req, res) {
|
|
|
511
650
|
const goalRows = db.prepare(
|
|
512
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`,
|
|
513
652
|
).all(sid, start, end);
|
|
514
|
-
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 }));
|
|
515
655
|
|
|
516
656
|
// properties: only meaningful when drilled into a single goal — top values per property key.
|
|
517
657
|
let properties = null;
|
|
@@ -537,6 +677,7 @@ function statsHandler(req, res) {
|
|
|
537
677
|
const srcAll = db.prepare(
|
|
538
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`,
|
|
539
679
|
).all(...args);
|
|
680
|
+
if (imp.on) { const merged = mergeTop(srcAll, imp.top('source'), 40); srcAll.length = 0; srcAll.push(...merged); }
|
|
540
681
|
const sourcesClean = srcAll.filter((r) => !isPaymentHost(r.name)).slice(0, 8);
|
|
541
682
|
const paymentReturns = srcAll.filter((r) => isPaymentHost(r.name));
|
|
542
683
|
|
|
@@ -616,14 +757,17 @@ function statsHandler(req, res) {
|
|
|
616
757
|
|
|
617
758
|
res.json({
|
|
618
759
|
site: { domain: site.domain, name: site.name },
|
|
619
|
-
period, range: { from:
|
|
760
|
+
period, timezone: tz, range: { from: fromDay, to: toDay },
|
|
620
761
|
filters: Object.assign({}, ...Object.keys(FILTER_COLS).filter((k) => req.query[k]).map((k) => ({ [k]: req.query[k] })), goal ? { goal } : {}),
|
|
621
762
|
totals: { visitors: totals.visitors, pageviews: totals.pageviews, bounce, viewsPerVisitor, duration },
|
|
622
763
|
trend: { visitors: pct(totals.visitors, prev.visitors), pageviews: pct(totals.pageviews, prev.pageviews) },
|
|
623
764
|
previous: { visitors: prev.visitors, pageviews: prev.pageviews },
|
|
624
765
|
yoy,
|
|
625
|
-
dataSince: firstTs ?
|
|
766
|
+
dataSince: firstTs ? TZ.localDay(tz, firstTs) : null,
|
|
767
|
+
imported: imp.exists ? { included: imp.on, excludedByFilter: imp.filtered, until: imp.until } : null,
|
|
626
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),
|
|
627
771
|
pages: topBy('path', 10),
|
|
628
772
|
entryPages: edge('ASC', 8),
|
|
629
773
|
exitPages: edge('DESC', 8),
|
|
@@ -680,9 +824,13 @@ app.post('/api/shares', adminOnly, (req, res) => {
|
|
|
680
824
|
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
681
825
|
const token = newToken();
|
|
682
826
|
db.prepare('INSERT INTO shares (token, site_id, created) VALUES (?, ?, ?)').run(token, site.id, Date.now());
|
|
827
|
+
audit(res, 'share.add', site.domain);
|
|
683
828
|
res.json({ token, domain: site.domain, url: '/share/' + token });
|
|
684
829
|
});
|
|
685
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); }
|
|
686
834
|
db.prepare('DELETE FROM shares WHERE token = ?').run(String(req.params.token));
|
|
687
835
|
res.json({ ok: true });
|
|
688
836
|
});
|
|
@@ -694,6 +842,83 @@ app.get('/api/share-info/:token', (req, res) => {
|
|
|
694
842
|
res.json({ domain: site.domain, name: site.name });
|
|
695
843
|
});
|
|
696
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
|
+
|
|
697
922
|
// ── funnels: ordered steps (pages or goals), conversion measured per visitor in-range ──
|
|
698
923
|
app.get('/api/funnels', auth, (req, res) => {
|
|
699
924
|
const site = getSiteByDomain.get(norm(req.query.site));
|
|
@@ -716,9 +941,13 @@ app.post('/api/funnels', adminOnly, (req, res) => {
|
|
|
716
941
|
}));
|
|
717
942
|
if (steps.some((s) => !s.value)) return res.status(400).json({ error: 'Every step needs a value' });
|
|
718
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}`);
|
|
719
945
|
res.json({ id: info.lastInsertRowid, name, steps });
|
|
720
946
|
});
|
|
721
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);
|
|
722
951
|
db.prepare('DELETE FROM funnels WHERE id = ?').run(Number(req.params.id));
|
|
723
952
|
res.json({ ok: true });
|
|
724
953
|
});
|
|
@@ -728,7 +957,7 @@ app.get('/api/funnels/:id/stats', authOrShare, (req, res) => {
|
|
|
728
957
|
if (!f) return res.status(404).json({ error: 'Unknown funnel' });
|
|
729
958
|
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(f.site_id);
|
|
730
959
|
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
731
|
-
const { start, end } = rangeFor(req.query);
|
|
960
|
+
const { start, end, fromDay, toDay } = rangeFor(req.query, site.timezone);
|
|
732
961
|
const steps = JSON.parse(f.steps);
|
|
733
962
|
// one pass per visitor: fetch matching events sorted by time, walk the step ladder.
|
|
734
963
|
const conds = steps.map((s) => (s.type === 'goal' ? 'name = ?' : "(name IS NULL AND path = ?)"));
|
|
@@ -750,7 +979,7 @@ app.get('/api/funnels/:id/stats', authOrShare, (req, res) => {
|
|
|
750
979
|
pctOfFirst: counts[0] ? +((counts[i] / counts[0]) * 100).toFixed(1) : 0,
|
|
751
980
|
dropFromPrev: i > 0 && counts[i - 1] ? +((1 - counts[i] / counts[i - 1]) * 100).toFixed(1) : 0,
|
|
752
981
|
}));
|
|
753
|
-
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 });
|
|
754
983
|
});
|
|
755
984
|
|
|
756
985
|
// ── static dashboard ─────────────────────────────────────────────────────────
|
|
@@ -762,7 +991,7 @@ app.get('/api/journeys', authOrShare, (req, res) => {
|
|
|
762
991
|
const site = getSiteByDomain.get(norm(req.query.site));
|
|
763
992
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
764
993
|
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
765
|
-
const { start, end } = rangeFor(req.query);
|
|
994
|
+
const { start, end } = rangeFor(req.query, site.timezone);
|
|
766
995
|
const maxDepth = Math.min(5, Math.max(2, parseInt(req.query.depth, 10) || 4));
|
|
767
996
|
const GAP = 30 * 60000;
|
|
768
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);
|
|
@@ -803,7 +1032,199 @@ app.get('/api/journeys', authOrShare, (req, res) => {
|
|
|
803
1032
|
});
|
|
804
1033
|
|
|
805
1034
|
|
|
806
|
-
|
|
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
|
+
|
|
807
1228
|
|
|
808
1229
|
// ── data retention (privacy by design: minimise what we keep) ─────────────────
|
|
809
1230
|
// When DATA_RETENTION_DAYS > 0, raw events older than the window are purged daily.
|
|
@@ -819,24 +1240,31 @@ if (RETENTION_DAYS > 0) { purgeOld(); setInterval(purgeOld, 24 * 3600 * 1000).un
|
|
|
819
1240
|
|
|
820
1241
|
// ── daily digest: one webhook message per day summarising every site (yesterday vs the day before).
|
|
821
1242
|
// Configure DIGEST_WEBHOOK_URL (Discord-compatible) and optionally DIGEST_HOUR_UTC (default 7).
|
|
822
|
-
const DIGEST_WEBHOOK_URL = process.env.DIGEST_WEBHOOK_URL || '';
|
|
823
1243
|
const DIGEST_HOUR_UTC = Math.min(23, Math.max(0, parseInt(process.env.DIGEST_HOUR_UTC, 10) || 7));
|
|
824
1244
|
let lastDigestDay = null;
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
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)
|
|
828
1247
|
const lines = [];
|
|
1248
|
+
let label = null;
|
|
829
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;
|
|
830
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);
|
|
831
1255
|
if (!cur.pv) continue; // silent sites stay out of the report
|
|
832
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);
|
|
833
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);
|
|
834
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;
|
|
835
1259
|
const arrow = was.v ? (cur.v > was.v ? '⬆️' : cur.v < was.v ? '⬇️' : '➡️') : '✨';
|
|
836
|
-
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}\`` : ''}`);
|
|
837
1261
|
}
|
|
838
|
-
if (!lines.length) return;
|
|
839
|
-
|
|
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;
|
|
840
1268
|
try {
|
|
841
1269
|
const r = await fetch(DIGEST_WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
842
1270
|
if (!r.ok) console.error('digest webhook failed:', r.status);
|
|
@@ -850,37 +1278,81 @@ if (DIGEST_WEBHOOK_URL) {
|
|
|
850
1278
|
}, 5 * 60000).unref();
|
|
851
1279
|
}
|
|
852
1280
|
|
|
853
|
-
// ──
|
|
854
|
-
//
|
|
855
|
-
//
|
|
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.
|
|
856
1284
|
const WATCH_MIN_VISITORS = Math.max(1, parseInt(process.env.WATCH_MIN_VISITORS, 10) || 10);
|
|
857
|
-
const
|
|
1285
|
+
const legacyFired = new Map(); // "siteId|kind" → ts, for the implicit default rules
|
|
858
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; }
|
|
859
|
-
|
|
860
|
-
const now = Date.now();
|
|
1287
|
+
function trafficVerdict(siteId, now, tz) {
|
|
861
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 = [];
|
|
862
1305
|
for (const site of allSites.all()) {
|
|
863
|
-
const
|
|
864
|
-
const
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
+
}
|
|
868
1341
|
}
|
|
869
|
-
const base = median(hist);
|
|
870
|
-
// abstain when the baseline is too thin to mean anything — a default is not a measurement
|
|
871
|
-
if (base < WATCH_MIN_VISITORS && cur < WATCH_MIN_VISITORS) continue;
|
|
872
|
-
let kind = null, emoji = '', text = '';
|
|
873
|
-
if (base >= 1 && cur >= Math.max(3 * base, base + WATCH_MIN_VISITORS)) { kind = 'spike'; emoji = '🚀'; text = `traffic spike: **${cur}** visitors last hour (usually ~${base})`; }
|
|
874
|
-
else if (base >= WATCH_MIN_VISITORS && cur <= base * 0.2) { kind = 'drop'; emoji = '📉'; text = `traffic drop: **${cur}** visitors last hour (usually ~${base}) — tracker offline?`; }
|
|
875
|
-
if (!kind) continue;
|
|
876
|
-
const key = site.id + '|' + kind;
|
|
877
|
-
if (now - (watchAlerted.get(key) || 0) < 6 * 3600e3) continue; // cooldown
|
|
878
|
-
watchAlerted.set(key, now);
|
|
879
|
-
try {
|
|
880
|
-
await fetch(DIGEST_WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'Tacilytics', content: `${emoji} **${site.domain}** ${text}` }) });
|
|
881
|
-
} catch (e) { console.error('watchdog webhook error:', e.message); }
|
|
882
1342
|
}
|
|
1343
|
+
return fired;
|
|
883
1344
|
}
|
|
884
|
-
if (
|
|
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())));
|
|
1350
|
+
}
|
|
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')));
|
|
885
1357
|
|
|
886
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` : ''}`));
|