tacilytics 2.1.0 → 2.3.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 +70 -3
- package/auth.js +203 -10
- package/importers.js +223 -0
- package/package.json +6 -1
- package/public/account.js +252 -16
- 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 +102 -0
- package/public/tacilytics.js +41 -1
- package/public/theme.js +14 -0
- package/report.js +300 -0
- package/server.js +592 -69
- 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,50 @@ 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'");
|
|
146
|
+
// paused sites collect nothing (hosting: trial over / subscription ended). Only an unlimited admin or owner can pause.
|
|
147
|
+
if (!db.prepare('PRAGMA table_info(sites)').all().some((c) => c.name === 'paused')) db.exec('ALTER TABLE sites ADD COLUMN paused INTEGER NOT NULL DEFAULT 0');
|
|
107
148
|
const getSiteByDomain = db.prepare('SELECT * FROM sites WHERE domain = ?');
|
|
108
|
-
const insSite = db.prepare(
|
|
149
|
+
const insSite = db.prepare("INSERT OR IGNORE INTO sites (domain, name, created, timezone) VALUES (?, ?, ?, COALESCE(?, 'UTC'))");
|
|
109
150
|
const allSites = db.prepare('SELECT * FROM sites ORDER BY domain');
|
|
110
151
|
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
152
|
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 +266,26 @@ function clientIp(req) {
|
|
|
225
266
|
|
|
226
267
|
// ── app ─────────────────────────────────────────────────────────────────────
|
|
227
268
|
const app = express();
|
|
228
|
-
|
|
269
|
+
const TRUST_PROXY = Number.isFinite(+process.env.TRUST_PROXY) && process.env.TRUST_PROXY !== '' && process.env.TRUST_PROXY != null ? +process.env.TRUST_PROXY : 1;
|
|
270
|
+
app.set('trust proxy', TRUST_PROXY);
|
|
271
|
+
// A real HTTP proxy always appends X-Forwarded-For. A request without it means something else
|
|
272
|
+
// (socat, ssh -L, a TCP forwarder) is in front: then the last XFF entry is client-written and the
|
|
273
|
+
// limits can be bypassed. Normal visitors never send XFF themselves, so they trigger this; an
|
|
274
|
+
// attacker who forges XFF does not, but the warning is already in the log by then. Warn once.
|
|
275
|
+
let warnedNoXff = false;
|
|
276
|
+
if (TRUST_PROXY > 0) {
|
|
277
|
+
app.use((req, _res, next) => {
|
|
278
|
+
if (!warnedNoXff && !req.headers['x-forwarded-for']) {
|
|
279
|
+
warnedNoXff = true;
|
|
280
|
+
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.`);
|
|
281
|
+
}
|
|
282
|
+
next();
|
|
283
|
+
});
|
|
284
|
+
}
|
|
229
285
|
// Accept both application/json (dashboard) and text/plain (cross-origin beacons).
|
|
230
|
-
|
|
286
|
+
// /api/imports takes raw files (handled there); everything else is JSON (text/plain = cross-origin beacons)
|
|
287
|
+
const jsonBody = express.json({ limit: '32kb', type: ['application/json', 'text/plain'] });
|
|
288
|
+
app.use((req, res, next) => (req.path === '/api/imports' ? next() : jsonBody(req, res, next)));
|
|
231
289
|
app.disable('x-powered-by');
|
|
232
290
|
|
|
233
291
|
// Lightweight in-memory rate limiter (fixed window per key). No external dep.
|
|
@@ -261,7 +319,7 @@ function collect(req, res) {
|
|
|
261
319
|
const domain = norm(b.d || b.domain);
|
|
262
320
|
if (!domain) return res.status(204).end();
|
|
263
321
|
const site = getSiteByDomain.get(domain);
|
|
264
|
-
if (!site) return res.status(202).end(); // unknown site → silently ignore
|
|
322
|
+
if (!site || site.paused) return res.status(202).end(); // unknown or paused site → silently ignore
|
|
265
323
|
const platform = appPlatform(b);
|
|
266
324
|
// apps: the HTTP client's user agent says nothing about the device, so OS/device come from the beacon.
|
|
267
325
|
// A bot-looking UA is still dropped, so the bot filter cannot be bypassed by just adding `pf`.
|
|
@@ -330,7 +388,7 @@ app.get('/api/collect', collect);
|
|
|
330
388
|
|
|
331
389
|
// ── auth: accounts, team, optional 2FA + passkeys (auth.js) ───────────────────
|
|
332
390
|
const { setupAuth } = require('./auth');
|
|
333
|
-
const { auth, requireRole, siteAllowed } = setupAuth({
|
|
391
|
+
const { auth, requireRole, siteAllowed, audit, grantSite, isLimited } = setupAuth({
|
|
334
392
|
app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp,
|
|
335
393
|
allSiteIds: () => db.prepare('SELECT id FROM sites').all().map((r) => r.id),
|
|
336
394
|
});
|
|
@@ -355,6 +413,9 @@ function newToken() { return crypto.randomBytes(24).toString('hex'); }
|
|
|
355
413
|
// ── sites management ─────────────────────────────────────────────────────────
|
|
356
414
|
const sitesListHandler = (req, res) => res.json(allSites.all().filter((st) => siteAllowed(req, st.id)));
|
|
357
415
|
const sitesCreateHandler = (req, res) => {
|
|
416
|
+
// a site-limited account (a hosting customer) never creates sites itself: the operator does, because sites are what
|
|
417
|
+
// is billed and paused. Otherwise a paused customer could add a fresh, unpaused site and move the snippet (Kiro, 2.3.0).
|
|
418
|
+
if (isLimited(req)) return res.status(403).json({ error: 'Your role does not allow this' });
|
|
358
419
|
const rawDomain = norm(req.body && req.body.domain);
|
|
359
420
|
let domain = rawDomain;
|
|
360
421
|
let displayName = rawDomain; // keep Unicode original for a readable display name (e.g. ernährungs-plan.de)
|
|
@@ -364,14 +425,59 @@ const sitesCreateHandler = (req, res) => {
|
|
|
364
425
|
}
|
|
365
426
|
// Allow ASCII domains AND Punycode (xn--...). Unicode input was converted above.
|
|
366
427
|
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
|
-
|
|
428
|
+
const rawTz = req.body && req.body.timezone;
|
|
429
|
+
const timezone = rawTz == null || rawTz === '' ? null : TZ.validTz(String(rawTz));
|
|
430
|
+
if (rawTz != null && rawTz !== '' && !timezone) return res.status(400).json({ error: 'Unknown time zone' });
|
|
431
|
+
const existing = getSiteByDomain.get(domain);
|
|
432
|
+
if (existing) {
|
|
433
|
+
// an account limited to some sites must never get a foreign site back (or added to its list) by naming its domain
|
|
434
|
+
if (!siteAllowed(req, existing.id)) return res.status(409).json({ error: 'This domain is already registered' });
|
|
435
|
+
return res.json(existing);
|
|
436
|
+
}
|
|
437
|
+
const info = insSite.run(domain, (req.body && req.body.name) || displayName, Date.now(), timezone);
|
|
438
|
+
// defensive: if INSERT OR IGNORE did nothing (e.g. a second process sharing the DB won the domain), lastInsertRowid is
|
|
439
|
+
// stale and must never be granted. Not reachable over HTTP in one process (the handler is synchronous), so no test.
|
|
440
|
+
if (!info.changes) return res.status(409).json({ error: 'This domain is already registered' });
|
|
441
|
+
grantSite(req, Number(info.lastInsertRowid));
|
|
442
|
+
audit(res, 'site.add', domain);
|
|
368
443
|
res.json(getSiteByDomain.get(domain));
|
|
369
444
|
};
|
|
370
445
|
app.get('/api/sites', auth, sitesListHandler);
|
|
371
446
|
app.post('/api/sites', adminOnly, sitesCreateHandler);
|
|
447
|
+
// change a site's time zone (IANA names only — anything Intl rejects is a 400, never stored)
|
|
448
|
+
app.patch('/api/sites/:id', adminOnly, (req, res) => {
|
|
449
|
+
const id = Number(req.params.id);
|
|
450
|
+
if (!siteAllowed(req, id)) return res.status(403).json(NO_SITE);
|
|
451
|
+
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(id);
|
|
452
|
+
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
453
|
+
const b = req.body || {};
|
|
454
|
+
if (b.paused !== undefined) {
|
|
455
|
+
// pausing is the operator's switch (hosting: trial or subscription ended), never the customer's
|
|
456
|
+
if (isLimited(req)) return res.status(403).json({ error: 'Your role does not allow this' });
|
|
457
|
+
const paused = b.paused ? 1 : 0;
|
|
458
|
+
db.prepare('UPDATE sites SET paused = ? WHERE id = ?').run(paused, id);
|
|
459
|
+
if (paused !== site.paused) audit(res, paused ? 'site.pause' : 'site.resume', site.domain);
|
|
460
|
+
}
|
|
461
|
+
if (b.timezone !== undefined || b.paused === undefined) {
|
|
462
|
+
const timezone = TZ.validTz(String(b.timezone || ''));
|
|
463
|
+
if (!timezone) return res.status(400).json({ error: 'Unknown time zone' });
|
|
464
|
+
db.prepare('UPDATE sites SET timezone = ? WHERE id = ?').run(timezone, id);
|
|
465
|
+
if (timezone !== site.timezone) audit(res, 'site.timezone', site.domain + ': ' + site.timezone + ' → ' + timezone);
|
|
466
|
+
}
|
|
467
|
+
res.json(db.prepare('SELECT * FROM sites WHERE id = ?').get(id));
|
|
468
|
+
});
|
|
372
469
|
app.delete('/api/sites/:id', adminOnly, (req, res) => {
|
|
373
470
|
const id = Number(req.params.id);
|
|
471
|
+
if (!siteAllowed(req, id)) return res.status(403).json(NO_SITE);
|
|
472
|
+
// hard delete frees the domain and resets usage: only the operator (unlimited admin/owner) may do it (Tyto/Kiro, 2.3.0)
|
|
473
|
+
if (isLimited(req)) return res.status(403).json({ error: 'Your role does not allow this' });
|
|
474
|
+
const gone = db.prepare('SELECT domain FROM sites WHERE id = ?').get(id);
|
|
475
|
+
audit(res, 'site.remove', gone ? gone.domain : '#' + id);
|
|
374
476
|
db.prepare('DELETE FROM events WHERE site_id = ?').run(id);
|
|
477
|
+
db.prepare('DELETE FROM annotations WHERE site_id = ?').run(id);
|
|
478
|
+
db.prepare('DELETE FROM alerts WHERE site_id = ?').run(id);
|
|
479
|
+
db.prepare('DELETE FROM imported WHERE site_id = ?').run(id);
|
|
480
|
+
db.prepare('DELETE FROM imports WHERE site_id = ?').run(id);
|
|
375
481
|
db.prepare('DELETE FROM sites WHERE id = ?').run(id);
|
|
376
482
|
res.json({ ok: true });
|
|
377
483
|
});
|
|
@@ -385,31 +491,47 @@ app.get('/api/v1', (_req, res) => res.json({
|
|
|
385
491
|
'POST /api/v1/sites': 'add a site { domain }',
|
|
386
492
|
'GET /api/v1/stats?site=&period=': 'aggregated stats (period: today|7d|30d|90d|12mo, or from=YYYY-MM-DD&to=YYYY-MM-DD)',
|
|
387
493
|
'GET /api/v1/realtime?site=': 'online count, last-30-min pulse + recent events',
|
|
494
|
+
'GET /api/v1/usage?month=YYYY-MM': 'page views per site in a calendar month (UTC), owner / server key only',
|
|
388
495
|
},
|
|
389
496
|
filters: 'add any of country,source,page,browser,os,device,lang,medium,campaign,goal to /stats to segment',
|
|
390
497
|
}));
|
|
391
498
|
app.get('/api/v1/sites', auth, sitesListHandler);
|
|
499
|
+
// page views per site in one calendar month (UTC), for hosting volume checks. Operator only:
|
|
500
|
+
// owners or the server API key with access to all sites, never a site-limited account.
|
|
501
|
+
app.get('/api/v1/usage', requireRole('owner'), (req, res) => {
|
|
502
|
+
if (isLimited(req)) return res.status(403).json({ error: 'Your role does not allow this' });
|
|
503
|
+
const m = String(req.query.month || new Date().toISOString().slice(0, 7));
|
|
504
|
+
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(m)) return res.status(400).json({ error: 'month must be YYYY-MM' });
|
|
505
|
+
const from = Date.UTC(+m.slice(0, 4), +m.slice(5, 7) - 1, 1), to = Date.UTC(+m.slice(0, 4), +m.slice(5, 7), 1);
|
|
506
|
+
res.json(db.prepare('SELECT s.id site_id, s.domain, COUNT(e.id) pageviews FROM sites s LEFT JOIN events e ON e.site_id = s.id AND e.ts >= ? AND e.ts < ? AND e.name IS NULL GROUP BY s.id ORDER BY s.id').all(from, to));
|
|
507
|
+
});
|
|
392
508
|
app.post('/api/v1/sites', adminOnly, sitesCreateHandler);
|
|
393
509
|
|
|
394
510
|
// ── stats ────────────────────────────────────────────────────────────────────
|
|
511
|
+
const TZ = require('./tz');
|
|
395
512
|
const PERIODS = { today: 1, '7d': 7, '30d': 30, '90d': 90, '12mo': 365 };
|
|
396
|
-
|
|
513
|
+
// Whole LOCAL days of the site's time zone: "7d" = today + the 6 full days before it, each day with
|
|
514
|
+
// its own boundaries (23/25 h on DST days). Starting at now - 7×24h would cut the oldest day off at the
|
|
515
|
+
// current clock time and undercount it.
|
|
516
|
+
function rangeFor(query, tz) {
|
|
517
|
+
tz = tz || 'UTC';
|
|
397
518
|
const now = Date.now();
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
519
|
+
const today = TZ.localDay(tz, now);
|
|
520
|
+
// custom range: from/to are local YYYY-MM-DD (inclusive)
|
|
521
|
+
const isDay = (d) => /^\d{4}-\d{2}-\d{2}$/.test(d || '') && !Number.isNaN(Date.parse(d + 'T00:00:00Z'));
|
|
522
|
+
if (isDay(query.from)) {
|
|
523
|
+
let fromDay = query.from, toDay = isDay(query.to) ? query.to : query.from;
|
|
524
|
+
if (toDay < fromDay) toDay = fromDay;
|
|
525
|
+
const start = TZ.dayStart(tz, fromDay);
|
|
526
|
+
const end = Math.max(start + 1, Math.min(now, TZ.dayStart(tz, TZ.addDays(toDay, 1))));
|
|
527
|
+
const days = Math.max(1, Math.round((Date.parse(toDay + 'T00:00:00Z') - Date.parse(fromDay + 'T00:00:00Z')) / 864e5) + 1);
|
|
528
|
+
const lastDay = TZ.localDay(tz, end - 1) < toDay ? TZ.localDay(tz, end - 1) : toDay;
|
|
529
|
+
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
530
|
}
|
|
406
531
|
const period = PERIODS[query.period] ? query.period : '7d';
|
|
407
532
|
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' };
|
|
533
|
+
const fromDay = TZ.addDays(today, -(days - 1));
|
|
534
|
+
return { start: TZ.dayStart(tz, fromDay), end: now, days, period, hourly: period === 'today', tz, fromDay, toDay: today };
|
|
413
535
|
}
|
|
414
536
|
|
|
415
537
|
// Whitelisted segmentation filters — click any breakdown row in the dashboard to drill in.
|
|
@@ -435,12 +557,51 @@ function eventPlatformFilter(query) {
|
|
|
435
557
|
return f;
|
|
436
558
|
}
|
|
437
559
|
|
|
560
|
+
// name shown as "created by" (same wording as the activity log)
|
|
561
|
+
function whoDid(req) {
|
|
562
|
+
const p = req.principal;
|
|
563
|
+
if (!p) return null;
|
|
564
|
+
if (p.kind === 'user') return p.user.username;
|
|
565
|
+
if (p.kind === 'userkey') return p.user.username + ' (API key)';
|
|
566
|
+
if (p.kind === 'apikey') return 'API key';
|
|
567
|
+
if (p.kind === 'legacy') return 'passphrase';
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ── imported history helpers ──
|
|
572
|
+
const firstNativeTs = db.prepare('SELECT MIN(ts) t FROM events WHERE site_id = ?');
|
|
573
|
+
// first LOCAL day with own data in the site's time zone (import boundary — shown in the import dialog too)
|
|
574
|
+
function firstOwnDay(site) { const t = firstNativeTs.get(site.id).t; return t == null ? null : TZ.localDay(site.timezone || 'UTC', t); }
|
|
575
|
+
function importedFor(siteId, start, end, filtered, tz) {
|
|
576
|
+
const exists = !!db.prepare('SELECT 1 FROM imports WHERE site_id = ? LIMIT 1').get(siteId);
|
|
577
|
+
const off = { exists, on: false, filtered: exists && filtered, tot: { pageviews: 0, visitors: 0 }, days: [], top: () => [], until: null };
|
|
578
|
+
if (!exists || filtered) return off;
|
|
579
|
+
// imported days count only before the first LOCAL day with own data (events.day is the UTC day)
|
|
580
|
+
const firstTs = firstNativeTs.get(siteId).t;
|
|
581
|
+
const first = firstTs == null ? null : TZ.localDay(tz, firstTs);
|
|
582
|
+
// imported rows are calendar days of the source; they are matched against the site's local days
|
|
583
|
+
const from = TZ.localDay(tz, start);
|
|
584
|
+
const lastDay = TZ.localDay(tz, end - 1);
|
|
585
|
+
const to = first && first <= lastDay ? TZ.addDays(first, -1) : lastDay;
|
|
586
|
+
if (to < from) return { ...off, until: first };
|
|
587
|
+
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);
|
|
588
|
+
const tot = days.reduce((a, r) => ({ pageviews: a.pageviews + r.pageviews, visitors: a.visitors + r.visitors }), { pageviews: 0, visitors: 0 });
|
|
589
|
+
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);
|
|
590
|
+
return { exists, on: days.length > 0, filtered: false, tot, days, top, until: first };
|
|
591
|
+
}
|
|
592
|
+
function mergeTop(native, imported, limit) {
|
|
593
|
+
const m = new Map();
|
|
594
|
+
for (const r of native) m.set(r.name, { name: r.name, pageviews: r.pageviews, visitors: r.visitors });
|
|
595
|
+
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); }
|
|
596
|
+
return [...m.values()].sort((a, b) => b.visitors - a.visitors || b.pageviews - a.pageviews).slice(0, limit);
|
|
597
|
+
}
|
|
598
|
+
|
|
438
599
|
function statsHandler(req, res) {
|
|
439
600
|
const site = getSiteByDomain.get(norm(req.query.site));
|
|
440
601
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
441
602
|
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
442
603
|
const sid = site.id;
|
|
443
|
-
const { start, end, days, period, hourly } = rangeFor(req.query);
|
|
604
|
+
const { start, end, days, period, hourly, tz, fromDay, toDay } = rangeFor(req.query, site.timezone);
|
|
444
605
|
const { clauses, params, goal } = buildFilters(req.query);
|
|
445
606
|
const evf = eventPlatformFilter(req.query); // platform/version also narrow event panels (revenue, search, 404)
|
|
446
607
|
|
|
@@ -452,6 +613,10 @@ function statsHandler(req, res) {
|
|
|
452
613
|
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
614
|
|
|
454
615
|
const totals = db.prepare(`SELECT COUNT(*) AS pageviews, COUNT(DISTINCT vh) AS visitors FROM events WHERE ${where}`).get(...args);
|
|
616
|
+
// imported history counts only for days before the site's first own event (no double counting)
|
|
617
|
+
// and only without segment filters (imported data is pre-aggregated per single dimension).
|
|
618
|
+
const imp = importedFor(sid, start, end, clauses.length > 0 || !!goal || !!evf, tz);
|
|
619
|
+
if (imp.on) { totals.pageviews += imp.tot.pageviews; totals.visitors += imp.tot.visitors; }
|
|
455
620
|
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
621
|
const bounce = totals.visitors ? Math.round((bounceRow.bounced / totals.visitors) * 100) : 0;
|
|
457
622
|
const viewsPerVisitor = totals.visitors ? +(totals.pageviews / totals.visitors).toFixed(1) : 0;
|
|
@@ -463,30 +628,37 @@ function statsHandler(req, res) {
|
|
|
463
628
|
const prevArgs = args.slice(); prevArgs[1] = start - len; prevArgs[2] = start;
|
|
464
629
|
if (goal) { prevArgs[prevArgs.length - 3] = start - len; prevArgs[prevArgs.length - 2] = start; }
|
|
465
630
|
const prev = db.prepare(`SELECT COUNT(*) pageviews, COUNT(DISTINCT vh) visitors FROM events WHERE ${where}`).get(...prevArgs);
|
|
631
|
+
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
632
|
const pct = (cur, was) => (was ? Math.round(((cur - was) / was) * 100) : null);
|
|
467
633
|
|
|
468
634
|
// time series (per hour for a single day, else per day)
|
|
469
635
|
let series;
|
|
470
636
|
if (hourly) {
|
|
471
|
-
|
|
637
|
+
// one bucket per real hour of the local day (23 or 25 on DST days), labelled with the local
|
|
638
|
+
// hour. Buckets start at local midnight, so +5:45 zones are not cut at UTC hours.
|
|
639
|
+
const dayEnd = TZ.dayStart(tz, TZ.addDays(fromDay, 1));
|
|
640
|
+
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
641
|
const map = new Map(rows.map((r) => [r.bucket, r]));
|
|
473
|
-
const
|
|
474
|
-
series = Array.from({ length:
|
|
475
|
-
const r = map.get(
|
|
476
|
-
|
|
642
|
+
const n = Math.round((dayEnd - start) / 3600000);
|
|
643
|
+
series = Array.from({ length: n }, (_, i) => {
|
|
644
|
+
const r = map.get(i);
|
|
645
|
+
const t = start + i * 3600000;
|
|
646
|
+
const lab = new Date(t + TZ.offsetMin(tz, t) * 60000).toISOString().slice(11, 13);
|
|
647
|
+
return { label: lab, visitors: r ? r.v : 0, pageviews: r ? r.pv : 0 };
|
|
477
648
|
});
|
|
478
649
|
} else {
|
|
479
|
-
const rows = db.prepare(`SELECT
|
|
480
|
-
const map = new Map(rows.map((r) => [r.
|
|
650
|
+
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);
|
|
651
|
+
const map = new Map(rows.map((r) => [r.lday, r]));
|
|
652
|
+
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
653
|
series = Array.from({ length: days }, (_, i) => {
|
|
482
|
-
const d =
|
|
654
|
+
const d = TZ.addDays(fromDay, i);
|
|
483
655
|
const r = map.get(d);
|
|
484
656
|
return { label: d.slice(5), day: d, visitors: r ? r.v : 0, pageviews: r ? r.pv : 0 };
|
|
485
657
|
});
|
|
486
658
|
}
|
|
487
659
|
|
|
488
660
|
// previous year: same window shifted by 364 days (same weekdays). Null until data reaches back that far.
|
|
489
|
-
const yoyStart =
|
|
661
|
+
const yoyStart = TZ.dayStart(tz, TZ.addDays(fromDay, -364)), yoyEnd = yoyStart + (end - start);
|
|
490
662
|
const firstTs = db.prepare('SELECT MIN(ts) t FROM events WHERE site_id = ?').get(sid).t;
|
|
491
663
|
let yoy = null;
|
|
492
664
|
if (firstTs != null && firstTs <= yoyStart) {
|
|
@@ -496,9 +668,13 @@ function statsHandler(req, res) {
|
|
|
496
668
|
yoy = { visitors: pct(totals.visitors, y.visitors), pageviews: pct(totals.pageviews, y.pageviews), was: y };
|
|
497
669
|
}
|
|
498
670
|
|
|
499
|
-
const
|
|
671
|
+
const topByRaw = (col, limit = 8) => db.prepare(
|
|
500
672
|
`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
673
|
).all(...args);
|
|
674
|
+
const IMP_DIM = { path: 'page', country: 'country', browser: 'browser', os: 'os', device: 'device' };
|
|
675
|
+
// native + imported for the same dimension; visitors are summed per day-bucket (an approximation,
|
|
676
|
+
// like every imported breakdown — imported data has no visitor identity to deduplicate)
|
|
677
|
+
const topBy = (col, limit = 8) => (imp.on && IMP_DIM[col] ? mergeTop(topByRaw(col, 200), imp.top(IMP_DIM[col]), limit) : topByRaw(col, limit));
|
|
502
678
|
|
|
503
679
|
// entry / exit pages: first / last pageview of each visit (vh,day), counted as visits.
|
|
504
680
|
const edge = (dir, limit = 8) => db.prepare(
|
|
@@ -511,7 +687,8 @@ function statsHandler(req, res) {
|
|
|
511
687
|
const goalRows = db.prepare(
|
|
512
688
|
`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
689
|
).all(sid, start, end);
|
|
514
|
-
const
|
|
690
|
+
const goalRowsAll = imp.on ? mergeTop(goalRows, imp.top('event'), 12) : goalRows;
|
|
691
|
+
const goals = goalRowsAll.map((g) => ({ ...g, cr: totals.visitors ? +((g.visitors / totals.visitors) * 100).toFixed(1) : 0 }));
|
|
515
692
|
|
|
516
693
|
// properties: only meaningful when drilled into a single goal — top values per property key.
|
|
517
694
|
let properties = null;
|
|
@@ -537,6 +714,7 @@ function statsHandler(req, res) {
|
|
|
537
714
|
const srcAll = db.prepare(
|
|
538
715
|
`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
716
|
).all(...args);
|
|
717
|
+
if (imp.on) { const merged = mergeTop(srcAll, imp.top('source'), 40); srcAll.length = 0; srcAll.push(...merged); }
|
|
540
718
|
const sourcesClean = srcAll.filter((r) => !isPaymentHost(r.name)).slice(0, 8);
|
|
541
719
|
const paymentReturns = srcAll.filter((r) => isPaymentHost(r.name));
|
|
542
720
|
|
|
@@ -616,14 +794,17 @@ function statsHandler(req, res) {
|
|
|
616
794
|
|
|
617
795
|
res.json({
|
|
618
796
|
site: { domain: site.domain, name: site.name },
|
|
619
|
-
period, range: { from:
|
|
797
|
+
period, timezone: tz, range: { from: fromDay, to: toDay },
|
|
620
798
|
filters: Object.assign({}, ...Object.keys(FILTER_COLS).filter((k) => req.query[k]).map((k) => ({ [k]: req.query[k] })), goal ? { goal } : {}),
|
|
621
799
|
totals: { visitors: totals.visitors, pageviews: totals.pageviews, bounce, viewsPerVisitor, duration },
|
|
622
800
|
trend: { visitors: pct(totals.visitors, prev.visitors), pageviews: pct(totals.pageviews, prev.pageviews) },
|
|
623
801
|
previous: { visitors: prev.visitors, pageviews: prev.pageviews },
|
|
624
802
|
yoy,
|
|
625
|
-
dataSince: firstTs ?
|
|
803
|
+
dataSince: firstTs ? TZ.localDay(tz, firstTs) : null,
|
|
804
|
+
imported: imp.exists ? { included: imp.on, excludedByFilter: imp.filtered, until: imp.until } : null,
|
|
626
805
|
series,
|
|
806
|
+
annotations: db.prepare('SELECT id, day, text, created_by FROM annotations WHERE site_id = ? AND day >= ? AND day <= ? ORDER BY day, id')
|
|
807
|
+
.all(sid, fromDay, toDay),
|
|
627
808
|
pages: topBy('path', 10),
|
|
628
809
|
entryPages: edge('ASC', 8),
|
|
629
810
|
exitPages: edge('DESC', 8),
|
|
@@ -680,9 +861,13 @@ app.post('/api/shares', adminOnly, (req, res) => {
|
|
|
680
861
|
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
681
862
|
const token = newToken();
|
|
682
863
|
db.prepare('INSERT INTO shares (token, site_id, created) VALUES (?, ?, ?)').run(token, site.id, Date.now());
|
|
864
|
+
audit(res, 'share.add', site.domain);
|
|
683
865
|
res.json({ token, domain: site.domain, url: '/share/' + token });
|
|
684
866
|
});
|
|
685
867
|
app.delete('/api/shares/:token', adminOnly, (req, res) => {
|
|
868
|
+
const sh = getShare.get(String(req.params.token));
|
|
869
|
+
if (sh && !siteAllowed(req, sh.site_id)) return res.status(403).json(NO_SITE);
|
|
870
|
+
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
871
|
db.prepare('DELETE FROM shares WHERE token = ?').run(String(req.params.token));
|
|
687
872
|
res.json({ ok: true });
|
|
688
873
|
});
|
|
@@ -694,6 +879,83 @@ app.get('/api/share-info/:token', (req, res) => {
|
|
|
694
879
|
res.json({ domain: site.domain, name: site.name });
|
|
695
880
|
});
|
|
696
881
|
|
|
882
|
+
// ── import history: Plausible export ZIP / CSVs, Google Analytics 4 CSVs (owner/admin) ──
|
|
883
|
+
const { parseImport } = require('./importers');
|
|
884
|
+
app.post('/api/imports', adminOnly, express.raw({ type: ['application/zip', 'application/octet-stream', 'text/csv', 'text/plain'], limit: '60mb' }), async (req, res) => {
|
|
885
|
+
const site = getSiteByDomain.get(norm(req.query.site));
|
|
886
|
+
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
887
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
888
|
+
const source = String(req.query.source || '');
|
|
889
|
+
if (!['plausible', 'ga'].includes(source)) return res.status(400).json({ error: 'source must be plausible or ga' });
|
|
890
|
+
if (!Buffer.isBuffer(req.body) || !req.body.length) return res.status(400).json({ error: 'Upload a .zip or .csv file' });
|
|
891
|
+
const isZip = req.body.readUInt32LE(0) === 0x04034b50;
|
|
892
|
+
let parsed;
|
|
893
|
+
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') }] }); }
|
|
894
|
+
catch (e) { return res.status(400).json({ error: e.message }); }
|
|
895
|
+
const rows = parsed.rows.filter((r) => r.visitors || r.pageviews);
|
|
896
|
+
if (!rows.length) return res.status(400).json({ error: 'The file contains no daily numbers' });
|
|
897
|
+
if (rows.length > 2e6) return res.status(400).json({ error: 'Too many rows' });
|
|
898
|
+
const daysTot = rows.filter((r) => r.dim === '').map((r) => r.day).sort();
|
|
899
|
+
const first = firstOwnDay(site);
|
|
900
|
+
const by = whoDid(req);
|
|
901
|
+
const id = db.transaction(() => {
|
|
902
|
+
const info = db.prepare('INSERT INTO imports (site_id, source, first_day, last_day, rows, pageviews, files, created_by, created) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
903
|
+
.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());
|
|
904
|
+
const ins = db.prepare('INSERT INTO imported (import_id, site_id, day, dim, name, visitors, pageviews, visits, bounces, duration) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
|
905
|
+
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);
|
|
906
|
+
return info.lastInsertRowid;
|
|
907
|
+
})();
|
|
908
|
+
const imp = db.prepare('SELECT * FROM imports WHERE id = ?').get(id);
|
|
909
|
+
audit(res, 'import.add', `${site.domain}: ${source} ${imp.first_day || '?'} – ${imp.last_day || '?'} (${imp.pageviews} views)`);
|
|
910
|
+
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 });
|
|
911
|
+
});
|
|
912
|
+
app.get('/api/imports', auth, (req, res) => {
|
|
913
|
+
const site = getSiteByDomain.get(norm(req.query.site));
|
|
914
|
+
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
915
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
916
|
+
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) });
|
|
917
|
+
});
|
|
918
|
+
app.delete('/api/imports/:id', adminOnly, (req, res) => {
|
|
919
|
+
const imp = db.prepare('SELECT * FROM imports WHERE id = ?').get(Number(req.params.id));
|
|
920
|
+
if (!imp) return res.json({ ok: true });
|
|
921
|
+
if (!siteAllowed(req, imp.site_id)) return res.status(403).json(NO_SITE);
|
|
922
|
+
db.transaction(() => { db.prepare('DELETE FROM imported WHERE import_id = ?').run(imp.id); db.prepare('DELETE FROM imports WHERE id = ?').run(imp.id); })();
|
|
923
|
+
audit(res, 'import.remove', `${imp.source} ${imp.first_day || ''} – ${imp.last_day || ''}`);
|
|
924
|
+
res.json({ ok: true });
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
// ── chart annotations: list for a site (any reader of the site), add/remove (admin) ──
|
|
928
|
+
app.get('/api/annotations', authOrShare, (req, res) => {
|
|
929
|
+
const site = getSiteByDomain.get(norm(req.query.site));
|
|
930
|
+
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
931
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
932
|
+
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));
|
|
933
|
+
});
|
|
934
|
+
app.post('/api/annotations', adminOnly, (req, res) => {
|
|
935
|
+
const b = req.body || {};
|
|
936
|
+
const site = getSiteByDomain.get(norm(b.site));
|
|
937
|
+
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
938
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
939
|
+
const day = String(b.day || '');
|
|
940
|
+
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) {
|
|
941
|
+
return res.status(400).json({ error: 'day must be a date like 2026-10-15' });
|
|
942
|
+
}
|
|
943
|
+
const text = String(b.text || '').replace(/\s+/g, ' ').trim().slice(0, 140);
|
|
944
|
+
if (!text) return res.status(400).json({ error: 'Write a short note' });
|
|
945
|
+
const by = whoDid(req);
|
|
946
|
+
const info = db.prepare('INSERT INTO annotations (site_id, day, text, created_by, created) VALUES (?, ?, ?, ?, ?)').run(site.id, day, text, by, Date.now());
|
|
947
|
+
audit(res, 'note.add', `${site.domain} ${day}: ${text}`);
|
|
948
|
+
res.json({ id: info.lastInsertRowid, day, text, created_by: by });
|
|
949
|
+
});
|
|
950
|
+
app.delete('/api/annotations/:id', adminOnly, (req, res) => {
|
|
951
|
+
const a = db.prepare('SELECT * FROM annotations WHERE id = ?').get(Number(req.params.id));
|
|
952
|
+
if (!a) return res.json({ ok: true });
|
|
953
|
+
if (!siteAllowed(req, a.site_id)) return res.status(403).json(NO_SITE);
|
|
954
|
+
db.prepare('DELETE FROM annotations WHERE id = ?').run(a.id);
|
|
955
|
+
audit(res, 'note.remove', `${a.day}: ${a.text}`);
|
|
956
|
+
res.json({ ok: true });
|
|
957
|
+
});
|
|
958
|
+
|
|
697
959
|
// ── funnels: ordered steps (pages or goals), conversion measured per visitor in-range ──
|
|
698
960
|
app.get('/api/funnels', auth, (req, res) => {
|
|
699
961
|
const site = getSiteByDomain.get(norm(req.query.site));
|
|
@@ -716,9 +978,13 @@ app.post('/api/funnels', adminOnly, (req, res) => {
|
|
|
716
978
|
}));
|
|
717
979
|
if (steps.some((s) => !s.value)) return res.status(400).json({ error: 'Every step needs a value' });
|
|
718
980
|
const info = db.prepare('INSERT INTO funnels (site_id, name, steps, created) VALUES (?, ?, ?, ?)').run(site.id, name, JSON.stringify(steps), Date.now());
|
|
981
|
+
audit(res, 'funnel.add', `${site.domain}: ${name}`);
|
|
719
982
|
res.json({ id: info.lastInsertRowid, name, steps });
|
|
720
983
|
});
|
|
721
984
|
app.delete('/api/funnels/:id', adminOnly, (req, res) => {
|
|
985
|
+
const f = db.prepare('SELECT site_id, name FROM funnels WHERE id = ?').get(Number(req.params.id));
|
|
986
|
+
if (f && !siteAllowed(req, f.site_id)) return res.status(403).json(NO_SITE);
|
|
987
|
+
if (f) audit(res, 'funnel.remove', f.name);
|
|
722
988
|
db.prepare('DELETE FROM funnels WHERE id = ?').run(Number(req.params.id));
|
|
723
989
|
res.json({ ok: true });
|
|
724
990
|
});
|
|
@@ -728,7 +994,7 @@ app.get('/api/funnels/:id/stats', authOrShare, (req, res) => {
|
|
|
728
994
|
if (!f) return res.status(404).json({ error: 'Unknown funnel' });
|
|
729
995
|
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(f.site_id);
|
|
730
996
|
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
731
|
-
const { start, end } = rangeFor(req.query);
|
|
997
|
+
const { start, end, fromDay, toDay } = rangeFor(req.query, site.timezone);
|
|
732
998
|
const steps = JSON.parse(f.steps);
|
|
733
999
|
// one pass per visitor: fetch matching events sorted by time, walk the step ladder.
|
|
734
1000
|
const conds = steps.map((s) => (s.type === 'goal' ? 'name = ?' : "(name IS NULL AND path = ?)"));
|
|
@@ -750,10 +1016,20 @@ app.get('/api/funnels/:id/stats', authOrShare, (req, res) => {
|
|
|
750
1016
|
pctOfFirst: counts[0] ? +((counts[i] / counts[0]) * 100).toFixed(1) : 0,
|
|
751
1017
|
dropFromPrev: i > 0 && counts[i - 1] ? +((1 - counts[i] / counts[i - 1]) * 100).toFixed(1) : 0,
|
|
752
1018
|
}));
|
|
753
|
-
res.json({ id: f.id, name: f.name, range: { from:
|
|
1019
|
+
res.json({ id: f.id, name: f.name, range: { from: fromDay, to: toDay }, steps: out });
|
|
754
1020
|
});
|
|
755
1021
|
|
|
756
1022
|
// ── static dashboard ─────────────────────────────────────────────────────────
|
|
1023
|
+
// Hosting (e.g. Tacilytics Cloud): DASHBOARD_EXTRA_SCRIPT="/cloud/ui.js" adds one same-origin script to the
|
|
1024
|
+
// dashboard page (trial/billing banner). Only a plain local path is accepted, never a URL to another host.
|
|
1025
|
+
const EXTRA_SCRIPT = /^\/[A-Za-z0-9._\/-]{1,120}$/.test(process.env.DASHBOARD_EXTRA_SCRIPT || '') && !/\.\.|\/\//.test(process.env.DASHBOARD_EXTRA_SCRIPT) ? process.env.DASHBOARD_EXTRA_SCRIPT : '';
|
|
1026
|
+
const INDEX_HTML = path.join(__dirname, 'public', 'index.html');
|
|
1027
|
+
function sendIndex(_req, res) {
|
|
1028
|
+
if (!EXTRA_SCRIPT) return res.sendFile(INDEX_HTML);
|
|
1029
|
+
const html = fs.readFileSync(INDEX_HTML, 'utf8').replace('</body>', `<script src="${EXTRA_SCRIPT}" defer></script>\n</body>`);
|
|
1030
|
+
res.type('html').set('Cache-Control', 'no-cache').send(html);
|
|
1031
|
+
}
|
|
1032
|
+
app.get(['/', '/index.html'], sendIndex);
|
|
757
1033
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
758
1034
|
// ── visitor journeys: session-based path transitions for a sankey flow ───────────
|
|
759
1035
|
// A "session" = one visitor's pageviews with < 30 min between them.
|
|
@@ -762,7 +1038,7 @@ app.get('/api/journeys', authOrShare, (req, res) => {
|
|
|
762
1038
|
const site = getSiteByDomain.get(norm(req.query.site));
|
|
763
1039
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
764
1040
|
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
765
|
-
const { start, end } = rangeFor(req.query);
|
|
1041
|
+
const { start, end } = rangeFor(req.query, site.timezone);
|
|
766
1042
|
const maxDepth = Math.min(5, Math.max(2, parseInt(req.query.depth, 10) || 4));
|
|
767
1043
|
const GAP = 30 * 60000;
|
|
768
1044
|
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 +1079,202 @@ app.get('/api/journeys', authOrShare, (req, res) => {
|
|
|
803
1079
|
});
|
|
804
1080
|
|
|
805
1081
|
|
|
806
|
-
|
|
1082
|
+
// ── alerts: per-site rules (traffic spike / drop / goal reached), delivered to a webhook ──────────
|
|
1083
|
+
// Rules live in the DB and are managed from the dashboard (admin+). Delivery: the rule's own webhook,
|
|
1084
|
+
// else DIGEST_WEBHOOK_URL. Discord / Slack / generic JSON. Each rule has a cooldown so a long spike
|
|
1085
|
+
// sends one message, not twelve.
|
|
1086
|
+
db.exec(`
|
|
1087
|
+
CREATE TABLE IF NOT EXISTS alerts (
|
|
1088
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1089
|
+
site_id INTEGER NOT NULL,
|
|
1090
|
+
kind TEXT NOT NULL, -- 'spike' | 'drop' | 'goal'
|
|
1091
|
+
goal TEXT, -- event name for kind = goal
|
|
1092
|
+
threshold INTEGER, -- goal: events within the window; spike/drop: unused (baseline-based)
|
|
1093
|
+
window_hours INTEGER NOT NULL DEFAULT 24,
|
|
1094
|
+
webhook TEXT, -- NULL = DIGEST_WEBHOOK_URL
|
|
1095
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
1096
|
+
last_fired INTEGER,
|
|
1097
|
+
created INTEGER NOT NULL
|
|
1098
|
+
);
|
|
1099
|
+
`);
|
|
1100
|
+
const ALERT_KINDS = ['spike', 'drop', 'goal'];
|
|
1101
|
+
// SSRF guard (webhook.js): https only, public addresses only — checked when saved AND at
|
|
1102
|
+
// connect time (the dialled IP itself), so DNS rebinding between check and send does not help.
|
|
1103
|
+
const { webhookProblem, resolvesPublic: webhookResolvesPublic, postJson: postWebhook } = require('./webhook');
|
|
1104
|
+
// tests only: a local http receiver (never set this in production)
|
|
1105
|
+
const TEST_WEBHOOK_BASE = process.env.TACILYTICS_TEST_HOOKS === '1' ? (process.env.TACILYTICS_TEST_WEBHOOK_BASE || '') : '';
|
|
1106
|
+
const isTestWebhook = (u) => !!TEST_WEBHOOK_BASE && String(u).startsWith(TEST_WEBHOOK_BASE);
|
|
1107
|
+
// The operator's DIGEST_WEBHOOK_URL (.env) is trusted as before (may be internal, e.g. ntfy on localhost).
|
|
1108
|
+
// Webhooks typed into the dashboard are checked, because a teammate must not reach internal services.
|
|
1109
|
+
// tests only: a fake resolver (TACILYTICS_TEST_DNS='{"name":["1.2.3.4","127.0.0.1"]}' → answers in turn)
|
|
1110
|
+
let webhookLookup = require('dns').lookup;
|
|
1111
|
+
if (process.env.TACILYTICS_TEST_HOOKS === '1' && process.env.TACILYTICS_TEST_DNS) {
|
|
1112
|
+
const table = JSON.parse(process.env.TACILYTICS_TEST_DNS), seen = {};
|
|
1113
|
+
const real = webhookLookup;
|
|
1114
|
+
webhookLookup = (host, opts, cb) => {
|
|
1115
|
+
if (typeof opts === 'function') { cb = opts; opts = {}; }
|
|
1116
|
+
if (!table[host]) return real(host, opts, cb);
|
|
1117
|
+
const list = table[host], i = Math.min(seen[host] = (seen[host] || 0) + 1, list.length) - 1;
|
|
1118
|
+
const address = list[i], family = address.includes(':') ? 6 : 4;
|
|
1119
|
+
return opts.all ? cb(null, [{ address, family }]) : cb(null, address, family);
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
async function deliver(url, text) {
|
|
1123
|
+
const trusted = url && (url === DIGEST_WEBHOOK_URL || isTestWebhook(url));
|
|
1124
|
+
if (!url || (!trusted && (webhookProblem(url) || !(await webhookResolvesPublic(url, webhookLookup))))) { console.error('alert: webhook refused'); return false; }
|
|
1125
|
+
const isSlack = /hooks\.slack\.com/.test(url);
|
|
1126
|
+
const body = isSlack ? { text } : { username: 'Tacilytics', content: text, text };
|
|
1127
|
+
try {
|
|
1128
|
+
const r = trusted
|
|
1129
|
+
? await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(10000) })
|
|
1130
|
+
: await postWebhook(url, body, { lookup: webhookLookup }); // no redirects, IP checked on connect
|
|
1131
|
+
if (!r.ok) console.error('alert webhook failed:', r.status);
|
|
1132
|
+
return r.ok;
|
|
1133
|
+
} catch (e) { console.error(e.code === 'EWEBHOOKPRIVATE' ? 'alert: webhook refused (resolved to a private address)' : 'alert webhook error: ' + e.message); return false; }
|
|
1134
|
+
}
|
|
1135
|
+
function publicAlert(a, domain) {
|
|
1136
|
+
return { id: a.id, site: domain, kind: a.kind, goal: a.goal, threshold: a.threshold, window_hours: a.window_hours,
|
|
1137
|
+
webhook: a.webhook ? new URL(a.webhook).hostname : null, enabled: !!a.enabled, last_fired: a.last_fired };
|
|
1138
|
+
}
|
|
1139
|
+
app.get('/api/alerts', auth, (req, res) => {
|
|
1140
|
+
const site = getSiteByDomain.get(norm(req.query.site));
|
|
1141
|
+
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
1142
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
1143
|
+
res.json({ fallbackWebhook: !!DIGEST_WEBHOOK_URL && !isLimited(req), alerts: db.prepare('SELECT * FROM alerts WHERE site_id = ? ORDER BY id').all(site.id).map((a) => publicAlert(a, site.domain)) });
|
|
1144
|
+
});
|
|
1145
|
+
app.post('/api/alerts', adminOnly, async (req, res) => {
|
|
1146
|
+
const b = req.body || {};
|
|
1147
|
+
const site = getSiteByDomain.get(norm(b.site));
|
|
1148
|
+
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
1149
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
1150
|
+
if (!ALERT_KINDS.includes(b.kind)) return res.status(400).json({ error: 'kind must be spike, drop or goal' });
|
|
1151
|
+
let goal = null, threshold = null, windowHours = 1;
|
|
1152
|
+
if (b.kind === 'goal') {
|
|
1153
|
+
goal = String(b.goal || '').trim().slice(0, 80);
|
|
1154
|
+
threshold = Math.floor(Number(b.threshold));
|
|
1155
|
+
windowHours = Math.floor(Number(b.window_hours) || 24);
|
|
1156
|
+
if (!goal) return res.status(400).json({ error: 'Name the goal (event name), e.g. Purchase' });
|
|
1157
|
+
if (!(threshold >= 1 && threshold <= 1e6)) return res.status(400).json({ error: 'Threshold must be at least 1' });
|
|
1158
|
+
if (![1, 24, 168].includes(windowHours)) return res.status(400).json({ error: 'Window must be 1, 24 or 168 hours' });
|
|
1159
|
+
}
|
|
1160
|
+
let webhook = null;
|
|
1161
|
+
if (b.webhook && isTestWebhook(b.webhook)) {
|
|
1162
|
+
webhook = String(b.webhook);
|
|
1163
|
+
} else if (b.webhook) {
|
|
1164
|
+
const prob = webhookProblem(b.webhook);
|
|
1165
|
+
if (prob) return res.status(400).json({ error: prob });
|
|
1166
|
+
if (!(await webhookResolvesPublic(b.webhook, webhookLookup))) return res.status(400).json({ error: 'Webhook must point to a public address' });
|
|
1167
|
+
webhook = String(b.webhook);
|
|
1168
|
+
} else if (!DIGEST_WEBHOOK_URL || isLimited(req)) {
|
|
1169
|
+
// the operator's webhook is for the operator: an account limited to some sites must bring its own
|
|
1170
|
+
return res.status(400).json({ error: 'Add a webhook URL' + (DIGEST_WEBHOOK_URL ? '' : ' (no default DIGEST_WEBHOOK_URL is set)') });
|
|
1171
|
+
}
|
|
1172
|
+
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' });
|
|
1173
|
+
const info = db.prepare('INSERT INTO alerts (site_id, kind, goal, threshold, window_hours, webhook, created) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
1174
|
+
.run(site.id, b.kind, goal, threshold, windowHours, webhook, Date.now());
|
|
1175
|
+
audit(res, 'alert.add', `${site.domain}: ${b.kind}${goal ? ' ' + goal + ' ≥ ' + threshold + '/' + windowHours + 'h' : ''}`);
|
|
1176
|
+
res.json(publicAlert(db.prepare('SELECT * FROM alerts WHERE id = ?').get(info.lastInsertRowid), site.domain));
|
|
1177
|
+
});
|
|
1178
|
+
app.patch('/api/alerts/:id', adminOnly, (req, res) => {
|
|
1179
|
+
const a = db.prepare('SELECT * FROM alerts WHERE id = ?').get(Number(req.params.id));
|
|
1180
|
+
if (!a) return res.status(404).json({ error: 'Unknown alert' });
|
|
1181
|
+
if (!siteAllowed(req, a.site_id)) return res.status(403).json(NO_SITE);
|
|
1182
|
+
db.prepare('UPDATE alerts SET enabled = ? WHERE id = ?').run((req.body || {}).enabled ? 1 : 0, a.id);
|
|
1183
|
+
audit(res, (req.body || {}).enabled ? 'alert.on' : 'alert.off', `${a.kind}${a.goal ? ' ' + a.goal : ''}`);
|
|
1184
|
+
res.json({ ok: true });
|
|
1185
|
+
});
|
|
1186
|
+
app.delete('/api/alerts/:id', adminOnly, (req, res) => {
|
|
1187
|
+
const a = db.prepare('SELECT * FROM alerts WHERE id = ?').get(Number(req.params.id));
|
|
1188
|
+
if (!a) return res.json({ ok: true });
|
|
1189
|
+
if (!siteAllowed(req, a.site_id)) return res.status(403).json(NO_SITE);
|
|
1190
|
+
db.prepare('DELETE FROM alerts WHERE id = ?').run(a.id);
|
|
1191
|
+
audit(res, 'alert.remove', `${a.kind}${a.goal ? ' ' + a.goal : ''}`);
|
|
1192
|
+
res.json({ ok: true });
|
|
1193
|
+
});
|
|
1194
|
+
app.post('/api/alerts/:id/test', adminOnly, async (req, res) => {
|
|
1195
|
+
const a = db.prepare('SELECT * FROM alerts WHERE id = ?').get(Number(req.params.id));
|
|
1196
|
+
if (!a) return res.status(404).json({ error: 'Unknown alert' });
|
|
1197
|
+
if (!siteAllowed(req, a.site_id)) return res.status(403).json(NO_SITE);
|
|
1198
|
+
if (rateLimited('alert-test:' + a.id, 5, 60 * 60000)) return res.status(429).json({ error: 'Max. 5 test messages per hour' });
|
|
1199
|
+
const site = db.prepare('SELECT domain FROM sites WHERE id = ?').get(a.site_id);
|
|
1200
|
+
const ok = await deliver(a.webhook || DIGEST_WEBHOOK_URL, `🔔 **${site.domain}** test message from Tacilytics — this alert works.`);
|
|
1201
|
+
res.status(ok ? 200 : 502).json(ok ? { ok: true } : { error: 'The webhook did not accept the message' });
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
// ── weekly report by e-mail ──────────────────────────────────────────────────────────────
|
|
1205
|
+
// Every account can opt in (Settings → Security) with an address; it gets one mail per week
|
|
1206
|
+
// (Monday, REPORT_HOUR_UTC) covering the sites it can see: last 7 days vs. the 7 before.
|
|
1207
|
+
// Needs SMTP_URL (smtp://user:pass@host:587 or smtps://…:465) and REPORT_FROM in .env.
|
|
1208
|
+
const SMTP_URL = process.env.SMTP_URL || '';
|
|
1209
|
+
const REPORT_FROM = process.env.REPORT_FROM || '';
|
|
1210
|
+
const REPORT_HOUR_UTC = Math.min(23, Math.max(0, parseInt(process.env.REPORT_HOUR_UTC, 10) || 6));
|
|
1211
|
+
const PUBLIC_URL = (process.env.TACILYTICS_ORIGIN || '').replace(/\/$/, '');
|
|
1212
|
+
for (const col of ['report_email TEXT', 'report_weekly INTEGER NOT NULL DEFAULT 0', 'report_last INTEGER', 'report_lang TEXT']) {
|
|
1213
|
+
const name = col.split(' ')[0];
|
|
1214
|
+
if (!db.prepare('PRAGMA table_info(users)').all().some((c) => c.name === name)) db.exec(`ALTER TABLE users ADD COLUMN ${col}`);
|
|
1215
|
+
}
|
|
1216
|
+
let mailer = null;
|
|
1217
|
+
function getMailer() {
|
|
1218
|
+
if (!SMTP_URL || !REPORT_FROM) return null;
|
|
1219
|
+
if (!mailer) mailer = require('nodemailer').createTransport(SMTP_URL);
|
|
1220
|
+
return mailer;
|
|
1221
|
+
}
|
|
1222
|
+
const validEmail = (e) => /^[^\s@<>",;]{1,64}@[^\s@<>",;]{1,190}\.[A-Za-z]{2,}$/.test(e);
|
|
1223
|
+
const { createReport, REPORT_LANGS } = require('./report');
|
|
1224
|
+
const reporter = createReport(db);
|
|
1225
|
+
function buildReport(user, now) {
|
|
1226
|
+
const sites = allSites.all().filter((st) => !st.paused && (!user.sites || JSON.parse(user.sites).includes(st.id)));
|
|
1227
|
+
return reporter.build(user, sites, now, { publicUrl: PUBLIC_URL, lang: REPORT_LANGS.includes(user.report_lang) ? user.report_lang : 'en' });
|
|
1228
|
+
}
|
|
1229
|
+
async function sendReport(user, now = Date.now()) {
|
|
1230
|
+
const m = getMailer();
|
|
1231
|
+
if (!m) throw new Error('E-mail is not configured on this server (SMTP_URL, REPORT_FROM)');
|
|
1232
|
+
const r = buildReport(user, now);
|
|
1233
|
+
if (!r) return { skipped: 'no traffic' };
|
|
1234
|
+
await m.sendMail({ from: REPORT_FROM, to: user.report_email, subject: r.subject, text: r.text, html: r.html });
|
|
1235
|
+
return { sent: true };
|
|
1236
|
+
}
|
|
1237
|
+
async function reportTick(now = Date.now()) {
|
|
1238
|
+
const d = new Date(now);
|
|
1239
|
+
if (d.getUTCDay() !== 1 || d.getUTCHours() !== REPORT_HOUR_UTC || !getMailer()) return [];
|
|
1240
|
+
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);
|
|
1241
|
+
const out = [];
|
|
1242
|
+
for (const u of due) {
|
|
1243
|
+
db.prepare('UPDATE users SET report_last = ? WHERE id = ?').run(now, u.id); // first: a crash must not mean two mails
|
|
1244
|
+
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 }); }
|
|
1245
|
+
}
|
|
1246
|
+
return out;
|
|
1247
|
+
}
|
|
1248
|
+
if (process.env.TACILYTICS_ALERT_TICK !== 'off') setInterval(() => { reportTick().catch((e) => console.error('reports:', e.message)); }, 10 * 60000).unref();
|
|
1249
|
+
|
|
1250
|
+
app.get('/api/me/report', auth, (req, res) => {
|
|
1251
|
+
if (!req.principal.user || req.principal.kind !== 'user') return res.status(400).json({ error: 'Sign in with an account' });
|
|
1252
|
+
const u = db.prepare('SELECT report_email, report_weekly, report_last, report_lang FROM users WHERE id = ?').get(req.principal.user.id);
|
|
1253
|
+
res.json({ configured: !!getMailer(), email: u.report_email, weekly: !!u.report_weekly, last: u.report_last, lang: u.report_lang || null });
|
|
1254
|
+
});
|
|
1255
|
+
app.post('/api/me/report', auth, (req, res) => {
|
|
1256
|
+
if (!req.principal.user || req.principal.kind !== 'user') return res.status(400).json({ error: 'Sign in with an account' });
|
|
1257
|
+
const b = req.body || {};
|
|
1258
|
+
const email = String(b.email || '').trim();
|
|
1259
|
+
if (b.weekly && !validEmail(email)) return res.status(400).json({ error: 'Enter a valid e-mail address' });
|
|
1260
|
+
if (email && !validEmail(email)) return res.status(400).json({ error: 'Enter a valid e-mail address' });
|
|
1261
|
+
if (b.lang != null && !REPORT_LANGS.includes(b.lang)) return res.status(400).json({ error: 'Unknown language' });
|
|
1262
|
+
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);
|
|
1263
|
+
audit(res, b.weekly ? 'me.report.on' : 'me.report.off');
|
|
1264
|
+
res.json({ ok: true });
|
|
1265
|
+
});
|
|
1266
|
+
app.post('/api/me/report/send', auth, async (req, res) => {
|
|
1267
|
+
if (!req.principal.user || req.principal.kind !== 'user') return res.status(400).json({ error: 'Sign in with an account' });
|
|
1268
|
+
if (rateLimited('report-now:' + req.principal.user.id, 3, 60 * 60000)) return res.status(429).json({ error: 'Max. 3 test reports per hour' });
|
|
1269
|
+
const u = db.prepare('SELECT * FROM users WHERE id = ?').get(req.principal.user.id);
|
|
1270
|
+
if (!u.report_email) return res.status(400).json({ error: 'Save an e-mail address first' });
|
|
1271
|
+
try { const r = await sendReport(u); res.json(r.skipped ? { error: 'No traffic in the last two weeks — nothing to report' } : { ok: true }); }
|
|
1272
|
+
catch (e) { res.status(502).json({ error: e.message.startsWith('E-mail is not') ? e.message : 'Sending failed: ' + e.message.slice(0, 120) }); }
|
|
1273
|
+
});
|
|
1274
|
+
if (process.env.TACILYTICS_TEST_HOOKS === '1') {
|
|
1275
|
+
app.post('/api/_test/report-tick', requireRole('owner'), async (req, res) => res.json(await reportTick(Number((req.body || {}).now) || Date.now())));
|
|
1276
|
+
}
|
|
1277
|
+
|
|
807
1278
|
|
|
808
1279
|
// ── data retention (privacy by design: minimise what we keep) ─────────────────
|
|
809
1280
|
// When DATA_RETENTION_DAYS > 0, raw events older than the window are purged daily.
|
|
@@ -819,24 +1290,31 @@ if (RETENTION_DAYS > 0) { purgeOld(); setInterval(purgeOld, 24 * 3600 * 1000).un
|
|
|
819
1290
|
|
|
820
1291
|
// ── daily digest: one webhook message per day summarising every site (yesterday vs the day before).
|
|
821
1292
|
// Configure DIGEST_WEBHOOK_URL (Discord-compatible) and optionally DIGEST_HOUR_UTC (default 7).
|
|
822
|
-
const DIGEST_WEBHOOK_URL = process.env.DIGEST_WEBHOOK_URL || '';
|
|
823
1293
|
const DIGEST_HOUR_UTC = Math.min(23, Math.max(0, parseInt(process.env.DIGEST_HOUR_UTC, 10) || 7));
|
|
824
1294
|
let lastDigestDay = null;
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
const yEnd = yStart + 864e5, pStart = yStart - 864e5;
|
|
1295
|
+
function digestBody(now = Date.now()) {
|
|
1296
|
+
// "yesterday" per site, in the site's time zone (23/25 h on DST days)
|
|
828
1297
|
const lines = [];
|
|
1298
|
+
let label = null;
|
|
829
1299
|
for (const site of allSites.all()) {
|
|
1300
|
+
const tz = site.timezone || 'UTC';
|
|
1301
|
+
const yDay = TZ.addDays(TZ.localDay(tz, now), -1);
|
|
1302
|
+
const yStart = TZ.dayStart(tz, yDay), yEnd = TZ.dayStart(tz, TZ.addDays(yDay, 1)), pStart = TZ.dayStart(tz, TZ.addDays(yDay, -1));
|
|
1303
|
+
if (!label) label = yDay;
|
|
830
1304
|
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
1305
|
if (!cur.pv) continue; // silent sites stay out of the report
|
|
832
1306
|
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
1307
|
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
1308
|
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
1309
|
const arrow = was.v ? (cur.v > was.v ? '⬆️' : cur.v < was.v ? '⬇️' : '➡️') : '✨';
|
|
836
|
-
lines.push(`**${site.domain}
|
|
1310
|
+
lines.push(`**${site.domain}**${yDay !== label ? ` (${yDay})` : ''} ${arrow} ${cur.v} visitors · ${cur.pv} views${goals ? ` · ${goals} events` : ''}${top ? ` · top \`${top.path}\`` : ''}`);
|
|
837
1311
|
}
|
|
838
|
-
if (!lines.length) return;
|
|
839
|
-
|
|
1312
|
+
if (!lines.length) return null;
|
|
1313
|
+
return { username: 'Tacilytics', content: `📊 **Daily digest — ${label}**\n` + lines.join('\n') };
|
|
1314
|
+
}
|
|
1315
|
+
async function sendDigest(now = Date.now()) {
|
|
1316
|
+
const body = digestBody(now);
|
|
1317
|
+
if (!body) return;
|
|
840
1318
|
try {
|
|
841
1319
|
const r = await fetch(DIGEST_WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
842
1320
|
if (!r.ok) console.error('digest webhook failed:', r.status);
|
|
@@ -850,37 +1328,82 @@ if (DIGEST_WEBHOOK_URL) {
|
|
|
850
1328
|
}, 5 * 60000).unref();
|
|
851
1329
|
}
|
|
852
1330
|
|
|
853
|
-
// ──
|
|
854
|
-
//
|
|
855
|
-
//
|
|
1331
|
+
// ── alert engine: every 10 min. Spike/drop compare the last hour with the median of the same LOCAL
|
|
1332
|
+
// 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.
|
|
1333
|
+
// Sites without own rules keep the old behaviour when DIGEST_WEBHOOK_URL is set: spike + drop.
|
|
856
1334
|
const WATCH_MIN_VISITORS = Math.max(1, parseInt(process.env.WATCH_MIN_VISITORS, 10) || 10);
|
|
857
|
-
const
|
|
1335
|
+
const legacyFired = new Map(); // "siteId|kind" → ts, for the implicit default rules
|
|
858
1336
|
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();
|
|
1337
|
+
function trafficVerdict(siteId, now, tz) {
|
|
861
1338
|
const hourStart = now - 3600e3;
|
|
1339
|
+
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;
|
|
1340
|
+
const hist = [];
|
|
1341
|
+
for (let d = 1; d <= 14; d++) {
|
|
1342
|
+
const s0 = TZ.sameLocalTime(tz || 'UTC', hourStart, d);
|
|
1343
|
+
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);
|
|
1344
|
+
}
|
|
1345
|
+
const base = median(hist);
|
|
1346
|
+
// abstain when the baseline is too thin to mean anything — a default is not a measurement
|
|
1347
|
+
if (base < WATCH_MIN_VISITORS && cur < WATCH_MIN_VISITORS) return { cur, base, kind: null };
|
|
1348
|
+
if (base >= 1 && cur >= Math.max(3 * base, base + WATCH_MIN_VISITORS)) return { cur, base, kind: 'spike' };
|
|
1349
|
+
if (base >= WATCH_MIN_VISITORS && cur <= base * 0.2) return { cur, base, kind: 'drop' };
|
|
1350
|
+
return { cur, base, kind: null };
|
|
1351
|
+
}
|
|
1352
|
+
const COOLDOWN = { spike: 6 * 3600e3, drop: 6 * 3600e3 };
|
|
1353
|
+
async function alertTick(now = Date.now()) {
|
|
1354
|
+
const fired = [];
|
|
862
1355
|
for (const site of allSites.all()) {
|
|
863
|
-
|
|
864
|
-
const
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
1356
|
+
if (site.paused) continue; // a paused site collects nothing: a 'drop' alert would only announce the pause (Tyto, 2.3.0)
|
|
1357
|
+
const rules = db.prepare('SELECT * FROM alerts WHERE site_id = ? AND enabled = 1').all(site.id);
|
|
1358
|
+
const hasOwn = db.prepare('SELECT COUNT(*) n FROM alerts WHERE site_id = ?').get(site.id).n > 0;
|
|
1359
|
+
let verdict = null;
|
|
1360
|
+
const traffic = () => verdict || (verdict = trafficVerdict(site.id, now, site.timezone));
|
|
1361
|
+
const msgFor = (kind, v) => kind === 'spike'
|
|
1362
|
+
? `🚀 **${site.domain}** traffic spike: **${v.cur}** visitors in the last hour (usually ~${v.base})`
|
|
1363
|
+
: `📉 **${site.domain}** traffic drop: **${v.cur}** visitors in the last hour (usually ~${v.base}) — tracker offline?`;
|
|
1364
|
+
for (const r of rules) {
|
|
1365
|
+
let text = null;
|
|
1366
|
+
if (r.kind === 'goal') {
|
|
1367
|
+
// fire once per window: after firing, wait until the window has passed
|
|
1368
|
+
if (r.last_fired && now - r.last_fired < r.window_hours * 3600e3) continue;
|
|
1369
|
+
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;
|
|
1370
|
+
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})`;
|
|
1371
|
+
} else {
|
|
1372
|
+
if (r.last_fired && now - r.last_fired < COOLDOWN[r.kind]) continue;
|
|
1373
|
+
const v = traffic();
|
|
1374
|
+
if (v.kind === r.kind) text = msgFor(r.kind, v);
|
|
1375
|
+
}
|
|
1376
|
+
if (!text) continue;
|
|
1377
|
+
db.prepare('UPDATE alerts SET last_fired = ? WHERE id = ?').run(now, r.id); // before sending: a slow webhook must not double-fire
|
|
1378
|
+
fired.push({ site: site.domain, kind: r.kind, text });
|
|
1379
|
+
await deliver(r.webhook || DIGEST_WEBHOOK_URL, text);
|
|
1380
|
+
}
|
|
1381
|
+
if (!hasOwn && DIGEST_WEBHOOK_URL) {
|
|
1382
|
+
const v = traffic();
|
|
1383
|
+
if (v.kind) {
|
|
1384
|
+
const key = site.id + '|' + v.kind;
|
|
1385
|
+
if (now - (legacyFired.get(key) || 0) >= COOLDOWN[v.kind]) {
|
|
1386
|
+
legacyFired.set(key, now);
|
|
1387
|
+
const text = msgFor(v.kind, v);
|
|
1388
|
+
fired.push({ site: site.domain, kind: v.kind, text, implicit: true });
|
|
1389
|
+
await deliver(DIGEST_WEBHOOK_URL, text);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
868
1392
|
}
|
|
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
1393
|
}
|
|
1394
|
+
return fired;
|
|
1395
|
+
}
|
|
1396
|
+
if (process.env.TACILYTICS_ALERT_TICK !== 'off') setInterval(() => { alertTick().catch((e) => console.error('alerts:', e.message)); }, 10 * 60000).unref();
|
|
1397
|
+
// test hook: run one evaluation on demand (only with the server API_KEY, only when enabled explicitly)
|
|
1398
|
+
if (process.env.TACILYTICS_TEST_HOOKS === '1') {
|
|
1399
|
+
app.post('/api/_test/digest', requireRole('owner'), (req, res) => res.json(digestBody(Number((req.body || {}).now) || Date.now())));
|
|
1400
|
+
app.post('/api/_test/alert-tick', requireRole('owner'), async (req, res) => res.json(await alertTick(Number((req.body || {}).now) || Date.now())));
|
|
883
1401
|
}
|
|
884
|
-
|
|
1402
|
+
|
|
1403
|
+
// ── fallbacks: LAST, after every route (a route registered below these would never be reached) ──
|
|
1404
|
+
// unknown API paths are a 404 in JSON — never the SPA page. A client that asks an older/newer server for a
|
|
1405
|
+
// 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).
|
|
1406
|
+
app.all(/^\/(api|auth)(\/|$)/, (_req, res) => res.status(404).json({ error: 'Not found' }));
|
|
1407
|
+
app.get('*', sendIndex);
|
|
885
1408
|
|
|
886
1409
|
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` : ''}`));
|