tacilytics 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +5 -0
- package/README.md +66 -2
- package/auth.js +188 -8
- package/importers.js +223 -0
- package/package.json +6 -1
- package/public/account.js +245 -10
- package/public/app.js +216 -25
- package/public/assets/icons.svg +1 -0
- package/public/i18n.js +242 -0
- package/public/index.html +120 -6
- package/public/style.css +101 -0
- package/public/tacilytics.js +41 -1
- package/public/theme.js +14 -0
- package/report.js +300 -0
- package/server.js +540 -68
- package/tz.js +96 -0
- package/webhook.js +109 -0
package/.env.example
CHANGED
|
@@ -27,3 +27,8 @@ API_KEY=change-me
|
|
|
27
27
|
|
|
28
28
|
# Data minimisation: auto-purge raw events older than N days (0 = keep forever)
|
|
29
29
|
DATA_RETENTION_DAYS=0
|
|
30
|
+
|
|
31
|
+
# Weekly report by e-mail (optional). smtps://…:465 or smtp://…:587 (STARTTLS)
|
|
32
|
+
# SMTP_URL=smtps://user:password@mail.example.com:465
|
|
33
|
+
# REPORT_FROM=Tacilytics <reports@example.com>
|
|
34
|
+
# REPORT_HOUR_UTC=6
|
package/README.md
CHANGED
|
@@ -111,9 +111,12 @@ Opt-in flags on the `<script>` tag:
|
|
|
111
111
|
<script defer data-domain="example.com"
|
|
112
112
|
data-honor-dnt <!-- skip tracking if DNT / Global Privacy Control is on -->
|
|
113
113
|
data-track-outbound <!-- auto-record clicks to other domains as events -->
|
|
114
|
+
data-track-downloads <!-- auto-record file downloads (pdf, zip, docx …) as "File Download"; or ="pdf,epub" -->
|
|
114
115
|
src="https://analytics.example.com/tacilytics.js"></script>
|
|
115
116
|
```
|
|
116
117
|
|
|
118
|
+
**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
|
+
|
|
117
120
|
## Mobile & desktop apps
|
|
118
121
|
|
|
119
122
|
Track your **native app in the same dashboard as your website**. Every row gets a platform
|
|
@@ -152,6 +155,52 @@ The server hashes the launch id with a random daily salt that is deleted when th
|
|
|
152
155
|
Without `sid`, the usual IP + user-agent daily hash is used. Region comes from the timezone or
|
|
153
156
|
device locale, never from IP geolocation.
|
|
154
157
|
|
|
158
|
+
## Import from Google Analytics or Plausible
|
|
159
|
+
|
|
160
|
+
**Import** (toolbar) brings your history along, so switching doesn't mean starting at zero.
|
|
161
|
+
|
|
162
|
+
- **Plausible:** Site settings → Imports & Exports → *Export data*. Upload the ZIP (or the CSVs in it).
|
|
163
|
+
- **Google Analytics 4:** open a report (*Pages and screens*, *Traffic acquisition*, *Demographic details*, *Tech*), add **Date** as a secondary dimension, *Share → Download file → CSV*. One file per report; English or German column names.
|
|
164
|
+
|
|
165
|
+
Imported numbers are daily totals per dimension (pages, sources, countries, browsers, OS, devices, events) and count **only for days before your own data starts** — never twice. They appear in the chart, the totals and the panels; while a filter is active they are left out (they can't be combined), and the dashboard says so. Every import can be removed again. No visitor-level data is imported or stored.
|
|
166
|
+
|
|
167
|
+
## Notes in the chart
|
|
168
|
+
|
|
169
|
+
Click **Notes** in the chart legend to mark a day — a newsletter, a release, a sale. A coral line and a diamond appear on that day, the note shows in the tooltip, and everyone with access to the site (including share links) sees it. Admins and owners add and remove notes. `GET /api/annotations?site=…` lists them; `/stats` returns the ones in its range as `annotations`.
|
|
170
|
+
|
|
171
|
+
## Time zone per site
|
|
172
|
+
|
|
173
|
+
Every site has its own time zone (any IANA name, e.g. `Europe/Berlin`; the browser's zone is preselected when you add a site, existing sites stay on `UTC`). Click the clock next to the site picker to change it. Days, the hourly view, date ranges, notes, the weekly report, the daily digest and the alert baseline all follow it, with daylight-saving days as 23 or 25 hours and offsets like +5:45 kept exact. Stored data is not touched, so switching back and forth is safe.
|
|
174
|
+
|
|
175
|
+
One limit: the anonymous visitor ID rotates at midnight **UTC** (that is what keeps it unlinkable across days). Outside UTC a local day therefore spans two IDs, and *unique visitors per day* are an approximation — pageviews, goals and revenue are exact.
|
|
176
|
+
|
|
177
|
+
`PATCH /api/sites/:id {"timezone":"Europe/Berlin"}` (admin) changes it via the API; unknown names are rejected with 400.
|
|
178
|
+
|
|
179
|
+
## Weekly report by e-mail
|
|
180
|
+
|
|
181
|
+
Settings → Security → **Weekly report**: enter an address and tick *every Monday*. Each person gets one mail per week for **the sites they can see** — visitors and pageviews vs. the week before, top pages, sources, goals and a link to the dashboard. *Send one now* sends a sample.
|
|
182
|
+
|
|
183
|
+
Needs mail settings in `.env`:
|
|
184
|
+
|
|
185
|
+
```env
|
|
186
|
+
SMTP_URL=smtps://user:password@mail.example.com:465 # or smtp://…:587 (STARTTLS)
|
|
187
|
+
REPORT_FROM=Tacilytics <reports@example.com>
|
|
188
|
+
REPORT_HOUR_UTC=6 # Monday, this hour (UTC)
|
|
189
|
+
TACILYTICS_ORIGIN=https://analytics.example.com # for the dashboard link in the mail
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Alerts
|
|
193
|
+
|
|
194
|
+
**Alerts** (toolbar) sends a message to a webhook — Discord, Slack, or anything that takes JSON — when:
|
|
195
|
+
|
|
196
|
+
- **Traffic spike:** about 3× the usual for this hour of the day,
|
|
197
|
+
- **Traffic drop:** under 20 % of usual (often: the tracker fell off the site),
|
|
198
|
+
- **Goal reached:** e.g. 10× `Purchase` within 1 hour, 24 hours or 7 days.
|
|
199
|
+
|
|
200
|
+
"Usual" is the median of the same hour over the last 14 days, so quiet nights don't look like incidents. Checked every 10 minutes, with a cooldown so one long spike is one message. **Test** sends a sample message. Admins and owners manage alerts.
|
|
201
|
+
|
|
202
|
+
Webhooks entered in the dashboard must be public `https://` addresses — no localhost, private ranges or redirects, so nobody on the team can use alerts to reach internal services. `DIGEST_WEBHOOK_URL` from `.env` is the default target (and may be internal); sites without own alerts keep the default spike/drop alerts there.
|
|
203
|
+
|
|
155
204
|
## Live visitors
|
|
156
205
|
|
|
157
206
|
Click **“● online now”** for a realtime view: a 30-minute pulse and a live feed of
|
|
@@ -210,6 +259,10 @@ counts unique visitors *per day* and can’t follow the same person across days
|
|
|
210
259
|
devices. You get the 95 % that matters (how many, which pages, from where) with 0 %
|
|
211
260
|
cookie banner — the same approach as Plausible/Fathom.
|
|
212
261
|
|
|
262
|
+
## Light mode & German
|
|
263
|
+
|
|
264
|
+
Settings → Security → **Appearance**: *Theme* (System / Light / Dark) and *Language* (Automatic / English / Deutsch), saved per browser. *System* follows the device's light/dark setting live; *Automatic* picks German when the browser is German. Your data (paths, sources, event names) is never translated.
|
|
265
|
+
|
|
213
266
|
## Team, 2FA & passkeys
|
|
214
267
|
|
|
215
268
|
Out of the box the dashboard opens with one shared passphrase (`WEB_PASSWORD`). When you want more:
|
|
@@ -228,9 +281,15 @@ Everyone can turn on, **optionally**:
|
|
|
228
281
|
- **Passkeys** (Face ID, Touch ID, Windows Hello, security keys). A passkey replaces password *and* code and cannot be phished.
|
|
229
282
|
- **One-time codes (TOTP)** from any authenticator app, with 10 single-use **recovery codes**. Codes cannot be reused.
|
|
230
283
|
|
|
284
|
+
**Forgot the password?** An owner clicks **Password link** next to the person in Settings → Team: a one-time link (24 h) to set a new one. Their 2FA stays on and all their sessions end.
|
|
285
|
+
|
|
286
|
+
**Activity log:** owners see every change in Settings → Activity — who added a site, invited someone, changed a role, created an API key, turned 2FA off … with time and address. Failed attempts and sign-ins are not logged, secrets never. Kept for a year (`AUDIT_RETENTION_DAYS`, minimum 30).
|
|
287
|
+
|
|
231
288
|
**Locked out?** On the server: `tacilytics users` lists accounts, `tacilytics reset-login <username>` prints a new password, turns 2FA off and ends that person's sessions. An owner can also reset a teammate's 2FA in Settings → Team.
|
|
232
289
|
|
|
233
|
-
|
|
290
|
+
**API keys per person:** Settings → Security → API keys creates a personal key (`tk_…`) for scripts, agents and the CLI. It acts as you and never more: it sees only your sites, is **read-only unless you untick it** (a viewer's key always is), and cannot manage the team or change security settings. Keys are stored as SHA-256, shown once, and can be revoked; removing a member revokes theirs.
|
|
291
|
+
|
|
292
|
+
The server-wide `API_KEY` from `.env` keeps owner rights for agents and the CLI on your own box.
|
|
234
293
|
|
|
235
294
|
## Configuration
|
|
236
295
|
|
|
@@ -239,10 +298,15 @@ The API key (`API_KEY`, Bearer) keeps owner rights for agents and the CLI.
|
|
|
239
298
|
| `PORT` | `3920` | Port (bound to 127.0.0.1) |
|
|
240
299
|
| `WEB_PASSWORD` | `tacilytics` | Dashboard passphrase — **only until the first account exists** |
|
|
241
300
|
| `TACILYTICS_ORIGIN` | request host | Public URL of the dashboard, e.g. `https://analytics.example.com`. Pins the passkey domain; recommended behind a proxy |
|
|
242
|
-
| `TRUST_PROXY` | `1` | Number of reverse proxies in front of Tacilytics (e.g. Cloudflare + Caddy = `2`). Rate limits **and visitor hashes** use the address the outermost trusted proxy saw, never the client-written first `X-Forwarded-For` entry. Too low: with two proxies and `1`, every visitor looks like the Cloudflare address and unique visitors collapse. Too high: clients can pick their own address and bypass the limits. Tacilytics binds to 127.0.0.1, so a proxy is always in front and `0` is almost always wrong (everyone becomes 127.0.0.1) |
|
|
301
|
+
| `TRUST_PROXY` | `1` | Number of HTTP reverse proxies in front of Tacilytics that append `X-Forwarded-For` (e.g. Cloudflare + Caddy = `2`; a plain TCP forwarder like socat or `ssh -L` does not count). Rate limits **and visitor hashes** use the address the outermost trusted proxy saw, never the client-written first `X-Forwarded-For` entry. Too low: with two proxies and `1`, every visitor looks like the Cloudflare address and unique visitors collapse. Too high: clients can pick their own address and bypass the limits. Tacilytics binds to 127.0.0.1, so a proxy is always in front and `0` is almost always wrong (everyone becomes 127.0.0.1). A request without `X-Forwarded-For` logs a one-time warning |
|
|
243
302
|
| `INGEST_SALT` | fixed | Extra server-side secret mixed into the visitor hash (on top of the random daily salt) |
|
|
244
303
|
| `API_KEY` | — | Bearer key for the API / CLI |
|
|
245
304
|
| `DB_PATH` | `./data/analytics.db` | SQLite path |
|
|
305
|
+
| `DIGEST_WEBHOOK_URL` | — | Daily digest + default alert target (Discord/Slack-compatible). `off` disables it |
|
|
306
|
+
| `WATCH_MIN_VISITORS` | `10` | Spike/drop alerts stay silent below this many visitors per hour |
|
|
307
|
+
| `SMTP_URL` / `REPORT_FROM` | — | Mail server + sender for the weekly report |
|
|
308
|
+
| `REPORT_HOUR_UTC` | `6` | Monday hour (UTC) the weekly report goes out; it covers the last 7 full days in each site's time zone |
|
|
309
|
+
| `AUDIT_RETENTION_DAYS` | `365` | How long the team activity log is kept (min. 30) |
|
|
246
310
|
| `DATA_RETENTION_DAYS` | `0` | `0` = keep forever; `>0` purges raw events older than N days, daily (data minimisation) |
|
|
247
311
|
|
|
248
312
|
## Privacy & security
|
package/auth.js
CHANGED
|
@@ -15,10 +15,12 @@
|
|
|
15
15
|
* TOTP secrets are stored as-is (they must be readable to verify codes).
|
|
16
16
|
*/
|
|
17
17
|
const crypto = require('crypto');
|
|
18
|
+
const PKG_VERSION = require('./package.json').version;
|
|
18
19
|
|
|
19
20
|
const ROLES = { viewer: 1, admin: 2, owner: 3 };
|
|
20
21
|
const SESSION_DAYS = 30;
|
|
21
22
|
const INVITE_DAYS = 7;
|
|
23
|
+
const RESET_HOURS = 24;
|
|
22
24
|
const MIN_PW = 10;
|
|
23
25
|
|
|
24
26
|
// ── small crypto helpers ───────────────────────────────────────────────────
|
|
@@ -138,6 +140,33 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
138
140
|
created INTEGER NOT NULL,
|
|
139
141
|
expires INTEGER NOT NULL
|
|
140
142
|
);
|
|
143
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
144
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
145
|
+
ts INTEGER NOT NULL,
|
|
146
|
+
actor TEXT, -- username, 'API key', 'passphrase', or NULL (public: invite / reset link)
|
|
147
|
+
action TEXT NOT NULL, -- e.g. 'site.add', 'team.invite', 'me.totp.enable'
|
|
148
|
+
target TEXT, -- what it was about (domain, username, …) — never a secret
|
|
149
|
+
ip TEXT -- address as seen through the proxy; for "was that me?"
|
|
150
|
+
);
|
|
151
|
+
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts);
|
|
152
|
+
CREATE TABLE IF NOT EXISTS api_keys (
|
|
153
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
154
|
+
key_hash TEXT NOT NULL UNIQUE,
|
|
155
|
+
prefix TEXT NOT NULL, -- first characters, shown in the list so people can tell keys apart
|
|
156
|
+
user_id INTEGER NOT NULL,
|
|
157
|
+
name TEXT,
|
|
158
|
+
read_only INTEGER NOT NULL DEFAULT 1,
|
|
159
|
+
created INTEGER NOT NULL,
|
|
160
|
+
last_used INTEGER
|
|
161
|
+
);
|
|
162
|
+
CREATE TABLE IF NOT EXISTS password_resets (
|
|
163
|
+
token_hash TEXT PRIMARY KEY,
|
|
164
|
+
user_id INTEGER NOT NULL,
|
|
165
|
+
created_by INTEGER,
|
|
166
|
+
created INTEGER NOT NULL,
|
|
167
|
+
expires INTEGER NOT NULL,
|
|
168
|
+
used_at INTEGER
|
|
169
|
+
);
|
|
141
170
|
CREATE TABLE IF NOT EXISTS invites (
|
|
142
171
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
143
172
|
token_hash TEXT NOT NULL UNIQUE,
|
|
@@ -169,6 +198,39 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
169
198
|
pkGet: db.prepare('SELECT * FROM passkeys WHERE id = ?'),
|
|
170
199
|
};
|
|
171
200
|
const hasUsers = () => q.userCount.get().n > 0;
|
|
201
|
+
|
|
202
|
+
// ── audit log ──────────────────────────────────────────────────────────────
|
|
203
|
+
// Every successful state change (POST/PATCH/DELETE answered with 2xx) gets one row, written
|
|
204
|
+
// after the response is sent. Handlers can name the action/target via res.locals.audit;
|
|
205
|
+
// otherwise method + route are recorded. New endpoints are covered without remembering to.
|
|
206
|
+
// 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']);
|
|
208
|
+
const AUDIT_DAYS = Math.max(30, parseInt(process.env.AUDIT_RETENTION_DAYS, 10) || 365);
|
|
209
|
+
const insAudit = db.prepare('INSERT INTO audit_log (ts, actor, action, target, ip) VALUES (?, ?, ?, ?, ?)');
|
|
210
|
+
function actorOf(p) {
|
|
211
|
+
if (!p) return null;
|
|
212
|
+
if (p.kind === 'user') return p.user.username;
|
|
213
|
+
if (p.kind === 'userkey') return p.user.username + ' (API key)';
|
|
214
|
+
if (p.kind === 'apikey') return 'API key';
|
|
215
|
+
if (p.kind === 'legacy') return 'passphrase';
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
app.use((req, res, next) => {
|
|
219
|
+
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS' || AUDIT_SKIP.has(req.path)) return next();
|
|
220
|
+
res.on('finish', () => {
|
|
221
|
+
if (res.statusCode < 200 || res.statusCode >= 300) return;
|
|
222
|
+
try {
|
|
223
|
+
const a = res.locals.audit || {};
|
|
224
|
+
const route = req.route ? req.route.path : req.path;
|
|
225
|
+
const action = a.action || `${req.method} ${route}`;
|
|
226
|
+
const actor = a.actor !== undefined ? a.actor : actorOf(req.principal);
|
|
227
|
+
insAudit.run(Date.now(), actor, action, a.target == null ? null : String(a.target).slice(0, 200), clientIp(req));
|
|
228
|
+
} catch (e) { console.error('audit log:', e.message); }
|
|
229
|
+
});
|
|
230
|
+
next();
|
|
231
|
+
});
|
|
232
|
+
setInterval(() => db.prepare('DELETE FROM audit_log WHERE ts < ?').run(Date.now() - AUDIT_DAYS * 864e5), 6 * 3600e3).unref();
|
|
233
|
+
const audit = (res, action, target, actor) => { res.locals.audit = { action, target, ...(actor !== undefined ? { actor } : {}) }; };
|
|
172
234
|
if (hasUsers() && !process.env.TACILYTICS_ORIGIN) {
|
|
173
235
|
console.warn('⚠️ TACILYTICS_ORIGIN is not set. Passkeys use the request host, so they break if the dashboard is reached under another host/port or behind a proxy without X-Forwarded-Proto. Set it to the public dashboard URL.');
|
|
174
236
|
}
|
|
@@ -183,10 +245,21 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
183
245
|
}
|
|
184
246
|
function principal(req) {
|
|
185
247
|
// agents / CLI: API key = owner over everything
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
248
|
+
const hdr = String(req.headers['authorization'] || '');
|
|
249
|
+
const key = hdr.startsWith('Bearer ') ? hdr.slice(7) : String(req.headers['x-api-key'] || '');
|
|
250
|
+
if (key) {
|
|
251
|
+
// the server-wide API_KEY from .env: owner over everything (agents / CLI on your own box)
|
|
252
|
+
if (API_KEY && safeEq(key, API_KEY)) return { kind: 'apikey', role: 'owner', sites: null, user: null };
|
|
253
|
+
// personal keys (tk_…): act as their owner, never more — and read-only unless created otherwise
|
|
254
|
+
if (key.startsWith('tk_')) {
|
|
255
|
+
const k = db.prepare('SELECT * FROM api_keys WHERE key_hash = ?').get(sha256(key));
|
|
256
|
+
const u = k && q.userById.get(k.user_id);
|
|
257
|
+
if (!u) return null;
|
|
258
|
+
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);
|
|
259
|
+
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 };
|
|
261
|
+
}
|
|
262
|
+
return null; // a wrong key is a failed sign-in, not an anonymous request
|
|
190
263
|
}
|
|
191
264
|
const t = String(req.headers['x-session-token'] || '');
|
|
192
265
|
if (!t) return null;
|
|
@@ -405,13 +478,16 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
405
478
|
const uid = tx();
|
|
406
479
|
if (uid === 'taken') return res.status(409).json({ error: 'This username is taken' });
|
|
407
480
|
if (!uid) return res.status(404).json({ error: 'This invite link is invalid, used or expired' });
|
|
481
|
+
audit(res, 'team.join', name, name);
|
|
408
482
|
res.json({ token: createSession(uid) });
|
|
409
483
|
});
|
|
410
484
|
|
|
411
485
|
// ── me ─────────────────────────────────────────────────────────────────────
|
|
412
486
|
app.get('/api/me', auth, (req, res) => {
|
|
413
487
|
const p = req.principal;
|
|
414
|
-
|
|
488
|
+
// version only behind auth: a public number would tell scanners which instances still run a release
|
|
489
|
+
// with a known hole (Tyto). Never in /auth/status, the login screen or share links.
|
|
490
|
+
const out = { kind: p.kind, role: p.role, accounts: hasUsers(), version: PKG_VERSION };
|
|
415
491
|
if (p.user) {
|
|
416
492
|
const u = q.userById.get(p.user.id);
|
|
417
493
|
Object.assign(out, publicUser(u), {
|
|
@@ -421,7 +497,10 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
421
497
|
}
|
|
422
498
|
res.json(out);
|
|
423
499
|
});
|
|
424
|
-
const userOnly = (req, res, next) =>
|
|
500
|
+
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' });
|
|
502
|
+
return req.principal.user ? next() : res.status(400).json({ error: 'Create an account first' });
|
|
503
|
+
};
|
|
425
504
|
async function needPassword(req, res) {
|
|
426
505
|
if (rateLimited('reauth:' + req.principal.user.id, 10, 15 * 60000)) { res.status(429).json({ error: 'Too many attempts — wait 15 minutes' }); return false; }
|
|
427
506
|
const u = q.userById.get(req.principal.user.id);
|
|
@@ -435,6 +514,7 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
435
514
|
if (npw.length < MIN_PW) return res.status(400).json({ error: `Password needs at least ${MIN_PW} characters` });
|
|
436
515
|
db.prepare('UPDATE users SET pw_hash = ? WHERE id = ?').run(await hashPassword(npw), req.principal.user.id);
|
|
437
516
|
q.sessDelUserExcept.run(req.principal.user.id, req.principal.tokenHash); // sign out other devices
|
|
517
|
+
audit(res, 'me.password');
|
|
438
518
|
res.json({ ok: true });
|
|
439
519
|
});
|
|
440
520
|
|
|
@@ -460,11 +540,13 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
460
540
|
db.prepare('UPDATE users SET totp_secret = totp_pending, totp_pending = NULL, totp_last_step = ?, recovery = ? WHERE id = ?')
|
|
461
541
|
.run(step, JSON.stringify(codes.map((c) => sha256(normRecovery(c)))), u.id);
|
|
462
542
|
q.sessDelUserExcept.run(u.id, req.principal.tokenHash);
|
|
543
|
+
audit(res, 'me.2fa.on');
|
|
463
544
|
res.json({ ok: true, recoveryCodes: codes });
|
|
464
545
|
});
|
|
465
546
|
app.post('/api/me/totp/disable', auth, userOnly, async (req, res) => {
|
|
466
547
|
if (!(await needPassword(req, res))) return;
|
|
467
548
|
db.prepare('UPDATE users SET totp_secret = NULL, totp_pending = NULL, totp_last_step = NULL, recovery = NULL WHERE id = ?').run(req.principal.user.id);
|
|
549
|
+
audit(res, 'me.2fa.off');
|
|
468
550
|
res.json({ ok: true });
|
|
469
551
|
});
|
|
470
552
|
app.post('/api/me/recovery', auth, userOnly, async (req, res) => {
|
|
@@ -473,6 +555,7 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
473
555
|
if (!u.totp_secret) return res.status(400).json({ error: '2FA is off' });
|
|
474
556
|
const codes = newRecoveryCodes();
|
|
475
557
|
db.prepare('UPDATE users SET recovery = ? WHERE id = ?').run(JSON.stringify(codes.map((c) => sha256(normRecovery(c)))), u.id);
|
|
558
|
+
audit(res, 'me.recovery.new');
|
|
476
559
|
res.json({ recoveryCodes: codes });
|
|
477
560
|
});
|
|
478
561
|
|
|
@@ -505,15 +588,53 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
505
588
|
const name = String(b.name || '').trim().slice(0, 60) || 'Passkey';
|
|
506
589
|
db.prepare('INSERT INTO passkeys (id, user_id, public_key, counter, transports, name, created) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
507
590
|
.run(c.id, t.userId, Buffer.from(c.publicKey), c.counter || 0, c.transports ? JSON.stringify(c.transports) : null, name, Date.now());
|
|
591
|
+
audit(res, 'me.passkey.add', name);
|
|
508
592
|
res.json({ ok: true });
|
|
509
593
|
});
|
|
510
594
|
app.delete('/api/me/passkeys/:id', auth, userOnly, (req, res) => {
|
|
595
|
+
const pk = db.prepare('SELECT name FROM passkeys WHERE id = ? AND user_id = ?').get(String(req.params.id), req.principal.user.id);
|
|
511
596
|
db.prepare('DELETE FROM passkeys WHERE id = ? AND user_id = ?').run(String(req.params.id), req.principal.user.id);
|
|
597
|
+
audit(res, 'me.passkey.remove', pk ? pk.name : null);
|
|
598
|
+
res.json({ ok: true });
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
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' });
|
|
603
|
+
const before = Number(req.query.before) || Date.now() + 1;
|
|
604
|
+
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100));
|
|
605
|
+
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));
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
// ── personal API keys ──
|
|
609
|
+
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 })));
|
|
612
|
+
});
|
|
613
|
+
app.post('/api/me/keys', auth, userOnly, (req, res) => {
|
|
614
|
+
const b = req.body || {};
|
|
615
|
+
const n = db.prepare('SELECT COUNT(*) n FROM api_keys WHERE user_id = ?').get(req.principal.user.id).n;
|
|
616
|
+
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
|
+
const key = 'tk_' + crypto.randomBytes(24).toString('base64url');
|
|
620
|
+
const name = String(b.name || '').trim().slice(0, 60) || 'API key';
|
|
621
|
+
const info = db.prepare('INSERT INTO api_keys (key_hash, prefix, user_id, name, read_only, created) VALUES (?, ?, ?, ?, ?, ?)')
|
|
622
|
+
.run(sha256(key), key.slice(0, 10), req.principal.user.id, name, readOnly ? 1 : 0, Date.now());
|
|
623
|
+
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
|
|
625
|
+
});
|
|
626
|
+
app.delete('/api/me/keys/:id', auth, userOnly, (req, res) => {
|
|
627
|
+
const k = db.prepare('SELECT name, prefix FROM api_keys WHERE id = ? AND user_id = ?').get(Number(req.params.id), req.principal.user.id);
|
|
628
|
+
db.prepare('DELETE FROM api_keys WHERE id = ? AND user_id = ?').run(Number(req.params.id), req.principal.user.id);
|
|
629
|
+
audit(res, 'me.apikey.revoke', k ? `${k.name} (${k.prefix}…)` : null);
|
|
512
630
|
res.json({ ok: true });
|
|
513
631
|
});
|
|
514
632
|
|
|
515
633
|
// ── team (owner) ───────────────────────────────────────────────────────────
|
|
516
|
-
|
|
634
|
+
// team management needs a real sign-in (or the server API_KEY): a personal key that leaks must not
|
|
635
|
+
// be able to invite, promote or remove people
|
|
636
|
+
const ownerOnly = (req, res, next) => requireRole('owner')(req, res, () => (req.principal.kind === 'userkey'
|
|
637
|
+
? res.status(403).json({ error: 'API keys cannot manage the team — sign in' }) : next()));
|
|
517
638
|
|
|
518
639
|
// first account: only from a legacy (WEB_PASSWORD) or API-key session, only while there are none
|
|
519
640
|
app.post('/api/team/bootstrap', ownerOnly, async (req, res) => {
|
|
@@ -524,6 +645,7 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
524
645
|
if (pw.length < MIN_PW) return res.status(400).json({ error: `Password needs at least ${MIN_PW} characters` });
|
|
525
646
|
const info = q.insUser.run(name, await hashPassword(pw), 'owner', null, b64u(crypto.randomBytes(16)), Date.now());
|
|
526
647
|
q.sessDelLegacy.run(); // WEB_PASSWORD sessions end here
|
|
648
|
+
audit(res, 'team.first-owner', name);
|
|
527
649
|
res.json({ token: createSession(info.lastInsertRowid) });
|
|
528
650
|
});
|
|
529
651
|
|
|
@@ -545,10 +667,12 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
545
667
|
const expires = Date.now() + INVITE_DAYS * 864e5;
|
|
546
668
|
db.prepare('INSERT INTO invites (token_hash, role, sites, created_by, created, expires) VALUES (?, ?, ?, ?, ?, ?)')
|
|
547
669
|
.run(sha256(token), role, sites ? JSON.stringify(sites) : null, req.principal.user ? req.principal.user.id : null, Date.now(), expires);
|
|
670
|
+
audit(res, 'team.invite', role + (sites ? ' · sites ' + sites.join(',') : ''));
|
|
548
671
|
res.json({ url: '/invite/' + token, role, expires });
|
|
549
672
|
});
|
|
550
673
|
app.delete('/api/team/invites/:id', ownerOnly, (req, res) => {
|
|
551
674
|
db.prepare('DELETE FROM invites WHERE id = ? AND used_at IS NULL').run(Number(req.params.id));
|
|
675
|
+
audit(res, 'team.invite.revoke', '#' + Number(req.params.id));
|
|
552
676
|
res.json({ ok: true });
|
|
553
677
|
});
|
|
554
678
|
app.patch('/api/team/users/:id', ownerOnly, (req, res) => {
|
|
@@ -562,6 +686,7 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
562
686
|
if (sites === undefined) return res.status(400).json({ error: 'sites must be a list of site ids or null' });
|
|
563
687
|
if (Array.isArray(sites) && !sites.length) return res.status(400).json({ error: 'Pick at least one site' });
|
|
564
688
|
db.prepare('UPDATE users SET role = ?, sites = ? WHERE id = ?').run(role, sites ? JSON.stringify(sites) : null, u.id);
|
|
689
|
+
audit(res, 'team.change', `${u.username}: ${u.role} → ${role}` + (sites ? ' · sites ' + sites.join(',') : ''));
|
|
565
690
|
res.json(publicUser(q.userById.get(u.id)));
|
|
566
691
|
});
|
|
567
692
|
app.delete('/api/team/users/:id', ownerOnly, (req, res) => {
|
|
@@ -570,9 +695,12 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
570
695
|
if (u.role === 'owner' && q.ownerCount.get().n <= 1) return res.status(400).json({ error: 'There must be at least one owner' });
|
|
571
696
|
db.transaction(() => {
|
|
572
697
|
db.prepare('DELETE FROM passkeys WHERE user_id = ?').run(u.id);
|
|
698
|
+
db.prepare('DELETE FROM password_resets WHERE user_id = ?').run(u.id);
|
|
699
|
+
db.prepare('DELETE FROM api_keys WHERE user_id = ?').run(u.id);
|
|
573
700
|
q.sessDelUser.run(u.id);
|
|
574
701
|
db.prepare('DELETE FROM users WHERE id = ?').run(u.id);
|
|
575
702
|
})();
|
|
703
|
+
audit(res, 'team.remove', u.username);
|
|
576
704
|
res.json({ ok: true });
|
|
577
705
|
});
|
|
578
706
|
// owner can reset 2FA of a teammate who lost their phone (the teammate then sets it up again)
|
|
@@ -581,10 +709,59 @@ function setupAuth({ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp, site
|
|
|
581
709
|
if (!u) return res.status(404).json({ error: 'Unknown user' });
|
|
582
710
|
db.prepare('UPDATE users SET totp_secret = NULL, totp_pending = NULL, totp_last_step = NULL, recovery = NULL WHERE id = ?').run(u.id);
|
|
583
711
|
q.sessDelUser.run(u.id);
|
|
712
|
+
audit(res, 'team.2fa.reset', u.username);
|
|
584
713
|
res.json({ ok: true });
|
|
585
714
|
});
|
|
586
715
|
|
|
587
|
-
|
|
716
|
+
// owner creates a one-time password-reset link for a teammate (24 h). Only the password changes:
|
|
717
|
+
// 2FA stays on (a reset link must not be a way around it) and all their sessions end.
|
|
718
|
+
app.post('/api/team/users/:id/reset-link', ownerOnly, (req, res) => {
|
|
719
|
+
const u = q.userById.get(Number(req.params.id));
|
|
720
|
+
if (!u) return res.status(404).json({ error: 'Unknown user' });
|
|
721
|
+
const token = randToken();
|
|
722
|
+
const expires = Date.now() + RESET_HOURS * 3600e3;
|
|
723
|
+
db.transaction(() => {
|
|
724
|
+
db.prepare('DELETE FROM password_resets WHERE user_id = ?').run(u.id); // only the newest link works
|
|
725
|
+
db.prepare('INSERT INTO password_resets (token_hash, user_id, created_by, created, expires) VALUES (?, ?, ?, ?, ?)')
|
|
726
|
+
.run(sha256(token), u.id, req.principal.user ? req.principal.user.id : null, Date.now(), expires);
|
|
727
|
+
})();
|
|
728
|
+
audit(res, 'team.reset-link', u.username);
|
|
729
|
+
res.json({ url: '/reset/' + token, username: u.username, expires });
|
|
730
|
+
});
|
|
731
|
+
function resetByToken(token) {
|
|
732
|
+
const r = db.prepare('SELECT * FROM password_resets WHERE token_hash = ?').get(sha256(String(token || '')));
|
|
733
|
+
if (!r || r.used_at || r.expires < Date.now()) return null;
|
|
734
|
+
return r;
|
|
735
|
+
}
|
|
736
|
+
app.get('/api/reset/:token', (req, res) => {
|
|
737
|
+
if (rateLimited('inv:' + clientIp(req), 30, 15 * 60000)) return res.status(429).json({ error: 'Too many attempts — wait 15 minutes' });
|
|
738
|
+
const r = resetByToken(req.params.token);
|
|
739
|
+
const u = r && q.userById.get(r.user_id);
|
|
740
|
+
if (!u) return res.status(404).json({ error: 'This reset link is invalid, used or expired' });
|
|
741
|
+
res.json({ username: u.username, totp: !!u.totp_secret, expires: r.expires });
|
|
742
|
+
});
|
|
743
|
+
app.post('/api/reset/:token', async (req, res) => {
|
|
744
|
+
if (rateLimited('inv:' + clientIp(req), 30, 15 * 60000)) return res.status(429).json({ error: 'Too many attempts — wait 15 minutes' });
|
|
745
|
+
const pw = String((req.body || {}).password || '');
|
|
746
|
+
if (pw.length < MIN_PW) return res.status(400).json({ error: `Password needs at least ${MIN_PW} characters` });
|
|
747
|
+
if (!resetByToken(req.params.token)) return res.status(404).json({ error: 'This reset link is invalid, used or expired' });
|
|
748
|
+
const pwHash = await hashPassword(pw); // async scrypt, outside the (synchronous) transaction
|
|
749
|
+
const uid = db.transaction(() => {
|
|
750
|
+
const r = resetByToken(req.params.token); // checked again: used/expired while hashing?
|
|
751
|
+
if (!r || !q.userById.get(r.user_id)) return null;
|
|
752
|
+
db.prepare('UPDATE password_resets SET used_at = ? WHERE token_hash = ?').run(Date.now(), r.token_hash);
|
|
753
|
+
db.prepare('UPDATE users SET pw_hash = ? WHERE id = ?').run(pwHash, r.user_id);
|
|
754
|
+
q.sessDelUser.run(r.user_id);
|
|
755
|
+
return r.user_id;
|
|
756
|
+
})();
|
|
757
|
+
if (!uid) return res.status(404).json({ error: 'This reset link is invalid, used or expired' });
|
|
758
|
+
// no session here: the person signs in normally (with 2FA, if they have it)
|
|
759
|
+
const who = q.userById.get(uid).username;
|
|
760
|
+
audit(res, 'me.password.reset-link', who, who);
|
|
761
|
+
res.json({ ok: true, username: who });
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
return { auth, requireRole, siteAllowed, principal, audit };
|
|
588
765
|
}
|
|
589
766
|
|
|
590
767
|
// ── offline admin (CLI on the server, for lock-outs) ─────────────────────────
|
|
@@ -600,6 +777,9 @@ function adminOps(db) {
|
|
|
600
777
|
const pw = crypto.randomBytes(12).toString('base64url');
|
|
601
778
|
db.prepare('UPDATE users SET pw_hash = ?, totp_secret = NULL, totp_pending = NULL, totp_last_step = NULL, recovery = NULL WHERE id = ?').run(hashPasswordSync(pw), u.id);
|
|
602
779
|
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(u.id);
|
|
780
|
+
// the dashboard's activity log should show this too (the CLI bypasses the HTTP audit middleware)
|
|
781
|
+
if (db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='audit_log'").get())
|
|
782
|
+
db.prepare('INSERT INTO audit_log (ts, actor, action, target, ip) VALUES (?, ?, ?, ?, ?)').run(Date.now(), 'CLI', 'cli.reset', u.username, null);
|
|
603
783
|
return pw;
|
|
604
784
|
},
|
|
605
785
|
};
|