tacilytics 2.3.0 → 2.4.1
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 +72 -6
- package/auth.js +36 -10
- package/package.json +1 -1
- package/public/account.js +19 -5
- package/public/i18n.js +3 -0
- package/public/index.html +2 -2
- package/public/tacilytics.js +27 -6
- package/server.js +73 -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,71 @@ 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 "server mode (count only)" key (Settings → API keys)
|
|
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. It reports only page loads that ended with `200` (no 404s, no redirects) and only for clients that asked for HTML:
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
app.use((req, res, next) => {
|
|
151
|
+
if (req.method !== 'GET' || req.accepts(['html', 'json']) !== 'html') return next();
|
|
152
|
+
res.on('finish', () => {
|
|
153
|
+
if (res.statusCode !== 200) return;
|
|
154
|
+
fetch('https://analytics.example.com/api/v1/hit', {
|
|
155
|
+
method: 'POST', headers: { Authorization: 'Bearer ' + process.env.TACILYTICS_KEY, 'Content-Type': 'application/json' },
|
|
156
|
+
body: JSON.stringify({ domain: 'example.com', path: req.originalUrl, ip: req.ip, ua: req.get('user-agent'),
|
|
157
|
+
referrer: req.get('referer'), lang: req.get('accept-language'), purpose: req.get('sec-purpose') || req.get('purpose') }),
|
|
158
|
+
}).then((r) => { if (r.status === 429) console.warn('tacilytics: rate limited'); }).catch(() => {});
|
|
159
|
+
});
|
|
160
|
+
next();
|
|
161
|
+
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Which key goes on the web server.** Web servers are where sites get broken into, so the key there should be able to do as little as possible:
|
|
165
|
+
|
|
166
|
+
- Create a **server mode (count only)** key under Settings → API keys (admin or owner) and tick the site(s) it reports for. It can call `POST /api/v1/hit` for those sites and nothing else (`403` everywhere else, reads included), and it never reaches more than the account that created it.
|
|
167
|
+
- **Never** put the `API_KEY` from `.env` on a web server. It is the owner key for every site on the instance.
|
|
168
|
+
- Don't use a read + write key either. It still works on `/api/v1/hit` (as in 2.4.0), but a stolen one can also create public share links, point alert webhooks at any URL, upload or delete imports, and change notes, funnels and the time zone of its sites.
|
|
169
|
+
- If several sites run on different servers, give each server its own key, so one break-in can be revoked without touching the others.
|
|
170
|
+
- Server-mode keys arrived in 2.4.1. Before downgrading to 2.4.0, delete them: 2.4.0 does not know the scope and would treat them as ordinary read-only keys.
|
|
171
|
+
|
|
172
|
+
- **The visitor IP** is only accepted here, with a server-mode or 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.
|
|
173
|
+
- **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 key can only report for its own sites. The rate limit (6,000 page views a minute) applies per key, so sites on one shared-hosting IP don't block each other; above it the API answers `429` and the hit is not counted.
|
|
174
|
+
- **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.
|
|
175
|
+
- **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 }`.
|
|
176
|
+
- **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.
|
|
177
|
+
|
|
118
178
|
**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
179
|
|
|
120
180
|
## Mobile & desktop apps
|
|
@@ -225,6 +285,7 @@ curl -H "Authorization: Bearer $TACILYTICS_API_KEY" \
|
|
|
225
285
|
| `POST /api/v1/sites` | add a site `{ domain }` |
|
|
226
286
|
| `GET /api/v1/stats?site=&period=` | aggregated stats — `today \| 7d \| 30d \| 90d \| 12mo`, or `from=YYYY-MM-DD&to=YYYY-MM-DD` |
|
|
227
287
|
| `GET /api/v1/realtime?site=` | online count, 30-min pulse + recent events |
|
|
288
|
+
| `POST /api/v1/hit` | server mode: report a page view from your web server (server-mode key), see [Server mode](#server-mode-no-script-at-all) |
|
|
228
289
|
|
|
229
290
|
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
291
|
|
|
@@ -256,8 +317,9 @@ row per pageview. The dashboard aggregates it with plain SQL.
|
|
|
256
317
|
|
|
257
318
|
**The cookieless trade-off, honestly:** because there’s no persistent identifier, Tacilytics
|
|
258
319
|
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
|
-
|
|
320
|
+
devices. You get the 95 % that matters (how many, which pages, from where) without cookies,
|
|
321
|
+
the same approach as Plausible/Fathom. Whether you still need consent depends on your
|
|
322
|
+
jurisdiction and setup; strict mode (`data-strict`) reduces the device reads further.
|
|
261
323
|
|
|
262
324
|
## Light mode & German
|
|
263
325
|
|
|
@@ -320,6 +382,8 @@ The server-wide `API_KEY` from `.env` keeps owner rights for agents and the CLI
|
|
|
320
382
|
- **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
383
|
- **Data minimisation:** set `DATA_RETENTION_DAYS` to auto-purge raw events after your chosen window.
|
|
322
384
|
- **Respects DNT / Global Privacy Control** when you add `data-honor-dnt` to the tag.
|
|
385
|
+
- **Strict mode** (`data-strict`) skips the device reads (no localStorage, time zone, language or window size). See [Strict mode](#strict-mode).
|
|
386
|
+
- **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
387
|
- Engagement time adds **no new identifier** — it’s matched to the visitor’s own daily hash server-side.
|
|
324
388
|
- Custom event properties are capped (scalars only, size-limited) so they can’t become a PII dumping ground.
|
|
325
389
|
- 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 +396,9 @@ The server-wide `API_KEY` from `.env` keeps owner rights for agents and the CLI
|
|
|
332
396
|
|
|
333
397
|
| | Tacilytics | Google Analytics |
|
|
334
398
|
|---|---|---|
|
|
335
|
-
| Cookies
|
|
399
|
+
| Cookies | ❌ none | ✅ yes |
|
|
400
|
+
| Reads from the device | a few (time zone, language, window size, one localStorage flag); none in strict mode | ✅ a lot |
|
|
401
|
+
| Works without any script on the visitor's device | ✅ server mode | ❌ |
|
|
336
402
|
| Stores IPs / personal data | ❌ never | ✅ yes |
|
|
337
403
|
| Self-hosted, you own the data | ✅ | ❌ |
|
|
338
404
|
| Weight | ~2 KB | ~45 KB+ |
|
package/auth.js
CHANGED
|
@@ -180,6 +180,11 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
180
180
|
);
|
|
181
181
|
`);
|
|
182
182
|
|
|
183
|
+
// 2.4.1: key scopes. '' = the old behaviour (read-only or the account's own rights); 'hit' = server mode only,
|
|
184
|
+
// usable for POST /api/v1/hit on the listed sites and nothing else. Existing keys keep scope '' (unchanged).
|
|
185
|
+
for (const [col, decl] of [['scope', "TEXT NOT NULL DEFAULT ''"], ['sites', 'TEXT']]) {
|
|
186
|
+
if (!db.prepare('PRAGMA table_info(api_keys)').all().some((c) => c.name === col)) db.exec(`ALTER TABLE api_keys ADD COLUMN ${col} ${decl}`);
|
|
187
|
+
}
|
|
183
188
|
const q = {
|
|
184
189
|
userCount: db.prepare('SELECT COUNT(*) n FROM users'),
|
|
185
190
|
ownerCount: db.prepare("SELECT COUNT(*) n FROM users WHERE role = 'owner'"),
|
|
@@ -204,13 +209,14 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
204
209
|
// after the response is sent. Handlers can name the action/target via res.locals.audit;
|
|
205
210
|
// otherwise method + route are recorded. New endpoints are covered without remembering to.
|
|
206
211
|
// Not logged: tracking beacons, sign-in steps, WebAuthn option requests (no state change).
|
|
207
|
-
const AUDIT_SKIP = new Set(['/api/collect', '/auth/login', '/auth/login/totp', '/auth/passkey/options', '/auth/passkey/verify', '/auth/logout', '/api/me/passkeys/options', '/api/me/totp/setup']);
|
|
212
|
+
const AUDIT_SKIP = new Set(['/api/collect', '/auth/login', '/auth/login/totp', '/auth/passkey/options', '/auth/passkey/verify', '/auth/logout', '/api/me/passkeys/options', '/api/me/totp/setup', '/api/v1/hit']);
|
|
208
213
|
const AUDIT_DAYS = Math.max(30, parseInt(process.env.AUDIT_RETENTION_DAYS, 10) || 365);
|
|
209
214
|
const insAudit = db.prepare('INSERT INTO audit_log (ts, actor, action, target, ip) VALUES (?, ?, ?, ?, ?)');
|
|
210
215
|
function actorOf(p) {
|
|
211
216
|
if (!p) return null;
|
|
212
217
|
if (p.kind === 'user') return p.user.username;
|
|
213
218
|
if (p.kind === 'userkey') return p.user.username + ' (API key)';
|
|
219
|
+
if (p.kind === 'hitkey') return p.user.username + ' (server-mode key)';
|
|
214
220
|
if (p.kind === 'apikey') return 'API key';
|
|
215
221
|
if (p.kind === 'legacy') return 'passphrase';
|
|
216
222
|
return null;
|
|
@@ -256,8 +262,14 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
256
262
|
const u = k && q.userById.get(k.user_id);
|
|
257
263
|
if (!u) return null;
|
|
258
264
|
if (!k.last_used || Date.now() - k.last_used > 60000) db.prepare('UPDATE api_keys SET last_used = ? WHERE id = ?').run(Date.now(), k.id);
|
|
265
|
+
const userSites = u.sites ? JSON.parse(u.sites) : null;
|
|
266
|
+
if (k.scope === 'hit') {
|
|
267
|
+
// never more than the account itself may see today (an account limited later narrows its old keys too)
|
|
268
|
+
const ks = JSON.parse(k.sites || '[]').filter((id) => userSites == null || userSites.includes(id));
|
|
269
|
+
return { kind: 'hitkey', role: 'viewer', sites: ks, user: u, keyId: k.id };
|
|
270
|
+
}
|
|
259
271
|
const role = k.read_only ? 'viewer' : u.role;
|
|
260
|
-
return { kind: 'userkey', role, sites:
|
|
272
|
+
return { kind: 'userkey', role, sites: userSites, user: u, keyId: k.id };
|
|
261
273
|
}
|
|
262
274
|
return null; // a wrong key is a failed sign-in, not an anonymous request
|
|
263
275
|
}
|
|
@@ -278,6 +290,8 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
278
290
|
function auth(req, res, next) {
|
|
279
291
|
const p = principal(req);
|
|
280
292
|
if (!p) return res.status(401).json({ error: 'Not authenticated' });
|
|
293
|
+
// a server-mode key reports page views and does nothing else — not even read stats
|
|
294
|
+
if (p.kind === 'hitkey' && !(req.method === 'POST' && req.path === '/api/v1/hit')) return res.status(403).json({ error: 'This key can only report page views (POST /api/v1/hit)' });
|
|
281
295
|
req.principal = p;
|
|
282
296
|
next();
|
|
283
297
|
}
|
|
@@ -498,7 +512,7 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
498
512
|
res.json(out);
|
|
499
513
|
});
|
|
500
514
|
const userOnly = (req, res, next) => {
|
|
501
|
-
if (req.principal.kind === 'userkey') return res.status(403).json({ error: 'API keys cannot change account settings — sign in' });
|
|
515
|
+
if (req.principal.kind === 'userkey' || req.principal.kind === 'hitkey') return res.status(403).json({ error: 'API keys cannot change account settings — sign in' });
|
|
502
516
|
return req.principal.user ? next() : res.status(400).json({ error: 'Create an account first' });
|
|
503
517
|
};
|
|
504
518
|
async function needPassword(req, res) {
|
|
@@ -599,7 +613,7 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
599
613
|
});
|
|
600
614
|
|
|
601
615
|
app.get('/api/audit', requireRole('owner'), (req, res) => {
|
|
602
|
-
if (req.principal.kind === 'userkey') return res.status(403).json({ error: 'API keys cannot read the audit log — sign in' });
|
|
616
|
+
if (req.principal.kind === 'userkey' || req.principal.kind === 'hitkey') return res.status(403).json({ error: 'API keys cannot read the audit log — sign in' });
|
|
603
617
|
const before = Number(req.query.before) || Date.now() + 1;
|
|
604
618
|
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100));
|
|
605
619
|
res.json(db.prepare('SELECT id, ts, actor, action, target, ip FROM audit_log WHERE ts < ? ORDER BY ts DESC, id DESC LIMIT ?').all(before, limit));
|
|
@@ -607,21 +621,33 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
607
621
|
|
|
608
622
|
// ── personal API keys ──
|
|
609
623
|
app.get('/api/me/keys', auth, userOnly, (req, res) => {
|
|
610
|
-
res.json(db.prepare('SELECT id, prefix, name, read_only, created, last_used FROM api_keys WHERE user_id = ? ORDER BY created').all(req.principal.user.id)
|
|
611
|
-
.map((k) => ({ ...k, read_only: !!k.read_only })));
|
|
624
|
+
res.json(db.prepare('SELECT id, prefix, name, read_only, scope, sites, created, last_used FROM api_keys WHERE user_id = ? ORDER BY created').all(req.principal.user.id)
|
|
625
|
+
.map((k) => ({ ...k, read_only: !!k.read_only, scope: k.scope || '', sites: k.sites ? JSON.parse(k.sites) : null })));
|
|
612
626
|
});
|
|
613
627
|
app.post('/api/me/keys', auth, userOnly, (req, res) => {
|
|
614
628
|
const b = req.body || {};
|
|
615
629
|
const n = db.prepare('SELECT COUNT(*) n FROM api_keys WHERE user_id = ?').get(req.principal.user.id).n;
|
|
616
630
|
if (n >= 20) return res.status(400).json({ error: 'At most 20 keys per account' });
|
|
617
|
-
// a viewer can only ever get a read-only key
|
|
618
|
-
const readOnly = req.principal.role === 'viewer' ? true : b.readOnly !== false;
|
|
619
631
|
const key = 'tk_' + crypto.randomBytes(24).toString('base64url');
|
|
620
632
|
const name = String(b.name || '').trim().slice(0, 60) || 'API key';
|
|
633
|
+
if (b.scope === 'hit') {
|
|
634
|
+
// server mode: bound to explicit sites, never more than the account may see. Admin or owner only: reporting
|
|
635
|
+
// page views is writing data (it can add visitors), and a viewer may not write — same rule as /api/v1/hit in 2.4.0.
|
|
636
|
+
if (req.principal.role === 'viewer') return res.status(403).json({ error: 'Viewers cannot create server-mode keys' });
|
|
637
|
+
if (!Array.isArray(b.sites) || !b.sites.length) return res.status(400).json({ error: 'Pick the site(s) this server reports for' });
|
|
638
|
+
const ids = [...new Set(b.sites.map(Number))];
|
|
639
|
+
if (ids.some((id) => !allSiteIds().includes(id) || !siteAllowed(req, id))) return res.status(403).json({ error: 'You have no access to one of these sites' });
|
|
640
|
+
const info = db.prepare("INSERT INTO api_keys (key_hash, prefix, user_id, name, read_only, scope, sites, created) VALUES (?, ?, ?, ?, 1, 'hit', ?, ?)")
|
|
641
|
+
.run(sha256(key), key.slice(0, 10), req.principal.user.id, name, JSON.stringify(ids), Date.now());
|
|
642
|
+
audit(res, 'me.apikey.add', `${name} (${key.slice(0, 10)}…, server mode: sites ${ids.join(',')})`);
|
|
643
|
+
return res.json({ id: info.lastInsertRowid, key, name, read_only: true, scope: 'hit', sites: ids });
|
|
644
|
+
}
|
|
645
|
+
// a viewer can only ever get a read-only key
|
|
646
|
+
const readOnly = req.principal.role === 'viewer' ? true : b.readOnly !== false;
|
|
621
647
|
const info = db.prepare('INSERT INTO api_keys (key_hash, prefix, user_id, name, read_only, created) VALUES (?, ?, ?, ?, ?, ?)')
|
|
622
648
|
.run(sha256(key), key.slice(0, 10), req.principal.user.id, name, readOnly ? 1 : 0, Date.now());
|
|
623
649
|
audit(res, 'me.apikey.add', `${name} (${key.slice(0, 10)}…, ${readOnly ? 'read-only' : 'read + write'})`);
|
|
624
|
-
res.json({ id: info.lastInsertRowid, key, name, read_only: readOnly }); // the key is shown exactly once
|
|
650
|
+
res.json({ id: info.lastInsertRowid, key, name, read_only: readOnly, scope: '' }); // the key is shown exactly once
|
|
625
651
|
});
|
|
626
652
|
app.delete('/api/me/keys/:id', auth, userOnly, (req, res) => {
|
|
627
653
|
const k = db.prepare('SELECT name, prefix FROM api_keys WHERE id = ? AND user_id = ?').get(Number(req.params.id), req.principal.user.id);
|
|
@@ -633,7 +659,7 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
633
659
|
// ── team (owner) ───────────────────────────────────────────────────────────
|
|
634
660
|
// team management needs a real sign-in (or the server API_KEY): a personal key that leaks must not
|
|
635
661
|
// be able to invite, promote or remove people
|
|
636
|
-
const ownerOnly = (req, res, next) => requireRole('owner')(req, res, () => (req.principal.kind === 'userkey'
|
|
662
|
+
const ownerOnly = (req, res, next) => requireRole('owner')(req, res, () => (req.principal.kind === 'userkey' || req.principal.kind === 'hitkey'
|
|
637
663
|
? res.status(403).json({ error: 'API keys cannot manage the team — sign in' }) : next()));
|
|
638
664
|
|
|
639
665
|
// first account: only from a legacy (WEB_PASSWORD) or API-key session, only while there are none
|
package/package.json
CHANGED
package/public/account.js
CHANGED
|
@@ -277,11 +277,13 @@
|
|
|
277
277
|
'<p class="muted">Every Monday morning, for each of your sites: key figures, a day-by-day chart, highlights, top pages, sources, countries, devices, goals, shop numbers and your notes — compared with the week before.</p>' +
|
|
278
278
|
'<div id="repBox"><p class="muted small">Loading…</p></div></div>' +
|
|
279
279
|
'<div class="set-card"><h3>' + ico('i-code') + 'API keys</h3>' +
|
|
280
|
-
'<p class="muted">For scripts, agents and the CLI (<code>TACILYTICS_API_KEY</code>). A key acts as you, sees only your sites, and cannot touch the team or your security settings
|
|
280
|
+
'<p class="muted">For scripts, agents and the CLI (<code>TACILYTICS_API_KEY</code>). A key acts as you, sees only your sites, and cannot touch the team or your security settings.</p>' +
|
|
281
|
+
'<p class="muted small">For server mode, create a “server mode (count only)” key: it can only report page views for the sites you tick, nothing else. Never put a read + write key on a web server.</p>' +
|
|
281
282
|
'<div id="keyList"><p class="muted small">Loading…</p></div>' +
|
|
282
283
|
'<div class="set-inline"><input id="keyName" type="text" placeholder="Name, e.g. reporting script" maxlength="60">' +
|
|
283
|
-
|
|
284
|
-
'<button class="btn" id="keyAdd">Create key</button></div
|
|
284
|
+
'<select id="keyKind" style="flex:0 0 auto;width:auto"><option value="ro">read-only</option>' + (me.role !== 'viewer' ? '<option value="rw">read + write</option><option value="hit">server mode (count only)</option>' : '') + '</select>' +
|
|
285
|
+
'<button class="btn" id="keyAdd">Create key</button></div>' +
|
|
286
|
+
'<div id="keySites" class="hide small muted" style="margin-top:8px"></div><div id="keyOut"></div></div>' +
|
|
285
287
|
'<div class="set-card"><h3>' + ico('i-shield') + 'Password</h3>' +
|
|
286
288
|
'<div class="set-inline"><input id="pwOld" type="password" placeholder="Current password" autocomplete="current-password"><input id="pwNew" type="password" placeholder="New password (10+)" autocomplete="new-password"><button class="btn ghost" id="pwSave">Change</button></div>' +
|
|
287
289
|
'<p class="muted small">Changing it signs you out on all other devices.</p></div>' +
|
|
@@ -343,7 +345,7 @@
|
|
|
343
345
|
function loadKeys() {
|
|
344
346
|
call('GET', '/api/me/keys').then(function (keys) {
|
|
345
347
|
$('keyList').innerHTML = keys.length ? keys.map(function (k) {
|
|
346
|
-
return '<div class="set-row"><span>' + ico('i-code') + esc(k.name) + ' <code class="muted small">' + esc(k.prefix) + '…</code>' + (k.read_only ? ' <span class="pill">read-only</span>' : ' <span class="pill">read + write</span>') + '</span>' +
|
|
348
|
+
return '<div class="set-row"><span>' + ico('i-code') + esc(k.name) + ' <code class="muted small">' + esc(k.prefix) + '…</code>' + (k.scope === 'hit' ? ' <span class="pill"><span>server mode</span> · <span data-no-i18n>' + esc(siteNames(k.sites)) + '</span></span>' : k.read_only ? ' <span class="pill">read-only</span>' : ' <span class="pill">read + write</span>') + '</span>' +
|
|
347
349
|
'<span class="muted small">' + (k.last_used ? 'used ' + date(k.last_used) : 'never used') + '</span><button class="btn ghost danger mini" data-delkey="' + k.id + '">Revoke</button></div>';
|
|
348
350
|
}).join('') : '<p class="muted small">No keys yet.</p>';
|
|
349
351
|
$('keyList').querySelectorAll('[data-delkey]').forEach(function (b) {
|
|
@@ -352,9 +354,21 @@
|
|
|
352
354
|
}).catch(function (e) { setErr(e.message); });
|
|
353
355
|
}
|
|
354
356
|
loadKeys();
|
|
357
|
+
var siteList = (window.Taci && window.Taci.sites && window.Taci.sites()) || [];
|
|
358
|
+
$('keyKind').onchange = function () {
|
|
359
|
+
var hit = this.value === 'hit';
|
|
360
|
+
$('keySites').classList.toggle('hide', !hit);
|
|
361
|
+
if (hit && !$('keySites').innerHTML) $('keySites').innerHTML = 'Reports for: ' + siteList.map(function (st) {
|
|
362
|
+
return '<label style="display:inline-flex;gap:4px;align-items:center;margin-right:10px"><input type="checkbox" class="keySite" value="' + st.id + '" style="width:auto;margin:0"' + (siteList.length === 1 ? ' checked' : '') + '>' + esc(st.domain) + '</label>';
|
|
363
|
+
}).join('');
|
|
364
|
+
};
|
|
355
365
|
$('keyAdd').onclick = function () {
|
|
356
366
|
setErr('');
|
|
357
|
-
|
|
367
|
+
var kind = $('keyKind').value;
|
|
368
|
+
var body = { name: $('keyName').value.trim() };
|
|
369
|
+
if (kind === 'hit') { body.scope = 'hit'; body.sites = Array.prototype.map.call(document.querySelectorAll('.keySite:checked'), function (c) { return Number(c.value); }); }
|
|
370
|
+
else body.readOnly = kind !== 'rw';
|
|
371
|
+
call('POST', '/api/me/keys', body).then(function (k) {
|
|
358
372
|
$('keyName').value = '';
|
|
359
373
|
$('keyOut').innerHTML = '<p class="muted small" style="margin:10px 0 4px">Copy it now — it is shown only once.</p><div class="snippet">' + esc(k.key) + '</div><button class="btn ghost mini" id="keyCopy">Copy key</button>';
|
|
360
374
|
$('keyCopy').onclick = function () { navigator.clipboard.writeText(k.key); this.textContent = 'Copied ✓'; };
|
package/public/i18n.js
CHANGED
|
@@ -141,6 +141,9 @@
|
|
|
141
141
|
'Direct / none': 'Direkt / keine', 'Android app': 'Android-App', 'iOS app': 'iOS-App', 'Web': 'Web',
|
|
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
|
+
'). A key acts as you, sees only your sites, and cannot touch the team or your security settings.': '). Ein Key handelt als du, sieht nur deine Seiten und kann weder das Team noch deine Sicherheitseinstellungen ändern.',
|
|
145
|
+
'For server mode, create a “server mode (count only)” key: it can only report page views for the sites you tick, nothing else. Never put a read + write key on a web server.': 'Für den Server-Modus legst du einen Key „Server-Modus (nur zählen)“ an: Er kann nur Seitenaufrufe für die angehakten Seiten melden, sonst nichts. Leg nie einen Key mit Schreibrechten auf einen Webserver.',
|
|
146
|
+
'server mode (count only)': 'Server-Modus (nur zählen)', 'server mode': 'Server-Modus', 'Reports for:': 'Meldet für:',
|
|
144
147
|
'Signed in as': 'Angemeldet als',
|
|
145
148
|
'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
149
|
'In Plausible:': 'In Plausible:', 'Site settings → Imports & Exports → Export data': 'Website-Einstellungen → Imports & Exports → Export data',
|
package/public/index.html
CHANGED
|
@@ -389,8 +389,8 @@
|
|
|
389
389
|
</div>
|
|
390
390
|
|
|
391
391
|
<div class="tip" id="tip"></div>
|
|
392
|
-
<script src="/i18n.js?v=2.2.
|
|
393
|
-
<script src="/account.js?v=2.5.
|
|
392
|
+
<script src="/i18n.js?v=2.2.5"></script>
|
|
393
|
+
<script src="/account.js?v=2.5.9"></script>
|
|
394
394
|
<script src="/app.js?v=2.7.2"></script>
|
|
395
395
|
</body>
|
|
396
396
|
</html>
|
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);
|
|
@@ -393,6 +402,7 @@ const { auth, requireRole, siteAllowed, audit, grantSite, isLimited } = setupAut
|
|
|
393
402
|
allSiteIds: () => db.prepare('SELECT id FROM sites').all().map((r) => r.id),
|
|
394
403
|
});
|
|
395
404
|
const adminOnly = requireRole('admin');
|
|
405
|
+
const ROLE_RANK = { viewer: 1, admin: 2, owner: 3 };
|
|
396
406
|
|
|
397
407
|
// share-token auth: read-only stats access scoped to exactly one site.
|
|
398
408
|
// Accepted as ?share=<token>; the handler must check siteOk(req, site).
|
|
@@ -492,6 +502,7 @@ app.get('/api/v1', (_req, res) => res.json({
|
|
|
492
502
|
'GET /api/v1/stats?site=&period=': 'aggregated stats (period: today|7d|30d|90d|12mo, or from=YYYY-MM-DD&to=YYYY-MM-DD)',
|
|
493
503
|
'GET /api/v1/realtime?site=': 'online count, last-30-min pulse + recent events',
|
|
494
504
|
'GET /api/v1/usage?month=YYYY-MM': 'page views per site in a calendar month (UTC), owner / server key only',
|
|
505
|
+
'POST /api/v1/hit': 'server mode: report a page view from your web server { domain, path, ip, ua, referrer?, lang?, purpose?, name? } (server-mode key)',
|
|
495
506
|
},
|
|
496
507
|
filters: 'add any of country,source,page,browser,os,device,lang,medium,campaign,goal to /stats to segment',
|
|
497
508
|
}));
|
|
@@ -507,6 +518,65 @@ app.get('/api/v1/usage', requireRole('owner'), (req, res) => {
|
|
|
507
518
|
});
|
|
508
519
|
app.post('/api/v1/sites', adminOnly, sitesCreateHandler);
|
|
509
520
|
|
|
521
|
+
// ── server mode (2.4): the website's own server reports a page view, no script on the visitor's device ──
|
|
522
|
+
// The site's backend forwards what the visitor's request carried anyway: path, Referer, Accept-Language,
|
|
523
|
+
// User-Agent and the visitor IP. The IP is only taken from an authenticated write key (never from the
|
|
524
|
+
// public /api/collect), otherwise anyone could feed arbitrary IPs into the visitor hash. It is hashed
|
|
525
|
+
// exactly like a tracker hit and never stored.
|
|
526
|
+
const net = require('net');
|
|
527
|
+
const PREFETCH_RE = /prefetch|prerender/i;
|
|
528
|
+
// Who may report: a server-mode key (scope 'hit', recommended, bound to its sites and good for nothing else), or —
|
|
529
|
+
// as in 2.4.0 — a key / sign-in with write access (admin role). A read-only key is a viewer and gets 403.
|
|
530
|
+
app.post('/api/v1/hit', auth, (req, res) => {
|
|
531
|
+
const p = req.principal;
|
|
532
|
+
if (p.kind !== 'hitkey' && ROLE_RANK[p.role] < ROLE_RANK.admin) return res.status(403).json({ error: 'Use a server-mode key (Settings → API keys)' });
|
|
533
|
+
// per key, not per web-server IP: many sites behind one shared-hosting IP must not starve each other
|
|
534
|
+
if (rateLimited('h:' + (p.keyId != null ? 'k' + p.keyId : p.kind + ':' + (p.user ? p.user.id : '')), 6000, 60000)) return res.status(429).json({ error: 'Too many hits' });
|
|
535
|
+
const b = req.body || {};
|
|
536
|
+
const domain = norm(b.domain || b.d);
|
|
537
|
+
const site = domain && getSiteByDomain.get(domain);
|
|
538
|
+
if (!site || !siteAllowed(req, site.id)) return res.status(404).json({ error: 'Unknown site' });
|
|
539
|
+
if (site.paused) return res.status(202).end();
|
|
540
|
+
const ip = String(b.ip || '').trim();
|
|
541
|
+
// without the visitor IP every visitor would fall onto one hash: refuse instead of counting wrong
|
|
542
|
+
if (!net.isIP(ip)) return res.status(400).json({ error: 'ip (the visitor IP) is required for server mode' });
|
|
543
|
+
// prefetch / prerender requests are not visits (Sec-Purpose / Purpose header of the visitor request)
|
|
544
|
+
if (PREFETCH_RE.test(String(b.purpose || ''))) return res.status(204).end();
|
|
545
|
+
const rawUa = String(b.ua || '').slice(0, 512);
|
|
546
|
+
const ua = parseUA(rawUa);
|
|
547
|
+
if (!ua) return res.status(204).end(); // bot, crawler, monitor or no user agent
|
|
548
|
+
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)
|
|
549
|
+
|
|
550
|
+
const ts = Date.now();
|
|
551
|
+
const day = dayStr(ts);
|
|
552
|
+
let url;
|
|
553
|
+
try { url = new URL(String(b.path || b.p || '/').slice(0, 1024), 'http://x'); } catch { return res.status(400).json({ error: 'invalid path' }); }
|
|
554
|
+
let pth = url.pathname.slice(0, 512);
|
|
555
|
+
try { pth = decodeURI(pth); } catch {}
|
|
556
|
+
if (pth.length > 1) pth = pth.replace(/\/+$/, '') || '/';
|
|
557
|
+
const vh = visitorHash(site.id, ip, rawUa, day);
|
|
558
|
+
const intern = INTERNAL_PATHS[domain] || [];
|
|
559
|
+
if (intern.some((pre) => pth.startsWith(pre))) { insInternal.run(site.id, vh, day); return res.status(204).end(); }
|
|
560
|
+
if (isInternal.get(site.id, vh, day)) return res.status(204).end();
|
|
561
|
+
|
|
562
|
+
const referrer = String(b.referrer || b.r || '').slice(0, 512);
|
|
563
|
+
const q = url.searchParams;
|
|
564
|
+
let source = 'Direct';
|
|
565
|
+
const utm = String(q.get('utm_source') || q.get('ref') || '').slice(0, 60).trim();
|
|
566
|
+
if (utm) source = utm;
|
|
567
|
+
else if (referrer) { try { const h = norm(new URL(referrer).hostname); if (h && h !== domain) source = h; } catch {} }
|
|
568
|
+
const name = String(b.name || b.n || '').slice(0, 80).trim() || null; // optional server-side goal (e.g. Purchase)
|
|
569
|
+
insEvent.run({
|
|
570
|
+
site_id: site.id, ts, day, path: pth, source, referrer: referrer || null,
|
|
571
|
+
country: null, tz: null, browser: ua.browser, os: ua.os, device: ua.device,
|
|
572
|
+
lang: acceptLang(b.lang), name,
|
|
573
|
+
utm_medium: String(q.get('utm_medium') || '').slice(0, 60).trim() || null,
|
|
574
|
+
utm_campaign: String(q.get('utm_campaign') || '').slice(0, 80).trim() || null,
|
|
575
|
+
dur: null, props: name ? sanitizeProps(b.props) : null, vh, platform: null, app_version: null,
|
|
576
|
+
});
|
|
577
|
+
res.status(204).end();
|
|
578
|
+
});
|
|
579
|
+
|
|
510
580
|
// ── stats ────────────────────────────────────────────────────────────────────
|
|
511
581
|
const TZ = require('./tz');
|
|
512
582
|
const PERIODS = { today: 1, '7d': 7, '30d': 30, '90d': 90, '12mo': 365 };
|