tacilytics 2.4.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
@@ -136,7 +136,7 @@ Server mode runs no script and reads or stores nothing on the visitor's device.
136
136
 
137
137
  ```http
138
138
  POST /api/v1/hit
139
- Authorization: Bearer tk_… # a key created with write access (Settings → API keys), not read-only
139
+ Authorization: Bearer tk_… # a "server mode (count only)" key (Settings → API keys)
140
140
  Content-Type: application/json
141
141
 
142
142
  { "domain": "example.com", "path": "/shop?utm_source=news", "ip": "203.0.113.7",
@@ -144,21 +144,33 @@ Content-Type: application/json
144
144
  "purpose": "<Sec-Purpose or Purpose header>" }
145
145
  ```
146
146
 
147
- Node / Express, after your static files and before your pages:
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
148
 
149
149
  ```js
150
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(() => {});
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
+ });
156
160
  next();
157
161
  });
158
162
  ```
159
163
 
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.
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.
162
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.
163
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 }`.
164
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.
@@ -273,7 +285,7 @@ curl -H "Authorization: Bearer $TACILYTICS_API_KEY" \
273
285
  | `POST /api/v1/sites` | add a site `{ domain }` |
274
286
  | `GET /api/v1/stats?site=&period=` | aggregated stats — `today \| 7d \| 30d \| 90d \| 12mo`, or `from=YYYY-MM-DD&to=YYYY-MM-DD` |
275
287
  | `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) |
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) |
277
289
 
278
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`.
279
291
 
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.4.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>
package/server.js CHANGED
@@ -402,6 +402,7 @@ const { auth, requireRole, siteAllowed, audit, grantSite, isLimited } = setupAut
402
402
  allSiteIds: () => db.prepare('SELECT id FROM sites').all().map((r) => r.id),
403
403
  });
404
404
  const adminOnly = requireRole('admin');
405
+ const ROLE_RANK = { viewer: 1, admin: 2, owner: 3 };
405
406
 
406
407
  // share-token auth: read-only stats access scoped to exactly one site.
407
408
  // Accepted as ?share=<token>; the handler must check siteOk(req, site).
@@ -501,7 +502,7 @@ app.get('/api/v1', (_req, res) => res.json({
501
502
  'GET /api/v1/stats?site=&period=': 'aggregated stats (period: today|7d|30d|90d|12mo, or from=YYYY-MM-DD&to=YYYY-MM-DD)',
502
503
  'GET /api/v1/realtime?site=': 'online count, last-30-min pulse + recent events',
503
504
  '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)',
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)',
505
506
  },
506
507
  filters: 'add any of country,source,page,browser,os,device,lang,medium,campaign,goal to /stats to segment',
507
508
  }));
@@ -524,9 +525,13 @@ app.post('/api/v1/sites', adminOnly, sitesCreateHandler);
524
525
  // exactly like a tracker hit and never stored.
525
526
  const net = require('net');
526
527
  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' });
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' });
530
535
  const b = req.body || {};
531
536
  const domain = norm(b.domain || b.d);
532
537
  const site = domain && getSiteByDomain.get(domain);