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 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 consent banner, no creepy tracking. (And in Italian, *taci* simply means "be quiet".)
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 & banner-free.** No cookies, no `localStorage`, no fingerprinting — nothing to consent to.
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 — no cookie banner required.
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) with 0 %
260
- cookie banner — the same approach as Plausible/Fathom.
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 / consent banner | ❌ none | ✅ required |
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: u.sites ? JSON.parse(u.sites) : null, user: u, keyId: k.id };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tacilytics",
3
- "version": "2.3.0",
3
+ "version": "2.4.1",
4
4
  "description": "Privacy-first, cookieless analytics for websites and apps. Self-hosted: one Node process, one SQLite file.",
5
5
  "main": "server.js",
6
6
  "type": "commonjs",
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. Read-only unless you untick it.</p>' +
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
- (me.role !== 'viewer' ? '<label class="small muted" style="display:inline-flex;gap:6px;align-items:center;flex:0 0 auto"><input type="checkbox" id="keyRo" checked style="width:auto;margin:0"> read-only</label>' : '') +
284
- '<button class="btn" id="keyAdd">Create key</button></div><div id="keyOut"></div></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
- call('POST', '/api/me/keys', { name: $('keyName').value.trim(), readOnly: $('keyRo') ? $('keyRo').checked : true }).then(function (k) {
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.3"></script>
393
- <script src="/account.js?v=2.5.6"></script>
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>
@@ -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
- if (s.hasAttribute('data-honor-dnt')) {
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
- try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) {}
44
- var lang = (navigator.language || '').slice(0, 12);
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) post({ d: domain, p: engPath, e: 'engagement', dur: secs, sc: maxScroll });
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 };