tacilytics 2.0.2 → 2.1.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 +7 -1
- package/README.md +32 -3
- package/auth.js +608 -0
- package/cli.js +36 -0
- package/package.json +5 -2
- package/public/account.js +335 -0
- package/public/app.js +15 -18
- package/public/assets/icons.svg +5 -0
- package/public/index.html +76 -33
- package/public/style.css +39 -0
- package/server.js +34 -44
package/public/index.html
CHANGED
|
@@ -17,10 +17,34 @@
|
|
|
17
17
|
<div class="modal">
|
|
18
18
|
<img class="logo" src="/assets/tacilytics-mark.svg" alt="">
|
|
19
19
|
<h2>Tacilytics</h2>
|
|
20
|
-
<p>Cookieless analytics. Sign in with your passphrase.</p>
|
|
21
|
-
<div id="loginErr" class="err"></div>
|
|
22
|
-
<
|
|
23
|
-
|
|
20
|
+
<p id="loginHint">Cookieless analytics. Sign in with your passphrase.</p>
|
|
21
|
+
<div id="loginErr" class="err" role="alert"></div>
|
|
22
|
+
<div id="loginStep1">
|
|
23
|
+
<input id="user" class="hide" type="text" placeholder="Username" autocomplete="username webauthn" autocapitalize="off" spellcheck="false">
|
|
24
|
+
<input id="pw" type="password" placeholder="Passphrase" autocomplete="current-password">
|
|
25
|
+
<button class="btn" id="loginBtn">Sign in</button>
|
|
26
|
+
<button class="btn ghost hide" id="passkeyBtn" style="margin-top:10px"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-key"></use></svg>Sign in with a passkey</button>
|
|
27
|
+
</div>
|
|
28
|
+
<div id="loginStep2" class="hide">
|
|
29
|
+
<input id="otp" type="text" inputmode="numeric" placeholder="6-digit code" autocomplete="one-time-code" maxlength="20">
|
|
30
|
+
<button class="btn" id="otpBtn">Verify</button>
|
|
31
|
+
<p class="muted small" style="margin:12px 0 0">Lost your phone? Enter one of your recovery codes instead.</p>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<!-- Invite: create an account from an invite link -->
|
|
37
|
+
<div id="invite" class="modal-overlay hide">
|
|
38
|
+
<div class="modal">
|
|
39
|
+
<img class="logo" src="/assets/tacilytics-mark.svg" alt="">
|
|
40
|
+
<h2>Join Tacilytics</h2>
|
|
41
|
+
<p id="inviteHint">Choose a username and a password.</p>
|
|
42
|
+
<div id="inviteErr" class="err" role="alert"></div>
|
|
43
|
+
<div id="inviteForm">
|
|
44
|
+
<input id="invUser" type="text" placeholder="Username" autocomplete="username" autocapitalize="off" spellcheck="false">
|
|
45
|
+
<input id="invPw" type="password" placeholder="Password (at least 10 characters)" autocomplete="new-password">
|
|
46
|
+
<button class="btn" id="invBtn">Create account</button>
|
|
47
|
+
</div>
|
|
24
48
|
</div>
|
|
25
49
|
</div>
|
|
26
50
|
|
|
@@ -37,7 +61,8 @@
|
|
|
37
61
|
<div class="header-right">
|
|
38
62
|
<div class="realtime" id="realtimeBtn" title="View live visitors" style="cursor:pointer"><span class="dot"></span><span id="realtime">0</span> online now</div>
|
|
39
63
|
<select id="siteSelect"></select>
|
|
40
|
-
<button class="btn ghost" id="addSiteBtn"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
64
|
+
<button class="btn ghost" id="addSiteBtn"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-plus"></use></svg>Add site</button>
|
|
65
|
+
<button class="btn ghost" id="settingsBtn" title="Account, security and team"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-settings"></use></svg><span id="meName">Settings</span></button>
|
|
41
66
|
<button class="btn ghost danger" id="logoutBtn">Sign out</button>
|
|
42
67
|
</div>
|
|
43
68
|
</header>
|
|
@@ -52,12 +77,12 @@
|
|
|
52
77
|
</div>
|
|
53
78
|
<div class="toolbar-right">
|
|
54
79
|
<div class="range" id="range">
|
|
55
|
-
<input type="date" id="fromDate" title="From"><span class="arrow"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
80
|
+
<input type="date" id="fromDate" title="From"><span class="arrow"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-arrow"></use></svg></span><input type="date" id="toDate" title="To">
|
|
56
81
|
<button class="btn ghost" id="applyRange">Apply</button>
|
|
57
82
|
</div>
|
|
58
|
-
<button class="btn ghost" id="exportBtn"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
59
|
-
<button class="btn ghost" id="shareBtn" title="Create a public read-only link"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
60
|
-
<button class="btn ghost" id="snippetBtn"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
83
|
+
<button class="btn ghost" id="exportBtn"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-download"></use></svg>CSV</button>
|
|
84
|
+
<button class="btn ghost" id="shareBtn" title="Create a public read-only link"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-share"></use></svg>Share</button>
|
|
85
|
+
<button class="btn ghost" id="snippetBtn"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-code"></use></svg>Embed code</button>
|
|
61
86
|
</div>
|
|
62
87
|
</div>
|
|
63
88
|
|
|
@@ -94,7 +119,7 @@
|
|
|
94
119
|
|
|
95
120
|
<div class="panels">
|
|
96
121
|
<div class="card panel">
|
|
97
|
-
<h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
122
|
+
<h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-pages"></use></svg> Pages
|
|
98
123
|
<span class="tabs" id="pageTabs">
|
|
99
124
|
<button data-t="pages" class="active">Top</button>
|
|
100
125
|
<button data-t="entryPages">Entry</button>
|
|
@@ -104,7 +129,7 @@
|
|
|
104
129
|
<div class="bars" id="p-pages"></div>
|
|
105
130
|
</div>
|
|
106
131
|
<div class="card panel">
|
|
107
|
-
<h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
132
|
+
<h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-sources"></use></svg> Sources
|
|
108
133
|
<span class="tabs" id="srcTabs">
|
|
109
134
|
<button data-t="sources" class="active">Source</button>
|
|
110
135
|
<button data-t="mediums">Medium</button>
|
|
@@ -113,37 +138,37 @@
|
|
|
113
138
|
</h3>
|
|
114
139
|
<div class="bars" id="p-sources"></div>
|
|
115
140
|
</div>
|
|
116
|
-
<div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
117
|
-
<div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
118
|
-
<div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
119
|
-
<div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
120
|
-
<div class="card panel hide" id="w-platforms"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
141
|
+
<div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-globe"></use></svg> Locations</h3><div class="bars" id="p-countries"></div></div>
|
|
142
|
+
<div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-browser"></use></svg> Browsers</h3><div class="bars" id="p-browsers"></div></div>
|
|
143
|
+
<div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-os"></use></svg> OS</h3><div class="bars" id="p-os"></div></div>
|
|
144
|
+
<div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-device"></use></svg> Devices</h3><div class="bars" id="p-devices"></div></div>
|
|
145
|
+
<div class="card panel hide" id="w-platforms"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-platform"></use></svg> Platforms
|
|
121
146
|
<span class="tabs" id="pfTabs"><button data-t="platforms" class="active">Platform</button><button data-t="appVersions">App version</button></span>
|
|
122
147
|
</h3><div class="bars" id="p-platforms"></div></div>
|
|
123
|
-
<div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
124
|
-
<div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
148
|
+
<div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-goal"></use></svg> Goals & conversions</h3><div class="bars" id="p-goals"></div></div>
|
|
149
|
+
<div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-lang"></use></svg> Languages</h3><div class="bars" id="p-languages"></div></div>
|
|
125
150
|
</div>
|
|
126
151
|
|
|
127
152
|
<!-- shop insights: revenue by source, site search, broken pages, scroll depth (13.10.2026) -->
|
|
128
153
|
<div class="panels" id="insights" style="margin-top:16px">
|
|
129
|
-
<div class="card panel hide" id="w-revsrc"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
130
|
-
<div class="card panel hide" id="w-revpf"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
131
|
-
<div class="card panel hide" id="w-search"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
154
|
+
<div class="card panel hide" id="w-revsrc"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-revenue"></use></svg> Revenue by source <span class="muted hint">first source of the buying visit</span></h3><div class="bars" id="p-revsrc"></div></div>
|
|
155
|
+
<div class="card panel hide" id="w-revpf"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-platform"></use></svg> Revenue by platform <span class="muted hint">web vs. apps</span></h3><div class="bars" id="p-revpf"></div></div>
|
|
156
|
+
<div class="card panel hide" id="w-search"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-search"></use></svg> Site search
|
|
132
157
|
<span class="tabs" id="searchTabs"><button data-t="top" class="active">Top</button><button data-t="zeroResults">No results</button></span>
|
|
133
158
|
</h3><p class="muted hint" id="searchMeta"></p><div class="bars" id="p-search"></div></div>
|
|
134
|
-
<div class="card panel hide" id="w-404"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
135
|
-
<div class="card panel hide" id="w-scroll"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
159
|
+
<div class="card panel hide" id="w-404"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-404"></use></svg> Broken pages (404)</h3><div class="bars" id="p-404"></div></div>
|
|
160
|
+
<div class="card panel hide" id="w-scroll"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-scroll"></use></svg> Scroll depth <span class="muted hint">avg · share reaching 75 %</span></h3><div class="bars" id="p-scroll"></div></div>
|
|
136
161
|
</div>
|
|
137
162
|
|
|
138
163
|
<!-- visitor journeys (sankey) -->
|
|
139
164
|
<div class="card panel" style="margin-top:16px">
|
|
140
|
-
<h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
165
|
+
<h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-journey"></use></svg> Visitor journeys <span class="muted" id="journeyMeta" style="font-size:.75rem;font-weight:400"></span></h3>
|
|
141
166
|
<div id="journeyWrap" style="overflow-x:auto"><svg id="journey" width="100%" height="340"></svg></div>
|
|
142
167
|
</div>
|
|
143
168
|
|
|
144
169
|
<!-- funnels -->
|
|
145
170
|
<div class="card panel" style="margin-top:16px">
|
|
146
|
-
<h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
171
|
+
<h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-funnel"></use></svg> Funnels
|
|
147
172
|
<span class="tabs"><button id="addFunnelBtn">+ New funnel</button></span>
|
|
148
173
|
</h3>
|
|
149
174
|
<div id="funnels" class="funnels"></div>
|
|
@@ -158,7 +183,7 @@
|
|
|
158
183
|
<!-- Add site modal -->
|
|
159
184
|
<div id="addModal" class="modal-overlay hide">
|
|
160
185
|
<div class="modal">
|
|
161
|
-
<span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
186
|
+
<span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-site"></use></svg></span>
|
|
162
187
|
<h2>Add a site</h2>
|
|
163
188
|
<p>Enter the domain you want to track.</p>
|
|
164
189
|
<div id="addErr" class="err"></div>
|
|
@@ -171,13 +196,13 @@
|
|
|
171
196
|
<!-- Snippet modal: website + apps (13.10.2026) -->
|
|
172
197
|
<div id="snippetModal" class="modal-overlay hide">
|
|
173
198
|
<div class="modal modal-wide">
|
|
174
|
-
<span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
199
|
+
<span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-code"></use></svg></span>
|
|
175
200
|
<h2>Add tracking</h2>
|
|
176
201
|
<p style="margin-bottom:12px">for <strong id="snipDomain"></strong>. Same dashboard for website and apps.</p>
|
|
177
202
|
<div class="tabs snip-tabs" id="snipTabs">
|
|
178
|
-
<button data-t="web" class="active"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
179
|
-
<button data-t="flutter"><svg class="ico brand" style="color:#02569B" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
180
|
-
<button data-t="other"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
203
|
+
<button data-t="web" class="active"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-web"></use></svg>Website</button>
|
|
204
|
+
<button data-t="flutter"><svg class="ico brand" style="color:#02569B" aria-hidden="true"><use href="/assets/icons.svg?v=2#b-flutter"></use></svg>Flutter app</button>
|
|
205
|
+
<button data-t="other"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-mobile"></use></svg>Other apps</button>
|
|
181
206
|
</div>
|
|
182
207
|
<p class="muted snip-step" id="snipStep1"></p>
|
|
183
208
|
<div class="snippet" id="snippetCode"></div>
|
|
@@ -193,7 +218,7 @@
|
|
|
193
218
|
<!-- Share modal -->
|
|
194
219
|
<div id="shareModal" class="modal-overlay hide">
|
|
195
220
|
<div class="modal">
|
|
196
|
-
<span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
221
|
+
<span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-share"></use></svg></span>
|
|
197
222
|
<h2>Share dashboard</h2>
|
|
198
223
|
<p>Anyone with this link can view stats for <strong id="shareDomain"></strong> — read-only, no login.</p>
|
|
199
224
|
<div class="snippet" id="shareUrl" style="word-break:break-all"></div>
|
|
@@ -206,7 +231,7 @@
|
|
|
206
231
|
<!-- Funnel builder modal -->
|
|
207
232
|
<div id="funnelModal" class="modal-overlay hide">
|
|
208
233
|
<div class="modal">
|
|
209
|
-
<span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
234
|
+
<span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-funnel"></use></svg></span>
|
|
210
235
|
<h2>New funnel</h2>
|
|
211
236
|
<p>2–8 ordered steps. A visitor advances only after completing the previous step.</p>
|
|
212
237
|
<div id="funnelErr" class="err"></div>
|
|
@@ -233,7 +258,25 @@
|
|
|
233
258
|
</div>
|
|
234
259
|
</div>
|
|
235
260
|
|
|
261
|
+
<!-- Settings: account, security, team -->
|
|
262
|
+
<div id="settingsModal" class="modal-overlay hide">
|
|
263
|
+
<div class="modal modal-wide settings">
|
|
264
|
+
<div class="settings-head">
|
|
265
|
+
<h2>Settings</h2>
|
|
266
|
+
<div class="tabs set-tabs" id="setTabs">
|
|
267
|
+
<button data-t="security" class="active"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-shield"></use></svg>Security</button>
|
|
268
|
+
<button data-t="team"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-users"></use></svg>Team</button>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
<div id="setErr" class="err" role="alert"></div>
|
|
272
|
+
<div id="setSecurity"></div>
|
|
273
|
+
<div id="setTeam" class="hide"></div>
|
|
274
|
+
<button class="btn ghost" id="closeSettings" style="margin-top:18px">Close</button>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
|
|
236
278
|
<div class="tip" id="tip"></div>
|
|
237
|
-
<script src="/
|
|
279
|
+
<script src="/account.js?v=2.1.0"></script>
|
|
280
|
+
<script src="/app.js?v=2.3.1"></script>
|
|
238
281
|
</body>
|
|
239
282
|
</html>
|
package/public/style.css
CHANGED
|
@@ -413,3 +413,42 @@ header::after { content: ""; position: absolute; left: 18px; right: 18px; bottom
|
|
|
413
413
|
/* native dropdown lists follow the dark theme (otherwise light text on a white popup) */
|
|
414
414
|
select { color-scheme: dark; }
|
|
415
415
|
select option { background: #0c1a1d; color: var(--text); }
|
|
416
|
+
|
|
417
|
+
/* ── settings: security + team (2.1) ───────────────────────────────────────── */
|
|
418
|
+
.modal.settings { text-align: left; max-width: 680px; }
|
|
419
|
+
.settings-head { display: flex; align-items: center; gap: 14px; margin-bottom: 12px; flex-wrap: wrap; }
|
|
420
|
+
.settings-head h2 { margin: 0; }
|
|
421
|
+
.set-tabs { margin-left: auto; display: inline-flex; gap: 2px; background: rgba(255,255,255,.03); border: 1px solid var(--line); border-radius: 9px; padding: 2px; }
|
|
422
|
+
.set-tabs button { background: transparent; border: none; color: var(--text-dim); padding: 6px 12px; border-radius: 7px; cursor: pointer; font: 550 0.8rem inherit; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
|
|
423
|
+
.set-tabs button.active { background: var(--surface-2); color: var(--text); box-shadow: inset 0 0 0 1px var(--line-strong); }
|
|
424
|
+
.set-card { border: 1px solid var(--line); border-radius: 14px; padding: 16px 18px; margin-bottom: 12px; background: rgba(255,255,255,.02); }
|
|
425
|
+
.set-card h3 { font-size: 0.95rem; font-weight: 650; margin: 0 0 6px; display: flex; align-items: center; gap: 8px; }
|
|
426
|
+
.set-card p { margin-bottom: 12px; font-size: 0.84rem; }
|
|
427
|
+
.modal .set-card input, .modal .set-card select { text-align: left; margin-bottom: 0; }
|
|
428
|
+
.modal .set-card .btn { width: auto; padding: 9px 14px; }
|
|
429
|
+
.set-card > input { margin-bottom: 10px !important; display: block; }
|
|
430
|
+
.set-card > .btn { width: 100%; }
|
|
431
|
+
.set-inline { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 8px; }
|
|
432
|
+
.set-inline input, .set-inline select { flex: 1 1 160px; min-width: 0; }
|
|
433
|
+
.set-row { display: flex; align-items: center; gap: 10px; padding: 9px 0; border-top: 1px solid var(--line); font-size: 0.86rem; }
|
|
434
|
+
.set-row:first-of-type { border-top: none; }
|
|
435
|
+
.set-row > span:first-child { flex: 1; min-width: 0; display: inline-flex; align-items: center; gap: 7px; flex-wrap: wrap; }
|
|
436
|
+
.team-row > span:first-child { display: block; }
|
|
437
|
+
.team-row select { width: auto; padding: 6px 8px; font-size: 0.8rem; }
|
|
438
|
+
.row-actions { display: inline-flex; gap: 6px; }
|
|
439
|
+
.btn.mini, .modal .btn.mini { padding: 5px 10px; font-size: 0.76rem; width: auto; }
|
|
440
|
+
.small { font-size: 0.76rem; }
|
|
441
|
+
.pill { font-size: 0.66rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; padding: 2px 7px; border-radius: 99px; border: 1px solid var(--line-strong); color: var(--text-dim); }
|
|
442
|
+
.totp-qr { display: grid; place-items: center; margin: 6px 0 10px; }
|
|
443
|
+
.totp-qr img { width: 180px; height: 180px; background: #fff; border-radius: 12px; padding: 8px; }
|
|
444
|
+
.snippet.center { text-align: center; letter-spacing: .08em; }
|
|
445
|
+
.snippet.recovery { columns: 2; text-align: center; font-size: 0.86rem; line-height: 1.9; }
|
|
446
|
+
.site-pick { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: 10px 0 4px; font-size: 0.82rem; }
|
|
447
|
+
.site-pick label { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
|
|
448
|
+
.modal .site-pick input { width: auto; margin: 0; accent-color: var(--accent-strong, var(--accent)); }
|
|
449
|
+
.site-edit { padding: 4px 0 10px; }
|
|
450
|
+
#user, #pw { margin-bottom: 10px; }
|
|
451
|
+
#login .btn.ghost .ico, #settingsBtn .ico { margin-right: 6px; }
|
|
452
|
+
/* viewers: hide everything that would only answer with 403 */
|
|
453
|
+
body.role-viewer #addSiteBtn, body.role-viewer #shareBtn, body.role-viewer #addFunnelBtn { display: none !important; }
|
|
454
|
+
@media (max-width: 560px) { #meName { display: none; } .team-row { flex-wrap: wrap; } }
|
package/server.js
CHANGED
|
@@ -216,14 +216,16 @@ function visitorHash(siteId, ip, ua, day) {
|
|
|
216
216
|
if (!salt) return crypto.randomBytes(12).toString('hex'); // unlinkable one-off visitor
|
|
217
217
|
return crypto.createHash('sha256').update(`${INGEST_SALT}|${salt}|${day}|${siteId}|${ip}|${ua}`).digest('hex').slice(0, 24);
|
|
218
218
|
}
|
|
219
|
+
// The client writes the FIRST X-Forwarded-For entry itself, so it must never be used for rate limits.
|
|
220
|
+
// req.ip honours 'trust proxy': with TRUST_PROXY=1 (default, one reverse proxy in front) it is the
|
|
221
|
+
// entry that proxy appended. Set TRUST_PROXY=0 when the server is reachable without a proxy.
|
|
219
222
|
function clientIp(req) {
|
|
220
|
-
|
|
221
|
-
return xff || req.socket.remoteAddress || '0.0.0.0';
|
|
223
|
+
return req.ip || req.socket.remoteAddress || '0.0.0.0';
|
|
222
224
|
}
|
|
223
225
|
|
|
224
226
|
// ── app ─────────────────────────────────────────────────────────────────────
|
|
225
227
|
const app = express();
|
|
226
|
-
app.set('trust proxy', 1);
|
|
228
|
+
app.set('trust proxy', Number.isFinite(+process.env.TRUST_PROXY) && process.env.TRUST_PROXY !== '' && process.env.TRUST_PROXY != null ? +process.env.TRUST_PROXY : 1);
|
|
227
229
|
// Accept both application/json (dashboard) and text/plain (cross-origin beacons).
|
|
228
230
|
app.use(express.json({ limit: '32kb', type: ['application/json', 'text/plain'] }));
|
|
229
231
|
app.disable('x-powered-by');
|
|
@@ -326,35 +328,16 @@ app.options('/api/collect', (_req, res) => { res.set('Access-Control-Allow-Origi
|
|
|
326
328
|
app.post('/api/collect', collect);
|
|
327
329
|
app.get('/api/collect', collect);
|
|
328
330
|
|
|
329
|
-
// ── auth
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
app
|
|
333
|
-
|
|
334
|
-
const pw = String((req.body && req.body.password) || '');
|
|
335
|
-
const ok = pw.length === WEB_PASSWORD.length && crypto.timingSafeEqual(Buffer.from(pw), Buffer.from(WEB_PASSWORD));
|
|
336
|
-
if (!ok) return res.status(401).json({ error: 'Wrong password' });
|
|
337
|
-
const token = newToken();
|
|
338
|
-
sessions.set(token, { expires: Date.now() + 7 * 864e5 });
|
|
339
|
-
res.json({ token });
|
|
331
|
+
// ── auth: accounts, team, optional 2FA + passkeys (auth.js) ───────────────────
|
|
332
|
+
const { setupAuth } = require('./auth');
|
|
333
|
+
const { auth, requireRole, siteAllowed } = setupAuth({
|
|
334
|
+
app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp,
|
|
335
|
+
allSiteIds: () => db.prepare('SELECT id FROM sites').all().map((r) => r.id),
|
|
340
336
|
});
|
|
341
|
-
|
|
342
|
-
if (!API_KEY) return false;
|
|
343
|
-
const hdr = String(req.headers['authorization'] || '');
|
|
344
|
-
const key = hdr.startsWith('Bearer ') ? hdr.slice(7) : String(req.headers['x-api-key'] || '');
|
|
345
|
-
return key.length === API_KEY.length && crypto.timingSafeEqual(Buffer.from(key), Buffer.from(API_KEY));
|
|
346
|
-
}
|
|
347
|
-
function auth(req, res, next) {
|
|
348
|
-
if (checkApiKey(req)) return next(); // agents / CLI
|
|
349
|
-
const t = req.headers['x-session-token'];
|
|
350
|
-
const s = t && sessions.get(t);
|
|
351
|
-
if (s && s.expires > Date.now()) return next();
|
|
352
|
-
if (s) sessions.delete(t);
|
|
353
|
-
res.status(401).json({ error: 'Not authenticated' });
|
|
354
|
-
}
|
|
337
|
+
const adminOnly = requireRole('admin');
|
|
355
338
|
|
|
356
339
|
// share-token auth: read-only stats access scoped to exactly one site.
|
|
357
|
-
// Accepted as ?share=<token>; the handler must
|
|
340
|
+
// Accepted as ?share=<token>; the handler must check siteOk(req, site).
|
|
358
341
|
const getShare = db.prepare('SELECT * FROM shares WHERE token = ?');
|
|
359
342
|
function authOrShare(req, res, next) {
|
|
360
343
|
const tok = String(req.query.share || '');
|
|
@@ -364,10 +347,13 @@ function authOrShare(req, res, next) {
|
|
|
364
347
|
}
|
|
365
348
|
return auth(req, res, next);
|
|
366
349
|
}
|
|
367
|
-
|
|
350
|
+
// share links see exactly their site; viewers only the sites they were given
|
|
351
|
+
const siteOk = (req, site) => siteAllowed(req, site.id);
|
|
352
|
+
const NO_SITE = { error: 'You have no access to this site' };
|
|
353
|
+
function newToken() { return crypto.randomBytes(24).toString('hex'); }
|
|
368
354
|
|
|
369
355
|
// ── sites management ─────────────────────────────────────────────────────────
|
|
370
|
-
const sitesListHandler = (
|
|
356
|
+
const sitesListHandler = (req, res) => res.json(allSites.all().filter((st) => siteAllowed(req, st.id)));
|
|
371
357
|
const sitesCreateHandler = (req, res) => {
|
|
372
358
|
const rawDomain = norm(req.body && req.body.domain);
|
|
373
359
|
let domain = rawDomain;
|
|
@@ -382,8 +368,8 @@ const sitesCreateHandler = (req, res) => {
|
|
|
382
368
|
res.json(getSiteByDomain.get(domain));
|
|
383
369
|
};
|
|
384
370
|
app.get('/api/sites', auth, sitesListHandler);
|
|
385
|
-
app.post('/api/sites',
|
|
386
|
-
app.delete('/api/sites/:id',
|
|
371
|
+
app.post('/api/sites', adminOnly, sitesCreateHandler);
|
|
372
|
+
app.delete('/api/sites/:id', adminOnly, (req, res) => {
|
|
387
373
|
const id = Number(req.params.id);
|
|
388
374
|
db.prepare('DELETE FROM events WHERE site_id = ?').run(id);
|
|
389
375
|
db.prepare('DELETE FROM sites WHERE id = ?').run(id);
|
|
@@ -403,7 +389,7 @@ app.get('/api/v1', (_req, res) => res.json({
|
|
|
403
389
|
filters: 'add any of country,source,page,browser,os,device,lang,medium,campaign,goal to /stats to segment',
|
|
404
390
|
}));
|
|
405
391
|
app.get('/api/v1/sites', auth, sitesListHandler);
|
|
406
|
-
app.post('/api/v1/sites',
|
|
392
|
+
app.post('/api/v1/sites', adminOnly, sitesCreateHandler);
|
|
407
393
|
|
|
408
394
|
// ── stats ────────────────────────────────────────────────────────────────────
|
|
409
395
|
const PERIODS = { today: 1, '7d': 7, '30d': 30, '90d': 90, '12mo': 365 };
|
|
@@ -452,7 +438,7 @@ function eventPlatformFilter(query) {
|
|
|
452
438
|
function statsHandler(req, res) {
|
|
453
439
|
const site = getSiteByDomain.get(norm(req.query.site));
|
|
454
440
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
455
|
-
if (!
|
|
441
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
456
442
|
const sid = site.id;
|
|
457
443
|
const { start, end, days, period, hourly } = rangeFor(req.query);
|
|
458
444
|
const { clauses, params, goal } = buildFilters(req.query);
|
|
@@ -669,7 +655,7 @@ app.get('/api/v1/stats', authOrShare, statsHandler);
|
|
|
669
655
|
function realtimeHandler(req, res) {
|
|
670
656
|
const site = getSiteByDomain.get(norm(req.query.site));
|
|
671
657
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
672
|
-
if (!
|
|
658
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
673
659
|
const sid = site.id, now = Date.now(), since = now - 30 * 60000;
|
|
674
660
|
const online = db.prepare('SELECT COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND name IS NULL AND ts >= ?').get(sid, now - 5 * 60000).v;
|
|
675
661
|
const rows = db.prepare('SELECT CAST((ts/60000) AS INT) b, COUNT(*) pv, COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND name IS NULL AND ts >= ? GROUP BY b').all(sid, since);
|
|
@@ -683,18 +669,20 @@ app.get('/api/realtime', authOrShare, realtimeHandler);
|
|
|
683
669
|
app.get('/api/v1/realtime', authOrShare, realtimeHandler);
|
|
684
670
|
|
|
685
671
|
// ── share links: create / list / revoke (owner only) + resolve (public) ──────────
|
|
686
|
-
app.get('/api/shares',
|
|
687
|
-
const rows = db.prepare('SELECT s.token, s.created, st.domain FROM shares s JOIN sites st ON st.id = s.site_id ORDER BY s.created DESC').all()
|
|
672
|
+
app.get('/api/shares', adminOnly, (req, res) => {
|
|
673
|
+
const rows = db.prepare('SELECT s.token, s.created, s.site_id, st.domain FROM shares s JOIN sites st ON st.id = s.site_id ORDER BY s.created DESC').all()
|
|
674
|
+
.filter((r) => siteAllowed(req, r.site_id)).map(({ site_id, ...r }) => r);
|
|
688
675
|
res.json(rows);
|
|
689
676
|
});
|
|
690
|
-
app.post('/api/shares',
|
|
677
|
+
app.post('/api/shares', adminOnly, (req, res) => {
|
|
691
678
|
const site = getSiteByDomain.get(norm(req.body && req.body.site));
|
|
692
679
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
680
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
693
681
|
const token = newToken();
|
|
694
682
|
db.prepare('INSERT INTO shares (token, site_id, created) VALUES (?, ?, ?)').run(token, site.id, Date.now());
|
|
695
683
|
res.json({ token, domain: site.domain, url: '/share/' + token });
|
|
696
684
|
});
|
|
697
|
-
app.delete('/api/shares/:token',
|
|
685
|
+
app.delete('/api/shares/:token', adminOnly, (req, res) => {
|
|
698
686
|
db.prepare('DELETE FROM shares WHERE token = ?').run(String(req.params.token));
|
|
699
687
|
res.json({ ok: true });
|
|
700
688
|
});
|
|
@@ -710,11 +698,13 @@ app.get('/api/share-info/:token', (req, res) => {
|
|
|
710
698
|
app.get('/api/funnels', auth, (req, res) => {
|
|
711
699
|
const site = getSiteByDomain.get(norm(req.query.site));
|
|
712
700
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
701
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
713
702
|
res.json(db.prepare('SELECT id, name, steps, created FROM funnels WHERE site_id = ? ORDER BY id').all(site.id).map((f) => ({ ...f, steps: JSON.parse(f.steps) })));
|
|
714
703
|
});
|
|
715
|
-
app.post('/api/funnels',
|
|
704
|
+
app.post('/api/funnels', adminOnly, (req, res) => {
|
|
716
705
|
const site = getSiteByDomain.get(norm(req.body && req.body.site));
|
|
717
706
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
707
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
718
708
|
const name = String((req.body && req.body.name) || '').slice(0, 80).trim();
|
|
719
709
|
const raw = (req.body && req.body.steps) || [];
|
|
720
710
|
if (!name) return res.status(400).json({ error: 'Funnel needs a name' });
|
|
@@ -728,7 +718,7 @@ app.post('/api/funnels', auth, (req, res) => {
|
|
|
728
718
|
const info = db.prepare('INSERT INTO funnels (site_id, name, steps, created) VALUES (?, ?, ?, ?)').run(site.id, name, JSON.stringify(steps), Date.now());
|
|
729
719
|
res.json({ id: info.lastInsertRowid, name, steps });
|
|
730
720
|
});
|
|
731
|
-
app.delete('/api/funnels/:id',
|
|
721
|
+
app.delete('/api/funnels/:id', adminOnly, (req, res) => {
|
|
732
722
|
db.prepare('DELETE FROM funnels WHERE id = ?').run(Number(req.params.id));
|
|
733
723
|
res.json({ ok: true });
|
|
734
724
|
});
|
|
@@ -737,7 +727,7 @@ app.get('/api/funnels/:id/stats', authOrShare, (req, res) => {
|
|
|
737
727
|
const f = db.prepare('SELECT * FROM funnels WHERE id = ?').get(Number(req.params.id));
|
|
738
728
|
if (!f) return res.status(404).json({ error: 'Unknown funnel' });
|
|
739
729
|
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(f.site_id);
|
|
740
|
-
if (!
|
|
730
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
741
731
|
const { start, end } = rangeFor(req.query);
|
|
742
732
|
const steps = JSON.parse(f.steps);
|
|
743
733
|
// one pass per visitor: fetch matching events sorted by time, walk the step ladder.
|
|
@@ -771,7 +761,7 @@ app.use(express.static(path.join(__dirname, 'public')));
|
|
|
771
761
|
app.get('/api/journeys', authOrShare, (req, res) => {
|
|
772
762
|
const site = getSiteByDomain.get(norm(req.query.site));
|
|
773
763
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
774
|
-
if (!
|
|
764
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
775
765
|
const { start, end } = rangeFor(req.query);
|
|
776
766
|
const maxDepth = Math.min(5, Math.max(2, parseInt(req.query.depth, 10) || 4));
|
|
777
767
|
const GAP = 30 * 60000;
|