tacilytics 2.2.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/README.md +4 -1
- package/auth.js +16 -3
- package/package.json +1 -1
- package/public/account.js +7 -6
- package/public/i18n.js +1 -1
- package/public/style.css +1 -0
- package/server.js +62 -11
package/README.md
CHANGED
|
@@ -273,9 +273,11 @@ Out of the box the dashboard opens with one shared passphrase (`WEB_PASSWORD`).
|
|
|
273
273
|
| Role | Can |
|
|
274
274
|
|---|---|
|
|
275
275
|
| **Owner** | everything, including the team |
|
|
276
|
-
| **Admin** | add/remove sites, share links, funnels |
|
|
276
|
+
| **Admin** | add/remove sites, share links, funnels — optionally limited to **specific sites** |
|
|
277
277
|
| **Viewer** | read only — optionally limited to **specific sites** |
|
|
278
278
|
|
|
279
|
+
**Hosting several customers on one instance** (2.3): give each customer an **admin limited to their sites**. The operator adds their sites; a domain that already belongs to someone else is refused (409) instead of being shared. Pause a site with `PATCH /api/sites/:id {"paused": true}` (server key or an unlimited owner/admin): it collects nothing until resumed, the data stays. `GET /api/v1/usage?month=YYYY-MM` lists page views per site for volume checks (operator only). The operator's `DIGEST_WEBHOOK_URL` is never used for alerts of limited accounts; set `DIGEST_WEBHOOK_URL=off` on a shared instance so the daily digest does not list customer domains. `DASHBOARD_EXTRA_SCRIPT=/path.js` adds one same-origin script to the dashboard (e.g. a billing banner served by your own proxy). Paused sites are left out of alerts and the weekly report. Site-limited accounts cannot add or delete sites (the operator does both, sites are what is billed and paused). **Domains are first come, first served:** if a customer gets 409 for a domain they own, the operator resolves it with an owner account (move the site to the right account in Settings → Team); domain verification is planned.
|
|
280
|
+
|
|
279
281
|
Everyone can turn on, **optionally**:
|
|
280
282
|
|
|
281
283
|
- **Passkeys** (Face ID, Touch ID, Windows Hello, security keys). A passkey replaces password *and* code and cannot be phished.
|
|
@@ -303,6 +305,7 @@ The server-wide `API_KEY` from `.env` keeps owner rights for agents and the CLI
|
|
|
303
305
|
| `API_KEY` | — | Bearer key for the API / CLI |
|
|
304
306
|
| `DB_PATH` | `./data/analytics.db` | SQLite path |
|
|
305
307
|
| `DIGEST_WEBHOOK_URL` | — | Daily digest + default alert target (Discord/Slack-compatible). `off` disables it |
|
|
308
|
+
| `DASHBOARD_EXTRA_SCRIPT` | — | One same-origin script added to the dashboard page (hosting banner). Only a plain path like `/cloud/ui.js`; URLs, `//` and `..` are ignored |
|
|
306
309
|
| `WATCH_MIN_VISITORS` | `10` | Spike/drop alerts stay silent below this many visitors per hour |
|
|
307
310
|
| `SMTP_URL` / `REPORT_FROM` | — | Mail server + sender for the weekly report |
|
|
308
311
|
| `REPORT_HOUR_UTC` | `6` | Monday hour (UTC) the weekly report goes out; it covers the last 7 full days in each site's time zone |
|
package/auth.js
CHANGED
|
@@ -660,7 +660,7 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
660
660
|
const b = req.body || {};
|
|
661
661
|
const role = cleanRole(b.role);
|
|
662
662
|
if (!role) return res.status(400).json({ error: 'Role must be owner, admin or viewer' });
|
|
663
|
-
const sites = role
|
|
663
|
+
const sites = role !== 'owner' ? cleanSites(b.sites) : null; // admins and viewers can be limited to sites
|
|
664
664
|
if (sites === undefined) return res.status(400).json({ error: 'sites must be a list of site ids or null' });
|
|
665
665
|
if (Array.isArray(sites) && !sites.length) return res.status(400).json({ error: 'Pick at least one site' });
|
|
666
666
|
const token = randToken();
|
|
@@ -682,7 +682,7 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
682
682
|
const role = b.role === undefined ? u.role : cleanRole(b.role);
|
|
683
683
|
if (!role) return res.status(400).json({ error: 'Role must be owner, admin or viewer' });
|
|
684
684
|
if (u.role === 'owner' && role !== 'owner' && q.ownerCount.get().n <= 1) return res.status(400).json({ error: 'There must be at least one owner' });
|
|
685
|
-
let sites = role
|
|
685
|
+
let sites = role !== 'owner' ? (b.sites === undefined ? (u.sites ? JSON.parse(u.sites) : null) : cleanSites(b.sites)) : null;
|
|
686
686
|
if (sites === undefined) return res.status(400).json({ error: 'sites must be a list of site ids or null' });
|
|
687
687
|
if (Array.isArray(sites) && !sites.length) return res.status(400).json({ error: 'Pick at least one site' });
|
|
688
688
|
db.prepare('UPDATE users SET role = ?, sites = ? WHERE id = ?').run(role, sites ? JSON.stringify(sites) : null, u.id);
|
|
@@ -761,7 +761,20 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
761
761
|
res.json({ ok: true, username: who });
|
|
762
762
|
});
|
|
763
763
|
|
|
764
|
-
|
|
764
|
+
/** a site created by a site-limited account becomes part of that account's list */
|
|
765
|
+
function grantSite(req, siteId) {
|
|
766
|
+
const p = req.principal;
|
|
767
|
+
if (!p || p.sites == null || !p.user) return;
|
|
768
|
+
const u = q.userById.get(p.user.id);
|
|
769
|
+
if (!u || u.sites == null) return;
|
|
770
|
+
const list = JSON.parse(u.sites);
|
|
771
|
+
if (!list.includes(siteId)) list.push(siteId);
|
|
772
|
+
db.prepare('UPDATE users SET sites = ? WHERE id = ?').run(JSON.stringify(list), u.id);
|
|
773
|
+
p.sites = list;
|
|
774
|
+
}
|
|
775
|
+
/** limited = can only see some sites (cloud customers, viewers with a site list) */
|
|
776
|
+
const isLimited = (req) => !!(req.principal && req.principal.sites != null);
|
|
777
|
+
return { auth, requireRole, siteAllowed, principal, audit, grantSite, isLimited };
|
|
765
778
|
}
|
|
766
779
|
|
|
767
780
|
// ── offline admin (CLI on the server, for lock-outs) ─────────────────────────
|
package/package.json
CHANGED
package/public/account.js
CHANGED
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
|
|
164
164
|
// ── settings ──
|
|
165
165
|
var ROLE_LABEL = { owner: 'Owner', admin: 'Admin', viewer: 'Viewer' };
|
|
166
|
-
var ROLE_HELP = { owner: 'everything, including the team', admin: 'sites, share links and funnels', viewer: 'read only' };
|
|
166
|
+
var ROLE_HELP = { owner: 'everything, including the team', admin: 'sites, share links and funnels (all or picked sites)', viewer: 'read only' };
|
|
167
167
|
var me = null;
|
|
168
168
|
function setErr(t) { $('setErr').textContent = t || ''; }
|
|
169
169
|
// footer version: only after sign-in (from /api/me), links to the release tag; strict format check so
|
|
@@ -183,6 +183,7 @@
|
|
|
183
183
|
$('meName').textContent = m.username || 'Settings';
|
|
184
184
|
showVersion(m.version);
|
|
185
185
|
document.body.classList.toggle('role-viewer', m.role === 'viewer');
|
|
186
|
+
document.body.classList.toggle('site-limited', !!(m.sites && m.role !== 'owner')); // the operator adds their sites
|
|
186
187
|
}).catch(function () {});
|
|
187
188
|
}
|
|
188
189
|
function openSettings(tab) {
|
|
@@ -418,15 +419,15 @@
|
|
|
418
419
|
var self = me && me.id === u.id;
|
|
419
420
|
return '<div class="set-row team-row" data-uid="' + u.id + '">' +
|
|
420
421
|
'<span><strong data-no-i18n>' + esc(u.username) + '</strong>' + (self ? ' <span class="muted small">(you)</span>' : '') +
|
|
421
|
-
'<br><span class="muted small">' + (u.role
|
|
422
|
+
'<br><span class="muted small">' + (u.role !== 'owner' ? siteNames(u.sites) + ' · ' : '') + (u.totp ? '2FA on' : 'no 2FA') + (u.passkeys ? ' · ' + u.passkeys + ' passkey' + (u.passkeys > 1 ? 's' : '') : '') + ' · last sign-in ' + date(u.last_login) + '</span></span>' +
|
|
422
423
|
'<select data-role>' + ['owner', 'admin', 'viewer'].map(function (r) { return '<option value="' + r + '"' + (r === u.role ? ' selected' : '') + '>' + ROLE_LABEL[r] + '</option>'; }).join('') + '</select>' +
|
|
423
|
-
'<span class="row-actions">' + (u.role
|
|
424
|
+
'<span class="row-actions">' + (u.role !== 'owner' ? '<button class="btn ghost mini" data-sites>Sites</button>' : '') +
|
|
424
425
|
(!self ? '<button class="btn ghost mini" data-resetlink title="One-time link to set a new password (24 h)">Password link</button>' : '') +
|
|
425
426
|
(u.totp && !self ? '<button class="btn ghost mini" data-reset2fa>Reset 2FA</button>' : '') +
|
|
426
427
|
(!self ? '<button class="btn ghost danger mini" data-remove>Remove</button>' : '') + '</span></div>';
|
|
427
428
|
}).join('');
|
|
428
429
|
var invites = t.invites.map(function (i) {
|
|
429
|
-
return '<div class="set-row"><span>' + ico('i-user-plus') + ROLE_LABEL[i.role] + (i.role
|
|
430
|
+
return '<div class="set-row"><span>' + ico('i-user-plus') + ROLE_LABEL[i.role] + (i.role !== 'owner' ? ' · ' + esc(siteNames(i.sites)) : '') + '</span><span class="muted small">expires ' + date(i.expires) + '</span><button class="btn ghost danger mini" data-delinv="' + i.id + '">Revoke</button></div>';
|
|
430
431
|
}).join('');
|
|
431
432
|
el.innerHTML =
|
|
432
433
|
'<div class="set-card"><h3>' + ico('i-users') + 'Members</h3>' + rows + '</div>' +
|
|
@@ -436,11 +437,11 @@
|
|
|
436
437
|
'<div id="invSites">' + sitePicker('invPick', null) + '</div>' +
|
|
437
438
|
'<div id="invOut"></div>' + (invites ? '<h4 class="muted small" style="margin-top:12px">Open invites</h4>' + invites : '') + '</div>';
|
|
438
439
|
|
|
439
|
-
$('invRole').onchange = function () { $('invSites').classList.toggle('hide', this.value
|
|
440
|
+
$('invRole').onchange = function () { $('invSites').classList.toggle('hide', this.value === 'owner'); };
|
|
440
441
|
$('invCreate').onclick = function () {
|
|
441
442
|
setErr('');
|
|
442
443
|
var role = $('invRole').value;
|
|
443
|
-
call('POST', '/api/team/invites', { role: role, sites: role
|
|
444
|
+
call('POST', '/api/team/invites', { role: role, sites: role !== 'owner' ? pickedSites('invPick') : null }).then(function (r) {
|
|
444
445
|
var url = location.origin + r.url;
|
|
445
446
|
$('invOut').innerHTML = '<div class="snippet" id="invUrl">' + esc(url) + '</div><button class="btn ghost" id="invCopy">Copy link</button>';
|
|
446
447
|
$('invCopy').onclick = function () { navigator.clipboard.writeText(url); $('invCopy').textContent = 'Copied ✓'; };
|
package/public/i18n.js
CHANGED
|
@@ -142,7 +142,7 @@
|
|
|
142
142
|
'For scripts, agents and the CLI (': 'Für Skripte, Agents und die CLI (',
|
|
143
143
|
'). A key acts as you, sees only your sites, and cannot touch the team or your security settings. Read-only unless you untick it.': '). Ein Key handelt als du, sieht nur deine Seiten und kann weder das Team noch deine Sicherheitseinstellungen ändern. Nur lesen, außer du nimmst den Haken raus.',
|
|
144
144
|
'Signed in as': 'Angemeldet als',
|
|
145
|
-
'Viewer — read only': 'Viewer: nur lesen', 'Admin — sites, share links and funnels': 'Admin: Seiten, Share-Links und Funnels', 'Owner — everything, including the team': 'Owner: alles, auch das Team',
|
|
145
|
+
'Viewer — read only': 'Viewer: nur lesen', 'Admin — sites, share links and funnels (all or picked sites)': 'Admin: Seiten, Share-Links und Funnels (alle oder ausgewählte Seiten)', 'Owner — everything, including the team': 'Owner: alles, auch das Team',
|
|
146
146
|
'In Plausible:': 'In Plausible:', 'Site settings → Imports & Exports → Export data': 'Website-Einstellungen → Imports & Exports → Export data',
|
|
147
147
|
'. Upload the ZIP you get (or the CSV files in it).': '. Lade das ZIP hoch (oder die CSV-Dateien darin).',
|
|
148
148
|
'In Google Analytics 4: open a report (e.g.': 'In Google Analytics 4: Öffne einen Bericht (z. B.', 'Pages and screens': 'Seiten und Bildschirme', 'Traffic acquisition': 'Akquisition von Zugriffen',
|
package/public/style.css
CHANGED
|
@@ -451,6 +451,7 @@ select option { background: #0c1a1d; color: var(--text); }
|
|
|
451
451
|
#login .btn.ghost .ico, #settingsBtn .ico { margin-right: 6px; }
|
|
452
452
|
/* viewers: hide everything that would only answer with 403 */
|
|
453
453
|
body.role-viewer #addSiteBtn, body.role-viewer #shareBtn, body.role-viewer #addFunnelBtn { display: none !important; }
|
|
454
|
+
body.site-limited #addSiteBtn { display: none !important; }
|
|
454
455
|
@media (max-width: 560px) { #meName { display: none; } .team-row { flex-wrap: wrap; } }
|
|
455
456
|
a.btn, a.btn:hover { text-decoration: none; display: inline-flex; align-items: center; gap: 6px; color: var(--text); }
|
|
456
457
|
.swatch.ann { width: 9px; height: 9px; transform: rotate(45deg); border-radius: 1px; background: var(--accent-2, #ff7a5c); }
|
package/server.js
CHANGED
|
@@ -143,6 +143,8 @@ db.exec(`
|
|
|
143
143
|
const norm = (d) => String(d || '').toLowerCase().replace(/^www\./, '').replace(/\/.*$/, '').trim();
|
|
144
144
|
// per-site time zone (Fabian): only query boundaries move, stored days stay UTC (Tyto). Old installs → 'UTC'.
|
|
145
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');
|
|
146
148
|
const getSiteByDomain = db.prepare('SELECT * FROM sites WHERE domain = ?');
|
|
147
149
|
const insSite = db.prepare("INSERT OR IGNORE INTO sites (domain, name, created, timezone) VALUES (?, ?, ?, COALESCE(?, 'UTC'))");
|
|
148
150
|
const allSites = db.prepare('SELECT * FROM sites ORDER BY domain');
|
|
@@ -317,7 +319,7 @@ function collect(req, res) {
|
|
|
317
319
|
const domain = norm(b.d || b.domain);
|
|
318
320
|
if (!domain) return res.status(204).end();
|
|
319
321
|
const site = getSiteByDomain.get(domain);
|
|
320
|
-
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
|
|
321
323
|
const platform = appPlatform(b);
|
|
322
324
|
// apps: the HTTP client's user agent says nothing about the device, so OS/device come from the beacon.
|
|
323
325
|
// A bot-looking UA is still dropped, so the bot filter cannot be bypassed by just adding `pf`.
|
|
@@ -386,7 +388,7 @@ app.get('/api/collect', collect);
|
|
|
386
388
|
|
|
387
389
|
// ── auth: accounts, team, optional 2FA + passkeys (auth.js) ───────────────────
|
|
388
390
|
const { setupAuth } = require('./auth');
|
|
389
|
-
const { auth, requireRole, siteAllowed, audit } = setupAuth({
|
|
391
|
+
const { auth, requireRole, siteAllowed, audit, grantSite, isLimited } = setupAuth({
|
|
390
392
|
app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp,
|
|
391
393
|
allSiteIds: () => db.prepare('SELECT id FROM sites').all().map((r) => r.id),
|
|
392
394
|
});
|
|
@@ -411,6 +413,9 @@ function newToken() { return crypto.randomBytes(24).toString('hex'); }
|
|
|
411
413
|
// ── sites management ─────────────────────────────────────────────────────────
|
|
412
414
|
const sitesListHandler = (req, res) => res.json(allSites.all().filter((st) => siteAllowed(req, st.id)));
|
|
413
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' });
|
|
414
419
|
const rawDomain = norm(req.body && req.body.domain);
|
|
415
420
|
let domain = rawDomain;
|
|
416
421
|
let displayName = rawDomain; // keep Unicode original for a readable display name (e.g. ernährungs-plan.de)
|
|
@@ -423,7 +428,17 @@ const sitesCreateHandler = (req, res) => {
|
|
|
423
428
|
const rawTz = req.body && req.body.timezone;
|
|
424
429
|
const timezone = rawTz == null || rawTz === '' ? null : TZ.validTz(String(rawTz));
|
|
425
430
|
if (rawTz != null && rawTz !== '' && !timezone) return res.status(400).json({ error: 'Unknown time zone' });
|
|
426
|
-
|
|
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));
|
|
427
442
|
audit(res, 'site.add', domain);
|
|
428
443
|
res.json(getSiteByDomain.get(domain));
|
|
429
444
|
};
|
|
@@ -435,15 +450,27 @@ app.patch('/api/sites/:id', adminOnly, (req, res) => {
|
|
|
435
450
|
if (!siteAllowed(req, id)) return res.status(403).json(NO_SITE);
|
|
436
451
|
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(id);
|
|
437
452
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
438
|
-
const
|
|
439
|
-
if (
|
|
440
|
-
|
|
441
|
-
|
|
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
|
+
}
|
|
442
467
|
res.json(db.prepare('SELECT * FROM sites WHERE id = ?').get(id));
|
|
443
468
|
});
|
|
444
469
|
app.delete('/api/sites/:id', adminOnly, (req, res) => {
|
|
445
470
|
const id = Number(req.params.id);
|
|
446
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' });
|
|
447
474
|
const gone = db.prepare('SELECT domain FROM sites WHERE id = ?').get(id);
|
|
448
475
|
audit(res, 'site.remove', gone ? gone.domain : '#' + id);
|
|
449
476
|
db.prepare('DELETE FROM events WHERE site_id = ?').run(id);
|
|
@@ -464,10 +491,20 @@ app.get('/api/v1', (_req, res) => res.json({
|
|
|
464
491
|
'POST /api/v1/sites': 'add a site { domain }',
|
|
465
492
|
'GET /api/v1/stats?site=&period=': 'aggregated stats (period: today|7d|30d|90d|12mo, or from=YYYY-MM-DD&to=YYYY-MM-DD)',
|
|
466
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',
|
|
467
495
|
},
|
|
468
496
|
filters: 'add any of country,source,page,browser,os,device,lang,medium,campaign,goal to /stats to segment',
|
|
469
497
|
}));
|
|
470
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
|
+
});
|
|
471
508
|
app.post('/api/v1/sites', adminOnly, sitesCreateHandler);
|
|
472
509
|
|
|
473
510
|
// ── stats ────────────────────────────────────────────────────────────────────
|
|
@@ -983,6 +1020,16 @@ app.get('/api/funnels/:id/stats', authOrShare, (req, res) => {
|
|
|
983
1020
|
});
|
|
984
1021
|
|
|
985
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);
|
|
986
1033
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
987
1034
|
// ── visitor journeys: session-based path transitions for a sankey flow ───────────
|
|
988
1035
|
// A "session" = one visitor's pageviews with < 30 min between them.
|
|
@@ -1093,7 +1140,7 @@ app.get('/api/alerts', auth, (req, res) => {
|
|
|
1093
1140
|
const site = getSiteByDomain.get(norm(req.query.site));
|
|
1094
1141
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
1095
1142
|
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)) });
|
|
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)) });
|
|
1097
1144
|
});
|
|
1098
1145
|
app.post('/api/alerts', adminOnly, async (req, res) => {
|
|
1099
1146
|
const b = req.body || {};
|
|
@@ -1118,7 +1165,10 @@ app.post('/api/alerts', adminOnly, async (req, res) => {
|
|
|
1118
1165
|
if (prob) return res.status(400).json({ error: prob });
|
|
1119
1166
|
if (!(await webhookResolvesPublic(b.webhook, webhookLookup))) return res.status(400).json({ error: 'Webhook must point to a public address' });
|
|
1120
1167
|
webhook = String(b.webhook);
|
|
1121
|
-
} else if (!DIGEST_WEBHOOK_URL
|
|
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
|
+
}
|
|
1122
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' });
|
|
1123
1173
|
const info = db.prepare('INSERT INTO alerts (site_id, kind, goal, threshold, window_hours, webhook, created) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
1124
1174
|
.run(site.id, b.kind, goal, threshold, windowHours, webhook, Date.now());
|
|
@@ -1173,7 +1223,7 @@ const validEmail = (e) => /^[^\s@<>",;]{1,64}@[^\s@<>",;]{1,190}\.[A-Za-z]{2,}$/
|
|
|
1173
1223
|
const { createReport, REPORT_LANGS } = require('./report');
|
|
1174
1224
|
const reporter = createReport(db);
|
|
1175
1225
|
function buildReport(user, now) {
|
|
1176
|
-
const sites = allSites.all().filter((st) => !user.sites || JSON.parse(user.sites).includes(st.id));
|
|
1226
|
+
const sites = allSites.all().filter((st) => !st.paused && (!user.sites || JSON.parse(user.sites).includes(st.id)));
|
|
1177
1227
|
return reporter.build(user, sites, now, { publicUrl: PUBLIC_URL, lang: REPORT_LANGS.includes(user.report_lang) ? user.report_lang : 'en' });
|
|
1178
1228
|
}
|
|
1179
1229
|
async function sendReport(user, now = Date.now()) {
|
|
@@ -1303,6 +1353,7 @@ const COOLDOWN = { spike: 6 * 3600e3, drop: 6 * 3600e3 };
|
|
|
1303
1353
|
async function alertTick(now = Date.now()) {
|
|
1304
1354
|
const fired = [];
|
|
1305
1355
|
for (const site of allSites.all()) {
|
|
1356
|
+
if (site.paused) continue; // a paused site collects nothing: a 'drop' alert would only announce the pause (Tyto, 2.3.0)
|
|
1306
1357
|
const rules = db.prepare('SELECT * FROM alerts WHERE site_id = ? AND enabled = 1').all(site.id);
|
|
1307
1358
|
const hasOwn = db.prepare('SELECT COUNT(*) n FROM alerts WHERE site_id = ?').get(site.id).n > 0;
|
|
1308
1359
|
let verdict = null;
|
|
@@ -1353,6 +1404,6 @@ if (process.env.TACILYTICS_TEST_HOOKS === '1') {
|
|
|
1353
1404
|
// unknown API paths are a 404 in JSON — never the SPA page. A client that asks an older/newer server for a
|
|
1354
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).
|
|
1355
1406
|
app.all(/^\/(api|auth)(\/|$)/, (_req, res) => res.status(404).json({ error: 'Not found' }));
|
|
1356
|
-
app.get('*',
|
|
1407
|
+
app.get('*', sendIndex);
|
|
1357
1408
|
|
|
1358
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` : ''}`));
|