tacilytics 2.3.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 +60 -6
- package/package.json +1 -1
- package/public/tacilytics.js +27 -6
- package/server.js +68 -3
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
|
|
|
@@ -320,6 +370,8 @@ The server-wide `API_KEY` from `.env` keeps owner rights for agents and the CLI
|
|
|
320
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.
|
|
321
371
|
- **Data minimisation:** set `DATA_RETENTION_DAYS` to auto-purge raw events after your chosen window.
|
|
322
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).
|
|
323
375
|
- Engagement time adds **no new identifier** — it’s matched to the visitor’s own daily hash server-side.
|
|
324
376
|
- Custom event properties are capped (scalars only, size-limited) so they can’t become a PII dumping ground.
|
|
325
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).
|
|
@@ -332,7 +384,9 @@ The server-wide `API_KEY` from `.env` keeps owner rights for agents and the CLI
|
|
|
332
384
|
|
|
333
385
|
| | Tacilytics | Google Analytics |
|
|
334
386
|
|---|---|---|
|
|
335
|
-
| 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 | ❌ |
|
|
336
390
|
| Stores IPs / personal data | ❌ never | ✅ yes |
|
|
337
391
|
| Self-hosted, you own the data | ✅ | ❌ |
|
|
338
392
|
| Weight | ~2 KB | ~45 KB+ |
|
package/package.json
CHANGED
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
|
@@ -239,6 +239,11 @@ function parseUA(ua) {
|
|
|
239
239
|
|
|
240
240
|
// Rough timezone → ISO country (privacy-preserving alternative to IP geo).
|
|
241
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
|
+
}
|
|
242
247
|
function countryFromTz(tz) { return (tz && TZ_COUNTRY[tz]) || null; }
|
|
243
248
|
|
|
244
249
|
const { createDaySalt, dayStr } = require('./day-salt.js');
|
|
@@ -330,6 +335,10 @@ function collect(req, res) {
|
|
|
330
335
|
ua = { browser: 'App', os: APP_PLATFORMS[platform], device: dv === 'tablet' ? 'Tablet' : dv === 'desktop' ? 'Desktop' : (platform === 'android' || platform === 'ios') ? 'Mobile' : 'Desktop' };
|
|
331
336
|
}
|
|
332
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();
|
|
333
342
|
|
|
334
343
|
const ts = Date.now();
|
|
335
344
|
const day = dayStr(ts);
|
|
@@ -352,7 +361,7 @@ function collect(req, res) {
|
|
|
352
361
|
// engagement beacon: attach a visit duration (+ max scroll depth) to the visitor's latest pageview (no new row).
|
|
353
362
|
if (b.e === 'engagement') {
|
|
354
363
|
const dur = Math.max(0, Math.min(7200, parseInt(b.dur, 10) || 0)); // cap at 2h
|
|
355
|
-
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));
|
|
356
365
|
if (dur > 0 || sc != null) updDur.run({ site_id: site.id, vh, path: pth, dur, scroll: sc, since: ts - 6 * 3600000 });
|
|
357
366
|
return res.status(204).end();
|
|
358
367
|
}
|
|
@@ -367,8 +376,8 @@ function collect(req, res) {
|
|
|
367
376
|
}
|
|
368
377
|
const utmMedium = String(b.um || b.utm_medium || '').slice(0, 60).trim() || null;
|
|
369
378
|
const utmCampaign = String(b.uc || b.utm_campaign || '').slice(0, 80).trim() || null;
|
|
370
|
-
const tz = String(b.tz || '').slice(0, 60) || null;
|
|
371
|
-
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);
|
|
372
381
|
const name = String(b.n || b.name || '').slice(0, 80).trim() || null; // custom event / goal
|
|
373
382
|
const props = name ? sanitizeProps(b.pr) : null; // properties only attach to named events
|
|
374
383
|
let country = countryFromTz(tz);
|
|
@@ -492,6 +501,7 @@ app.get('/api/v1', (_req, res) => res.json({
|
|
|
492
501
|
'GET /api/v1/stats?site=&period=': 'aggregated stats (period: today|7d|30d|90d|12mo, or from=YYYY-MM-DD&to=YYYY-MM-DD)',
|
|
493
502
|
'GET /api/v1/realtime?site=': 'online count, last-30-min pulse + recent events',
|
|
494
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)',
|
|
495
505
|
},
|
|
496
506
|
filters: 'add any of country,source,page,browser,os,device,lang,medium,campaign,goal to /stats to segment',
|
|
497
507
|
}));
|
|
@@ -507,6 +517,61 @@ app.get('/api/v1/usage', requireRole('owner'), (req, res) => {
|
|
|
507
517
|
});
|
|
508
518
|
app.post('/api/v1/sites', adminOnly, sitesCreateHandler);
|
|
509
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
|
+
|
|
510
575
|
// ── stats ────────────────────────────────────────────────────────────────────
|
|
511
576
|
const TZ = require('./tz');
|
|
512
577
|
const PERIODS = { today: 1, '7d': 7, '30d': 30, '90d': 90, '12mo': 365 };
|