tacilytics 2.2.0 → 2.4.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 +64 -7
- 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/public/tacilytics.js +27 -6
- package/server.js +130 -14
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
# Tacilytics
|
|
6
6
|
|
|
7
7
|
**Privacy-first, cookieless analytics for websites and apps — self-hosted in minutes.**
|
|
8
|
-
*Tacit* (adj.): understood without being said. Tacilytics counts what matters and keeps quiet about who did it — one tiny script, one Node process, one SQLite file. No cookies, no
|
|
8
|
+
*Tacit* (adj.): understood without being said. Tacilytics counts what matters and keeps quiet about who did it — one tiny script, one Node process, one SQLite file. No cookies, no IDs, no creepy tracking. (And in Italian, *taci* simply means "be quiet".)
|
|
9
9
|
|
|
10
10
|
> Formerly **Nyx Analytics**. Existing installs keep working: `/nyx.js`, `window.nyx()` and the old Flutter class names are still supported as aliases.
|
|
11
11
|
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
## Why Tacilytics
|
|
24
24
|
|
|
25
|
-
- 🍪 **Cookieless
|
|
25
|
+
- 🍪 **Cookieless.** No cookies, no IDs, nothing written to the visitor's device, no fingerprinting. **Strict mode** (`data-strict`) also reduces what the tracker reads on the device to what every page request carries anyway — see [Strict mode](#strict-mode).
|
|
26
26
|
- 🛡️ **IPs are never stored.** Unique visitors are counted with a *daily, salted, one-way hash* that’s irreversible and rotated every day.
|
|
27
27
|
- 🌍 **Region from the browser timezone**, never from IP geolocation.
|
|
28
28
|
- 🔍 **Segment everything.** Click any country, page, source, browser or campaign to filter the whole dashboard — stacked, with one-click chips.
|
|
@@ -72,7 +72,7 @@ Open the dashboard, log in, click **+ Add site**, and drop the snippet into your
|
|
|
72
72
|
<script defer data-domain="example.com" src="https://analytics.example.com/tacilytics.js"></script>
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
That’s it. Pageviews start flowing in
|
|
75
|
+
That’s it. Pageviews start flowing in, without cookies. (Want the tracker to read less on the device as well? Add `data-strict`, see [Strict mode](#strict-mode).)
|
|
76
76
|
|
|
77
77
|
## Custom events, goals & properties
|
|
78
78
|
|
|
@@ -110,11 +110,59 @@ Opt-in flags on the `<script>` tag:
|
|
|
110
110
|
```html
|
|
111
111
|
<script defer data-domain="example.com"
|
|
112
112
|
data-honor-dnt <!-- skip tracking if DNT / Global Privacy Control is on -->
|
|
113
|
+
data-strict <!-- reduce device reads (see Strict mode) -->
|
|
113
114
|
data-track-outbound <!-- auto-record clicks to other domains as events -->
|
|
114
115
|
data-track-downloads <!-- auto-record file downloads (pdf, zip, docx …) as "File Download"; or ="pdf,epub" -->
|
|
115
116
|
src="https://analytics.example.com/tacilytics.js"></script>
|
|
116
117
|
```
|
|
117
118
|
|
|
119
|
+
### Strict mode
|
|
120
|
+
|
|
121
|
+
The default tracker writes nothing to the visitor's device, but it does **read** a few things there: one `localStorage` flag (your own visits, below), the time zone (→ country), the browser language, the window size (→ scroll depth) and, with `data-honor-dnt`, the DNT/GPC setting. EU device-access rules (ePrivacy Art. 5(3), in Germany § 25 TDDDG) cover reading as well as storing, and the EDPB reads "access" broadly (Guidelines 2/2023 also count information a script makes the browser send). Strict mode reduces device access to what every page request carries anyway:
|
|
122
|
+
|
|
123
|
+
```html
|
|
124
|
+
<script defer data-domain="example.com" data-strict data-honor-dnt src="https://analytics.example.com/tacilytics.js"></script>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
In strict mode the tracker does not read local storage, the time zone, the language, the window or screen size, or the DNT/GPC setting. It sends what the page knows: domain, path, referrer, UTM parameters and time on page; the browser adds its usual request headers. To get there it still uses page state, not device properties: `location` and the query string (UTM), `document.referrer`, the DOM for the optional 404 and site-search detection, and a check that `navigator.sendBeacon` exists. Some authorities (EDPB Guidelines 2/2023) read the rules more broadly than "device properties", so do not take "strict" as "nothing at all". The server takes the language from `Accept-Language`, and with `data-honor-dnt` it checks the `DNT` / `Sec-GPC` request headers. Two things are still used for timing and never sent: the page's visibility state (`document.visibilityState`) and the device clock (`Date.now()`, only differences, for time on page). A test runs the tracker with every other device API booby-trapped and fails if strict mode touches one.
|
|
128
|
+
|
|
129
|
+
**WordPress plugin:** its role exclusion (don't count logged-in admins, editors …) uses a `localStorage` marker in a small inline script before the tracker. Turn the role exclusion off when you use strict mode; a later plugin release will handle it on the server.
|
|
130
|
+
|
|
131
|
+
What you give up: **no country** (it came from the time zone), **no scroll depth** (time on page stays), and `?tacilytics=off` does not work (the console says so) — exclude your own visits another way (e.g. a test browser you don't count, or filter yourself out in the dashboard). Strict mode does not decide whether you need consent; that depends on your jurisdiction and the rest of your website. This is not legal advice.
|
|
132
|
+
|
|
133
|
+
### Server mode (no script at all)
|
|
134
|
+
|
|
135
|
+
Server mode runs no script and reads or stores nothing on the visitor's device. Your web server reports each page view itself, from the request the visitor sent to your page anyway: path, `Referer`, `Accept-Language`, `User-Agent` and the visitor IP. Don't add the `<script>` tag on pages that use server mode; mixing both on one page would bring the script back.
|
|
136
|
+
|
|
137
|
+
```http
|
|
138
|
+
POST /api/v1/hit
|
|
139
|
+
Authorization: Bearer tk_… # a key created with write access (Settings → API keys), not read-only
|
|
140
|
+
Content-Type: application/json
|
|
141
|
+
|
|
142
|
+
{ "domain": "example.com", "path": "/shop?utm_source=news", "ip": "203.0.113.7",
|
|
143
|
+
"ua": "<User-Agent header>", "referrer": "<Referer header>", "lang": "<Accept-Language header>",
|
|
144
|
+
"purpose": "<Sec-Purpose or Purpose header>" }
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Node / Express, after your static files and before your pages:
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
app.use((req, res, next) => {
|
|
151
|
+
if (req.method === 'GET' && req.accepts('html')) fetch('https://analytics.example.com/api/v1/hit', {
|
|
152
|
+
method: 'POST', headers: { Authorization: 'Bearer ' + process.env.TACILYTICS_KEY, 'Content-Type': 'application/json' },
|
|
153
|
+
body: JSON.stringify({ domain: 'example.com', path: req.originalUrl, ip: req.ip, ua: req.get('user-agent'),
|
|
154
|
+
referrer: req.get('referer'), lang: req.get('accept-language'), purpose: req.get('sec-purpose') || req.get('purpose') }),
|
|
155
|
+
}).catch(() => {});
|
|
156
|
+
next();
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
- **The visitor IP** is only accepted here, with a write key, never on the public `/api/collect`. It is hashed like a tracker hit (daily salt) and never stored. Behind a proxy or load balancer, send the real visitor IP (e.g. `req.ip` with Express `trust proxy` set right), not your proxy's. Without an IP the hit is refused (`400`), because all visitors would fall onto one hash.
|
|
161
|
+
- **Filtered on our side:** bots and monitors (same list as the tracker), requests without a user agent, prefetch / prerender (`purpose`), and `dnt: true` / `gpc: true` if you choose to forward those headers. A site-limited key can only report for its own sites.
|
|
162
|
+
- **Page caches and CDNs:** if a cache plugin or a CDN (e.g. Cloudflare) answers the request, your server code never runs and the visit is not reported. Report from somewhere every request passes, or exclude HTML from the cache.
|
|
163
|
+
- **What you give up compared with the script:** no time on page, no scroll depth, no click / outbound / download events, no country, and no `?tacilytics=off`. Server-side goals work: add `"name": "Purchase", "props": { "value": 49.9 }`.
|
|
164
|
+
- **Data protection:** the IP and user agent are personal data while they are hashed. That needs a legal basis (typically legitimate interest, GDPR Art. 6(1)(f)) and a sentence in your privacy policy. If someone else runs Tacilytics for you (for example Tacilytics Cloud), your server passes visitor IPs to them, so you need a data processing agreement (DPA). Server mode does not decide whether you need consent for the rest of your website. This is not legal advice.
|
|
165
|
+
|
|
118
166
|
**Your own visits:** open your site once with `?tacilytics=off` (e.g. `https://example.com/?tacilytics=off`) and that browser is no longer counted on that site. `?tacilytics=on` undoes it. It is stored in the browser's localStorage, nothing is sent; repeat it on every browser and phone you use. Settings → Security has the links for all your sites.
|
|
119
167
|
|
|
120
168
|
## Mobile & desktop apps
|
|
@@ -225,6 +273,7 @@ curl -H "Authorization: Bearer $TACILYTICS_API_KEY" \
|
|
|
225
273
|
| `POST /api/v1/sites` | add a site `{ domain }` |
|
|
226
274
|
| `GET /api/v1/stats?site=&period=` | aggregated stats — `today \| 7d \| 30d \| 90d \| 12mo`, or `from=YYYY-MM-DD&to=YYYY-MM-DD` |
|
|
227
275
|
| `GET /api/v1/realtime?site=` | online count, 30-min pulse + recent events |
|
|
276
|
+
| `POST /api/v1/hit` | server mode: report a page view from your web server (write key), see [Server mode](#server-mode-no-script-at-all) |
|
|
228
277
|
|
|
229
278
|
Add any of `country, source, page, browser, os, device, lang, medium, campaign, goal` to `/stats` to segment, e.g. `…/stats?site=example.com&period=30d&country=DE&medium=email`.
|
|
230
279
|
|
|
@@ -256,8 +305,9 @@ row per pageview. The dashboard aggregates it with plain SQL.
|
|
|
256
305
|
|
|
257
306
|
**The cookieless trade-off, honestly:** because there’s no persistent identifier, Tacilytics
|
|
258
307
|
counts unique visitors *per day* and can’t follow the same person across days or
|
|
259
|
-
devices. You get the 95 % that matters (how many, which pages, from where)
|
|
260
|
-
|
|
308
|
+
devices. You get the 95 % that matters (how many, which pages, from where) without cookies,
|
|
309
|
+
the same approach as Plausible/Fathom. Whether you still need consent depends on your
|
|
310
|
+
jurisdiction and setup; strict mode (`data-strict`) reduces the device reads further.
|
|
261
311
|
|
|
262
312
|
## Light mode & German
|
|
263
313
|
|
|
@@ -273,9 +323,11 @@ Out of the box the dashboard opens with one shared passphrase (`WEB_PASSWORD`).
|
|
|
273
323
|
| Role | Can |
|
|
274
324
|
|---|---|
|
|
275
325
|
| **Owner** | everything, including the team |
|
|
276
|
-
| **Admin** | add/remove sites, share links, funnels |
|
|
326
|
+
| **Admin** | add/remove sites, share links, funnels — optionally limited to **specific sites** |
|
|
277
327
|
| **Viewer** | read only — optionally limited to **specific sites** |
|
|
278
328
|
|
|
329
|
+
**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.
|
|
330
|
+
|
|
279
331
|
Everyone can turn on, **optionally**:
|
|
280
332
|
|
|
281
333
|
- **Passkeys** (Face ID, Touch ID, Windows Hello, security keys). A passkey replaces password *and* code and cannot be phished.
|
|
@@ -303,6 +355,7 @@ The server-wide `API_KEY` from `.env` keeps owner rights for agents and the CLI
|
|
|
303
355
|
| `API_KEY` | — | Bearer key for the API / CLI |
|
|
304
356
|
| `DB_PATH` | `./data/analytics.db` | SQLite path |
|
|
305
357
|
| `DIGEST_WEBHOOK_URL` | — | Daily digest + default alert target (Discord/Slack-compatible). `off` disables it |
|
|
358
|
+
| `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
359
|
| `WATCH_MIN_VISITORS` | `10` | Spike/drop alerts stay silent below this many visitors per hour |
|
|
307
360
|
| `SMTP_URL` / `REPORT_FROM` | — | Mail server + sender for the weekly report |
|
|
308
361
|
| `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 |
|
|
@@ -317,6 +370,8 @@ The server-wide `API_KEY` from `.env` keeps owner rights for agents and the CLI
|
|
|
317
370
|
- **One process only.** The salt is drawn by the single Node process. Running several processes against the same `data/` would let each draw its own salt at midnight.
|
|
318
371
|
- **Data minimisation:** set `DATA_RETENTION_DAYS` to auto-purge raw events after your chosen window.
|
|
319
372
|
- **Respects DNT / Global Privacy Control** when you add `data-honor-dnt` to the tag.
|
|
373
|
+
- **Strict mode** (`data-strict`) skips the device reads (no localStorage, time zone, language or window size). See [Strict mode](#strict-mode).
|
|
374
|
+
- **Server mode** (`POST /api/v1/hit`) runs no script on the visitor's device at all; your server reports the page view. See [Server mode](#server-mode-no-script-at-all).
|
|
320
375
|
- Engagement time adds **no new identifier** — it’s matched to the visitor’s own daily hash server-side.
|
|
321
376
|
- Custom event properties are capped (scalars only, size-limited) so they can’t become a PII dumping ground.
|
|
322
377
|
- Passwords hashed with scrypt; session tokens, invite links and recovery codes are stored only as SHA-256. Sessions live in the database (survive restarts, expire after 30 days).
|
|
@@ -329,7 +384,9 @@ The server-wide `API_KEY` from `.env` keeps owner rights for agents and the CLI
|
|
|
329
384
|
|
|
330
385
|
| | Tacilytics | Google Analytics |
|
|
331
386
|
|---|---|---|
|
|
332
|
-
| Cookies
|
|
387
|
+
| Cookies | ❌ none | ✅ yes |
|
|
388
|
+
| Reads from the device | a few (time zone, language, window size, one localStorage flag); none in strict mode | ✅ a lot |
|
|
389
|
+
| Works without any script on the visitor's device | ✅ server mode | ❌ |
|
|
333
390
|
| Stores IPs / personal data | ❌ never | ✅ yes |
|
|
334
391
|
| Self-hosted, you own the data | ✅ | ❌ |
|
|
335
392
|
| Weight | ~2 KB | ~45 KB+ |
|
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/public/tacilytics.js
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
Optional data-* flags on the <script> tag:
|
|
6
6
|
data-honor-dnt respect Do-Not-Track / Global Privacy Control (skip all tracking)
|
|
7
|
+
data-strict strict mode: no localStorage, no timezone, no language, no window/screen size,
|
|
8
|
+
no DNT lookup in JS (only document.visibilityState and Date.now() differences for
|
|
9
|
+
time on page, never sent). Language comes from the Accept-Language header,
|
|
10
|
+
DNT/GPC from the request headers (with data-honor-dnt). Costs: no country, no scroll
|
|
11
|
+
depth, ?tacilytics=off does not work (exclude own visits another way).
|
|
7
12
|
data-track-outbound auto-record clicks on links to other domains as events
|
|
8
13
|
data-track-downloads auto-record file downloads as "File Download" events (optional value: "pdf,zip,…")
|
|
9
14
|
data-track-404 record a "404" event when the page is an error page
|
|
@@ -20,15 +25,19 @@
|
|
|
20
25
|
var domain = s && s.getAttribute('data-domain');
|
|
21
26
|
if (!s || !domain) return;
|
|
22
27
|
|
|
28
|
+
var strict = s.hasAttribute('data-strict');
|
|
29
|
+
var honorDnt = s.hasAttribute('data-honor-dnt');
|
|
30
|
+
|
|
23
31
|
// Privacy signals (opt-in): if the visitor asked not to be tracked, honour it and do nothing.
|
|
24
|
-
|
|
32
|
+
// In strict mode the server checks the DNT / Sec-GPC request headers instead (nothing is read here).
|
|
33
|
+
if (honorDnt && !strict) {
|
|
25
34
|
var dnt = navigator.doNotTrack || window.doNotTrack || navigator.msDoNotTrack;
|
|
26
35
|
if (navigator.globalPrivacyControl === true || dnt === '1' || dnt === 'yes') return;
|
|
27
36
|
}
|
|
28
37
|
|
|
29
38
|
// "Don't count this browser" (your own visits): open the site once with ?tacilytics=off
|
|
30
39
|
// (?tacilytics=on counts it again). Remembered per browser in localStorage, nothing is sent.
|
|
31
|
-
try {
|
|
40
|
+
if (!strict) try {
|
|
32
41
|
var optParam = new URLSearchParams(location.search).get('tacilytics');
|
|
33
42
|
if (optParam === 'off') { localStorage.setItem('tacilytics_ignore', '1'); console.info('Tacilytics: this browser is no longer counted on ' + location.hostname + ' (undo: ?tacilytics=on)'); }
|
|
34
43
|
else if (optParam === 'on') { localStorage.removeItem('tacilytics_ignore'); console.info('Tacilytics: this browser is counted again on ' + location.hostname); }
|
|
@@ -37,11 +46,17 @@
|
|
|
37
46
|
return;
|
|
38
47
|
}
|
|
39
48
|
} catch (e) {} // storage blocked → just count normally
|
|
49
|
+
else if (/[?&]tacilytics=(off|on)(&|$)/.test(location.search)) {
|
|
50
|
+
// the query string is part of the page address, not the device
|
|
51
|
+
try { console.info('Tacilytics: strict mode, ?tacilytics=off/on is not supported (it needs localStorage)'); } catch (e) {}
|
|
52
|
+
}
|
|
40
53
|
|
|
41
54
|
var endpoint = new URL(s.src).origin + '/api/collect';
|
|
42
|
-
var tz = '';
|
|
43
|
-
|
|
44
|
-
|
|
55
|
+
var tz = '', lang = '';
|
|
56
|
+
if (!strict) {
|
|
57
|
+
try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) {}
|
|
58
|
+
lang = (navigator.language || '').slice(0, 12);
|
|
59
|
+
}
|
|
45
60
|
var qs = new URLSearchParams(location.search);
|
|
46
61
|
var first = true;
|
|
47
62
|
|
|
@@ -68,6 +83,7 @@
|
|
|
68
83
|
um: qs.get('utm_medium') || '',
|
|
69
84
|
uc: qs.get('utm_campaign') || '',
|
|
70
85
|
};
|
|
86
|
+
if (strict) { payload.st = 1; if (honorDnt) payload.hd = 1; }
|
|
71
87
|
if (name) { payload.n = name; if (props && typeof props === 'object') payload.pr = props; }
|
|
72
88
|
else first = false;
|
|
73
89
|
post(payload);
|
|
@@ -77,6 +93,7 @@
|
|
|
77
93
|
var acc = 0, since = (document.visibilityState === 'visible') ? Date.now() : 0, engPath = location.pathname;
|
|
78
94
|
var maxScroll = 0;
|
|
79
95
|
function measureScroll() {
|
|
96
|
+
if (strict) return; // window and screen size are device information
|
|
80
97
|
var de = document.documentElement, b = document.body || de;
|
|
81
98
|
var h = Math.max(de.scrollHeight, b.scrollHeight), vh = window.innerHeight || de.clientHeight;
|
|
82
99
|
var y = window.pageYOffset || de.scrollTop || 0;
|
|
@@ -91,7 +108,11 @@
|
|
|
91
108
|
function flushEngagement() {
|
|
92
109
|
var secs = engaged();
|
|
93
110
|
measureScroll();
|
|
94
|
-
if (secs >= 1 || maxScroll > 0)
|
|
111
|
+
if (secs >= 1 || maxScroll > 0) {
|
|
112
|
+
var eb = { d: domain, p: engPath, e: 'engagement', dur: secs };
|
|
113
|
+
if (strict) { eb.st = 1; if (honorDnt) eb.hd = 1; } else eb.sc = maxScroll;
|
|
114
|
+
post(eb);
|
|
115
|
+
}
|
|
95
116
|
}
|
|
96
117
|
function resetEngagement() { acc = 0; since = Date.now(); engPath = location.pathname; maxScroll = 0; }
|
|
97
118
|
document.addEventListener('visibilitychange', function () {
|
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');
|
|
@@ -237,6 +239,11 @@ function parseUA(ua) {
|
|
|
237
239
|
|
|
238
240
|
// Rough timezone → ISO country (privacy-preserving alternative to IP geo).
|
|
239
241
|
const TZ_COUNTRY = require('./tz-country.js');
|
|
242
|
+
// first language of an Accept-Language header ("de-DE,de;q=0.9,en;q=0.8" → "de-DE"), or null
|
|
243
|
+
function acceptLang(h) {
|
|
244
|
+
const m = String(h || '').split(',')[0].split(';')[0].trim().match(/^[A-Za-z]{1,8}(-[A-Za-z0-9]{1,8})?$/);
|
|
245
|
+
return m ? m[0].slice(0, 12) : null;
|
|
246
|
+
}
|
|
240
247
|
function countryFromTz(tz) { return (tz && TZ_COUNTRY[tz]) || null; }
|
|
241
248
|
|
|
242
249
|
const { createDaySalt, dayStr } = require('./day-salt.js');
|
|
@@ -317,7 +324,7 @@ function collect(req, res) {
|
|
|
317
324
|
const domain = norm(b.d || b.domain);
|
|
318
325
|
if (!domain) return res.status(204).end();
|
|
319
326
|
const site = getSiteByDomain.get(domain);
|
|
320
|
-
if (!site) return res.status(202).end(); // unknown site → silently ignore
|
|
327
|
+
if (!site || site.paused) return res.status(202).end(); // unknown or paused site → silently ignore
|
|
321
328
|
const platform = appPlatform(b);
|
|
322
329
|
// apps: the HTTP client's user agent says nothing about the device, so OS/device come from the beacon.
|
|
323
330
|
// A bot-looking UA is still dropped, so the bot filter cannot be bypassed by just adding `pf`.
|
|
@@ -328,6 +335,10 @@ function collect(req, res) {
|
|
|
328
335
|
ua = { browser: 'App', os: APP_PLATFORMS[platform], device: dv === 'tablet' ? 'Tablet' : dv === 'desktop' ? 'Desktop' : (platform === 'android' || platform === 'ios') ? 'Mobile' : 'Desktop' };
|
|
329
336
|
}
|
|
330
337
|
if (!ua) return res.status(204).end(); // bot
|
|
338
|
+
// strict mode (data-strict): the tracker read nothing on the device. Privacy signals and language come
|
|
339
|
+
// from headers the browser sends with every request anyway.
|
|
340
|
+
const strict = String(b.st || '') === '1';
|
|
341
|
+
if (strict && String(b.hd || '') === '1' && (req.headers['dnt'] === '1' || req.headers['sec-gpc'] === '1')) return res.status(204).end();
|
|
331
342
|
|
|
332
343
|
const ts = Date.now();
|
|
333
344
|
const day = dayStr(ts);
|
|
@@ -350,7 +361,7 @@ function collect(req, res) {
|
|
|
350
361
|
// engagement beacon: attach a visit duration (+ max scroll depth) to the visitor's latest pageview (no new row).
|
|
351
362
|
if (b.e === 'engagement') {
|
|
352
363
|
const dur = Math.max(0, Math.min(7200, parseInt(b.dur, 10) || 0)); // cap at 2h
|
|
353
|
-
const sc = b.sc == null || b.sc === '' ? null : Math.max(0, Math.min(100, parseInt(b.sc, 10) || 0));
|
|
364
|
+
const sc = strict || b.sc == null || b.sc === '' ? null : Math.max(0, Math.min(100, parseInt(b.sc, 10) || 0));
|
|
354
365
|
if (dur > 0 || sc != null) updDur.run({ site_id: site.id, vh, path: pth, dur, scroll: sc, since: ts - 6 * 3600000 });
|
|
355
366
|
return res.status(204).end();
|
|
356
367
|
}
|
|
@@ -365,8 +376,8 @@ function collect(req, res) {
|
|
|
365
376
|
}
|
|
366
377
|
const utmMedium = String(b.um || b.utm_medium || '').slice(0, 60).trim() || null;
|
|
367
378
|
const utmCampaign = String(b.uc || b.utm_campaign || '').slice(0, 80).trim() || null;
|
|
368
|
-
const tz = String(b.tz || '').slice(0, 60) || null;
|
|
369
|
-
const lang = String(b.l || b.lang || '').slice(0, 12).split(',')[0] || null;
|
|
379
|
+
const tz = strict ? null : (String(b.tz || '').slice(0, 60) || null);
|
|
380
|
+
const lang = strict ? acceptLang(req.headers['accept-language']) : (String(b.l || b.lang || '').slice(0, 12).split(',')[0] || null);
|
|
370
381
|
const name = String(b.n || b.name || '').slice(0, 80).trim() || null; // custom event / goal
|
|
371
382
|
const props = name ? sanitizeProps(b.pr) : null; // properties only attach to named events
|
|
372
383
|
let country = countryFromTz(tz);
|
|
@@ -386,7 +397,7 @@ app.get('/api/collect', collect);
|
|
|
386
397
|
|
|
387
398
|
// ── auth: accounts, team, optional 2FA + passkeys (auth.js) ───────────────────
|
|
388
399
|
const { setupAuth } = require('./auth');
|
|
389
|
-
const { auth, requireRole, siteAllowed, audit } = setupAuth({
|
|
400
|
+
const { auth, requireRole, siteAllowed, audit, grantSite, isLimited } = setupAuth({
|
|
390
401
|
app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp,
|
|
391
402
|
allSiteIds: () => db.prepare('SELECT id FROM sites').all().map((r) => r.id),
|
|
392
403
|
});
|
|
@@ -411,6 +422,9 @@ function newToken() { return crypto.randomBytes(24).toString('hex'); }
|
|
|
411
422
|
// ── sites management ─────────────────────────────────────────────────────────
|
|
412
423
|
const sitesListHandler = (req, res) => res.json(allSites.all().filter((st) => siteAllowed(req, st.id)));
|
|
413
424
|
const sitesCreateHandler = (req, res) => {
|
|
425
|
+
// a site-limited account (a hosting customer) never creates sites itself: the operator does, because sites are what
|
|
426
|
+
// is billed and paused. Otherwise a paused customer could add a fresh, unpaused site and move the snippet (Kiro, 2.3.0).
|
|
427
|
+
if (isLimited(req)) return res.status(403).json({ error: 'Your role does not allow this' });
|
|
414
428
|
const rawDomain = norm(req.body && req.body.domain);
|
|
415
429
|
let domain = rawDomain;
|
|
416
430
|
let displayName = rawDomain; // keep Unicode original for a readable display name (e.g. ernährungs-plan.de)
|
|
@@ -423,7 +437,17 @@ const sitesCreateHandler = (req, res) => {
|
|
|
423
437
|
const rawTz = req.body && req.body.timezone;
|
|
424
438
|
const timezone = rawTz == null || rawTz === '' ? null : TZ.validTz(String(rawTz));
|
|
425
439
|
if (rawTz != null && rawTz !== '' && !timezone) return res.status(400).json({ error: 'Unknown time zone' });
|
|
426
|
-
|
|
440
|
+
const existing = getSiteByDomain.get(domain);
|
|
441
|
+
if (existing) {
|
|
442
|
+
// an account limited to some sites must never get a foreign site back (or added to its list) by naming its domain
|
|
443
|
+
if (!siteAllowed(req, existing.id)) return res.status(409).json({ error: 'This domain is already registered' });
|
|
444
|
+
return res.json(existing);
|
|
445
|
+
}
|
|
446
|
+
const info = insSite.run(domain, (req.body && req.body.name) || displayName, Date.now(), timezone);
|
|
447
|
+
// defensive: if INSERT OR IGNORE did nothing (e.g. a second process sharing the DB won the domain), lastInsertRowid is
|
|
448
|
+
// stale and must never be granted. Not reachable over HTTP in one process (the handler is synchronous), so no test.
|
|
449
|
+
if (!info.changes) return res.status(409).json({ error: 'This domain is already registered' });
|
|
450
|
+
grantSite(req, Number(info.lastInsertRowid));
|
|
427
451
|
audit(res, 'site.add', domain);
|
|
428
452
|
res.json(getSiteByDomain.get(domain));
|
|
429
453
|
};
|
|
@@ -435,15 +459,27 @@ app.patch('/api/sites/:id', adminOnly, (req, res) => {
|
|
|
435
459
|
if (!siteAllowed(req, id)) return res.status(403).json(NO_SITE);
|
|
436
460
|
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(id);
|
|
437
461
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
438
|
-
const
|
|
439
|
-
if (
|
|
440
|
-
|
|
441
|
-
|
|
462
|
+
const b = req.body || {};
|
|
463
|
+
if (b.paused !== undefined) {
|
|
464
|
+
// pausing is the operator's switch (hosting: trial or subscription ended), never the customer's
|
|
465
|
+
if (isLimited(req)) return res.status(403).json({ error: 'Your role does not allow this' });
|
|
466
|
+
const paused = b.paused ? 1 : 0;
|
|
467
|
+
db.prepare('UPDATE sites SET paused = ? WHERE id = ?').run(paused, id);
|
|
468
|
+
if (paused !== site.paused) audit(res, paused ? 'site.pause' : 'site.resume', site.domain);
|
|
469
|
+
}
|
|
470
|
+
if (b.timezone !== undefined || b.paused === undefined) {
|
|
471
|
+
const timezone = TZ.validTz(String(b.timezone || ''));
|
|
472
|
+
if (!timezone) return res.status(400).json({ error: 'Unknown time zone' });
|
|
473
|
+
db.prepare('UPDATE sites SET timezone = ? WHERE id = ?').run(timezone, id);
|
|
474
|
+
if (timezone !== site.timezone) audit(res, 'site.timezone', site.domain + ': ' + site.timezone + ' → ' + timezone);
|
|
475
|
+
}
|
|
442
476
|
res.json(db.prepare('SELECT * FROM sites WHERE id = ?').get(id));
|
|
443
477
|
});
|
|
444
478
|
app.delete('/api/sites/:id', adminOnly, (req, res) => {
|
|
445
479
|
const id = Number(req.params.id);
|
|
446
480
|
if (!siteAllowed(req, id)) return res.status(403).json(NO_SITE);
|
|
481
|
+
// hard delete frees the domain and resets usage: only the operator (unlimited admin/owner) may do it (Tyto/Kiro, 2.3.0)
|
|
482
|
+
if (isLimited(req)) return res.status(403).json({ error: 'Your role does not allow this' });
|
|
447
483
|
const gone = db.prepare('SELECT domain FROM sites WHERE id = ?').get(id);
|
|
448
484
|
audit(res, 'site.remove', gone ? gone.domain : '#' + id);
|
|
449
485
|
db.prepare('DELETE FROM events WHERE site_id = ?').run(id);
|
|
@@ -464,12 +500,78 @@ app.get('/api/v1', (_req, res) => res.json({
|
|
|
464
500
|
'POST /api/v1/sites': 'add a site { domain }',
|
|
465
501
|
'GET /api/v1/stats?site=&period=': 'aggregated stats (period: today|7d|30d|90d|12mo, or from=YYYY-MM-DD&to=YYYY-MM-DD)',
|
|
466
502
|
'GET /api/v1/realtime?site=': 'online count, last-30-min pulse + recent events',
|
|
503
|
+
'GET /api/v1/usage?month=YYYY-MM': 'page views per site in a calendar month (UTC), owner / server key only',
|
|
504
|
+
'POST /api/v1/hit': 'server mode: report a page view from your web server { domain, path, ip, ua, referrer?, lang?, purpose?, name? } (write key)',
|
|
467
505
|
},
|
|
468
506
|
filters: 'add any of country,source,page,browser,os,device,lang,medium,campaign,goal to /stats to segment',
|
|
469
507
|
}));
|
|
470
508
|
app.get('/api/v1/sites', auth, sitesListHandler);
|
|
509
|
+
// page views per site in one calendar month (UTC), for hosting volume checks. Operator only:
|
|
510
|
+
// owners or the server API key with access to all sites, never a site-limited account.
|
|
511
|
+
app.get('/api/v1/usage', requireRole('owner'), (req, res) => {
|
|
512
|
+
if (isLimited(req)) return res.status(403).json({ error: 'Your role does not allow this' });
|
|
513
|
+
const m = String(req.query.month || new Date().toISOString().slice(0, 7));
|
|
514
|
+
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(m)) return res.status(400).json({ error: 'month must be YYYY-MM' });
|
|
515
|
+
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);
|
|
516
|
+
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));
|
|
517
|
+
});
|
|
471
518
|
app.post('/api/v1/sites', adminOnly, sitesCreateHandler);
|
|
472
519
|
|
|
520
|
+
// ── server mode (2.4): the website's own server reports a page view, no script on the visitor's device ──
|
|
521
|
+
// The site's backend forwards what the visitor's request carried anyway: path, Referer, Accept-Language,
|
|
522
|
+
// User-Agent and the visitor IP. The IP is only taken from an authenticated write key (never from the
|
|
523
|
+
// public /api/collect), otherwise anyone could feed arbitrary IPs into the visitor hash. It is hashed
|
|
524
|
+
// exactly like a tracker hit and never stored.
|
|
525
|
+
const net = require('net');
|
|
526
|
+
const PREFETCH_RE = /prefetch|prerender/i;
|
|
527
|
+
// write access needed (admin role or a non-read-only key); a read-only key is a viewer and gets 403
|
|
528
|
+
app.post('/api/v1/hit', adminOnly, (req, res) => {
|
|
529
|
+
if (rateLimited('h:' + clientIp(req), 6000, 60000)) return res.status(429).json({ error: 'Too many hits' });
|
|
530
|
+
const b = req.body || {};
|
|
531
|
+
const domain = norm(b.domain || b.d);
|
|
532
|
+
const site = domain && getSiteByDomain.get(domain);
|
|
533
|
+
if (!site || !siteAllowed(req, site.id)) return res.status(404).json({ error: 'Unknown site' });
|
|
534
|
+
if (site.paused) return res.status(202).end();
|
|
535
|
+
const ip = String(b.ip || '').trim();
|
|
536
|
+
// without the visitor IP every visitor would fall onto one hash: refuse instead of counting wrong
|
|
537
|
+
if (!net.isIP(ip)) return res.status(400).json({ error: 'ip (the visitor IP) is required for server mode' });
|
|
538
|
+
// prefetch / prerender requests are not visits (Sec-Purpose / Purpose header of the visitor request)
|
|
539
|
+
if (PREFETCH_RE.test(String(b.purpose || ''))) return res.status(204).end();
|
|
540
|
+
const rawUa = String(b.ua || '').slice(0, 512);
|
|
541
|
+
const ua = parseUA(rawUa);
|
|
542
|
+
if (!ua) return res.status(204).end(); // bot, crawler, monitor or no user agent
|
|
543
|
+
if (b.dnt === true || b.dnt === '1' || b.gpc === true || b.gpc === '1') return res.status(204).end(); // forwarded DNT / GPC (opt-in on the sending side)
|
|
544
|
+
|
|
545
|
+
const ts = Date.now();
|
|
546
|
+
const day = dayStr(ts);
|
|
547
|
+
let url;
|
|
548
|
+
try { url = new URL(String(b.path || b.p || '/').slice(0, 1024), 'http://x'); } catch { return res.status(400).json({ error: 'invalid path' }); }
|
|
549
|
+
let pth = url.pathname.slice(0, 512);
|
|
550
|
+
try { pth = decodeURI(pth); } catch {}
|
|
551
|
+
if (pth.length > 1) pth = pth.replace(/\/+$/, '') || '/';
|
|
552
|
+
const vh = visitorHash(site.id, ip, rawUa, day);
|
|
553
|
+
const intern = INTERNAL_PATHS[domain] || [];
|
|
554
|
+
if (intern.some((pre) => pth.startsWith(pre))) { insInternal.run(site.id, vh, day); return res.status(204).end(); }
|
|
555
|
+
if (isInternal.get(site.id, vh, day)) return res.status(204).end();
|
|
556
|
+
|
|
557
|
+
const referrer = String(b.referrer || b.r || '').slice(0, 512);
|
|
558
|
+
const q = url.searchParams;
|
|
559
|
+
let source = 'Direct';
|
|
560
|
+
const utm = String(q.get('utm_source') || q.get('ref') || '').slice(0, 60).trim();
|
|
561
|
+
if (utm) source = utm;
|
|
562
|
+
else if (referrer) { try { const h = norm(new URL(referrer).hostname); if (h && h !== domain) source = h; } catch {} }
|
|
563
|
+
const name = String(b.name || b.n || '').slice(0, 80).trim() || null; // optional server-side goal (e.g. Purchase)
|
|
564
|
+
insEvent.run({
|
|
565
|
+
site_id: site.id, ts, day, path: pth, source, referrer: referrer || null,
|
|
566
|
+
country: null, tz: null, browser: ua.browser, os: ua.os, device: ua.device,
|
|
567
|
+
lang: acceptLang(b.lang), name,
|
|
568
|
+
utm_medium: String(q.get('utm_medium') || '').slice(0, 60).trim() || null,
|
|
569
|
+
utm_campaign: String(q.get('utm_campaign') || '').slice(0, 80).trim() || null,
|
|
570
|
+
dur: null, props: name ? sanitizeProps(b.props) : null, vh, platform: null, app_version: null,
|
|
571
|
+
});
|
|
572
|
+
res.status(204).end();
|
|
573
|
+
});
|
|
574
|
+
|
|
473
575
|
// ── stats ────────────────────────────────────────────────────────────────────
|
|
474
576
|
const TZ = require('./tz');
|
|
475
577
|
const PERIODS = { today: 1, '7d': 7, '30d': 30, '90d': 90, '12mo': 365 };
|
|
@@ -983,6 +1085,16 @@ app.get('/api/funnels/:id/stats', authOrShare, (req, res) => {
|
|
|
983
1085
|
});
|
|
984
1086
|
|
|
985
1087
|
// ── static dashboard ─────────────────────────────────────────────────────────
|
|
1088
|
+
// Hosting (e.g. Tacilytics Cloud): DASHBOARD_EXTRA_SCRIPT="/cloud/ui.js" adds one same-origin script to the
|
|
1089
|
+
// dashboard page (trial/billing banner). Only a plain local path is accepted, never a URL to another host.
|
|
1090
|
+
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 : '';
|
|
1091
|
+
const INDEX_HTML = path.join(__dirname, 'public', 'index.html');
|
|
1092
|
+
function sendIndex(_req, res) {
|
|
1093
|
+
if (!EXTRA_SCRIPT) return res.sendFile(INDEX_HTML);
|
|
1094
|
+
const html = fs.readFileSync(INDEX_HTML, 'utf8').replace('</body>', `<script src="${EXTRA_SCRIPT}" defer></script>\n</body>`);
|
|
1095
|
+
res.type('html').set('Cache-Control', 'no-cache').send(html);
|
|
1096
|
+
}
|
|
1097
|
+
app.get(['/', '/index.html'], sendIndex);
|
|
986
1098
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
987
1099
|
// ── visitor journeys: session-based path transitions for a sankey flow ───────────
|
|
988
1100
|
// A "session" = one visitor's pageviews with < 30 min between them.
|
|
@@ -1093,7 +1205,7 @@ app.get('/api/alerts', auth, (req, res) => {
|
|
|
1093
1205
|
const site = getSiteByDomain.get(norm(req.query.site));
|
|
1094
1206
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
1095
1207
|
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)) });
|
|
1208
|
+
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
1209
|
});
|
|
1098
1210
|
app.post('/api/alerts', adminOnly, async (req, res) => {
|
|
1099
1211
|
const b = req.body || {};
|
|
@@ -1118,7 +1230,10 @@ app.post('/api/alerts', adminOnly, async (req, res) => {
|
|
|
1118
1230
|
if (prob) return res.status(400).json({ error: prob });
|
|
1119
1231
|
if (!(await webhookResolvesPublic(b.webhook, webhookLookup))) return res.status(400).json({ error: 'Webhook must point to a public address' });
|
|
1120
1232
|
webhook = String(b.webhook);
|
|
1121
|
-
} else if (!DIGEST_WEBHOOK_URL
|
|
1233
|
+
} else if (!DIGEST_WEBHOOK_URL || isLimited(req)) {
|
|
1234
|
+
// the operator's webhook is for the operator: an account limited to some sites must bring its own
|
|
1235
|
+
return res.status(400).json({ error: 'Add a webhook URL' + (DIGEST_WEBHOOK_URL ? '' : ' (no default DIGEST_WEBHOOK_URL is set)') });
|
|
1236
|
+
}
|
|
1122
1237
|
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
1238
|
const info = db.prepare('INSERT INTO alerts (site_id, kind, goal, threshold, window_hours, webhook, created) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
1124
1239
|
.run(site.id, b.kind, goal, threshold, windowHours, webhook, Date.now());
|
|
@@ -1173,7 +1288,7 @@ const validEmail = (e) => /^[^\s@<>",;]{1,64}@[^\s@<>",;]{1,190}\.[A-Za-z]{2,}$/
|
|
|
1173
1288
|
const { createReport, REPORT_LANGS } = require('./report');
|
|
1174
1289
|
const reporter = createReport(db);
|
|
1175
1290
|
function buildReport(user, now) {
|
|
1176
|
-
const sites = allSites.all().filter((st) => !user.sites || JSON.parse(user.sites).includes(st.id));
|
|
1291
|
+
const sites = allSites.all().filter((st) => !st.paused && (!user.sites || JSON.parse(user.sites).includes(st.id)));
|
|
1177
1292
|
return reporter.build(user, sites, now, { publicUrl: PUBLIC_URL, lang: REPORT_LANGS.includes(user.report_lang) ? user.report_lang : 'en' });
|
|
1178
1293
|
}
|
|
1179
1294
|
async function sendReport(user, now = Date.now()) {
|
|
@@ -1303,6 +1418,7 @@ const COOLDOWN = { spike: 6 * 3600e3, drop: 6 * 3600e3 };
|
|
|
1303
1418
|
async function alertTick(now = Date.now()) {
|
|
1304
1419
|
const fired = [];
|
|
1305
1420
|
for (const site of allSites.all()) {
|
|
1421
|
+
if (site.paused) continue; // a paused site collects nothing: a 'drop' alert would only announce the pause (Tyto, 2.3.0)
|
|
1306
1422
|
const rules = db.prepare('SELECT * FROM alerts WHERE site_id = ? AND enabled = 1').all(site.id);
|
|
1307
1423
|
const hasOwn = db.prepare('SELECT COUNT(*) n FROM alerts WHERE site_id = ?').get(site.id).n > 0;
|
|
1308
1424
|
let verdict = null;
|
|
@@ -1353,6 +1469,6 @@ if (process.env.TACILYTICS_TEST_HOOKS === '1') {
|
|
|
1353
1469
|
// unknown API paths are a 404 in JSON — never the SPA page. A client that asks an older/newer server for a
|
|
1354
1470
|
// 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
1471
|
app.all(/^\/(api|auth)(\/|$)/, (_req, res) => res.status(404).json({ error: 'Not found' }));
|
|
1356
|
-
app.get('*',
|
|
1472
|
+
app.get('*', sendIndex);
|
|
1357
1473
|
|
|
1358
1474
|
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` : ''}`));
|