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 +22 -10
- 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/server.js +9 -4
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
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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 (
|
|
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:
|
|
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/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? } (
|
|
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
|
-
//
|
|
528
|
-
|
|
529
|
-
|
|
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);
|