@harshankur/viewcounter 3.0.0 → 3.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 +20 -0
- package/README.md +189 -14
- package/admin/apple-touch-icon.png +0 -0
- package/admin/assets/world-map.json +1 -0
- package/admin/css/admin.css +1875 -0
- package/admin/favicon.ico +0 -0
- package/admin/favicon.svg +9 -0
- package/admin/icon-192.png +0 -0
- package/admin/icon-512.png +0 -0
- package/admin/index.html +89 -0
- package/admin/js/api.js +100 -0
- package/admin/js/charts.js +502 -0
- package/admin/js/clamp.js +41 -0
- package/admin/js/constants.js +150 -0
- package/admin/js/dom.js +83 -0
- package/admin/js/format.js +79 -0
- package/admin/js/i18n.js +80 -0
- package/admin/js/insights.js +192 -0
- package/admin/js/listbox.js +144 -0
- package/admin/js/logs.js +167 -0
- package/admin/js/main.js +235 -0
- package/admin/js/modal.js +171 -0
- package/admin/js/table.js +134 -0
- package/admin/js/theme.js +72 -0
- package/admin/js/toast.js +47 -0
- package/admin/js/viewDialogs.js +208 -0
- package/admin/js/views.js +685 -0
- package/admin/locales/en.json +394 -0
- package/admin/site.webmanifest +20 -0
- package/config/index.js +83 -0
- package/constants.js +215 -2
- package/db/AdminRepository.js +562 -0
- package/db/DatabaseManager.js +94 -20
- package/db/LogRepository.js +217 -0
- package/db/adminSchema.js +244 -0
- package/db/retention.js +97 -0
- package/index.js +39 -6
- package/middleware/adminAuth.js +204 -0
- package/middleware/adminValidation.js +253 -0
- package/package.json +17 -10
- package/routes/admin.js +438 -0
- package/routes/analytics.js +11 -2
- package/utils/cookieUtils.js +47 -0
- package/utils/errorUtils.js +35 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
{
|
|
2
|
+
"app": {
|
|
3
|
+
"name": "viewcounter",
|
|
4
|
+
"badge": "admin",
|
|
5
|
+
"documentTitle": "ViewCounter Admin"
|
|
6
|
+
},
|
|
7
|
+
"nav": {
|
|
8
|
+
"skip": "Skip to content",
|
|
9
|
+
"logout": "Sign out"
|
|
10
|
+
},
|
|
11
|
+
"theme": {
|
|
12
|
+
"label": "Colour theme",
|
|
13
|
+
"light": "Light mode",
|
|
14
|
+
"dark": "Dark mode",
|
|
15
|
+
"auto": "System preference"
|
|
16
|
+
},
|
|
17
|
+
"login": {
|
|
18
|
+
"windowTitle": "admin@viewcounter:~",
|
|
19
|
+
"heading": "sign in",
|
|
20
|
+
"intro": "Enter the admin password configured as ADMIN_PASSWORD on this server.",
|
|
21
|
+
"password": "Password",
|
|
22
|
+
"submit": "Sign in",
|
|
23
|
+
"signedOut": "You have been signed out."
|
|
24
|
+
},
|
|
25
|
+
"tabs": {
|
|
26
|
+
"label": "Sections",
|
|
27
|
+
"views": "views",
|
|
28
|
+
"trash": "trash",
|
|
29
|
+
"adminLog": "admin-log",
|
|
30
|
+
"viewLog": "view-log"
|
|
31
|
+
},
|
|
32
|
+
"common": {
|
|
33
|
+
"none": "-",
|
|
34
|
+
"close": "Close",
|
|
35
|
+
"cancel": "Cancel",
|
|
36
|
+
"dismiss": "Dismiss",
|
|
37
|
+
"yes": "Yes",
|
|
38
|
+
"no": "No"
|
|
39
|
+
},
|
|
40
|
+
"errors": {
|
|
41
|
+
"UNAUTHENTICATED": "Your session has ended. Please sign in again.",
|
|
42
|
+
"INVALID_PASSWORD": "That password is not correct.",
|
|
43
|
+
"TOO_MANY_ATTEMPTS": "Too many failed attempts. Wait a few minutes and try again.",
|
|
44
|
+
"RATE_LIMITED": "Too many requests. Slow down for a moment and try again.",
|
|
45
|
+
"CSRF_REJECTED": "The request was refused for security reasons. Reload the page and try again.",
|
|
46
|
+
"VALIDATION_FAILED": "The server did not accept that input.",
|
|
47
|
+
"NOT_FOUND": "That was not found.",
|
|
48
|
+
"SERVER_ERROR": "Something went wrong on the server. Please try again.",
|
|
49
|
+
"NETWORK": "The server could not be reached. Check your connection."
|
|
50
|
+
},
|
|
51
|
+
"pager": {
|
|
52
|
+
"previous": "Previous",
|
|
53
|
+
"next": "Next",
|
|
54
|
+
"summary": {
|
|
55
|
+
"one": "Page {page} of {pages} · {total} entry",
|
|
56
|
+
"other": "Page {page} of {pages} · {total} entries"
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"appTabs": {
|
|
60
|
+
"label": "Apps",
|
|
61
|
+
"tabName": "{app}, {count}",
|
|
62
|
+
"activeCount": {
|
|
63
|
+
"one": "{count} view",
|
|
64
|
+
"other": "{count} views"
|
|
65
|
+
},
|
|
66
|
+
"deletedCount": {
|
|
67
|
+
"one": "{count} in trash",
|
|
68
|
+
"other": "{count} in trash"
|
|
69
|
+
},
|
|
70
|
+
"unavailable": "The table for {app} is missing on the database server.",
|
|
71
|
+
"all": "All apps"
|
|
72
|
+
},
|
|
73
|
+
"toolbar": {
|
|
74
|
+
"search": "Search views",
|
|
75
|
+
"searchPlaceholder": "Search page, title, source, note, event…",
|
|
76
|
+
"modifiedFilter": "Filter by admin changes",
|
|
77
|
+
"showDeleted": "Show deleted",
|
|
78
|
+
"pageSize": "Rows per page",
|
|
79
|
+
"perPage": {
|
|
80
|
+
"one": "{count} per page",
|
|
81
|
+
"other": "{count} per page"
|
|
82
|
+
},
|
|
83
|
+
"range": "Date range",
|
|
84
|
+
"eventType": "Event type",
|
|
85
|
+
"allEventTypes": "All event types"
|
|
86
|
+
},
|
|
87
|
+
"modifiedFilter": {
|
|
88
|
+
"any": "All",
|
|
89
|
+
"modified": "Modified",
|
|
90
|
+
"unmodified": "Original"
|
|
91
|
+
},
|
|
92
|
+
"batch": {
|
|
93
|
+
"region": "Actions for selected views",
|
|
94
|
+
"selected": {
|
|
95
|
+
"one": "{count} view selected",
|
|
96
|
+
"other": "{count} views selected"
|
|
97
|
+
},
|
|
98
|
+
"edit": "Edit fields",
|
|
99
|
+
"note": "Set note",
|
|
100
|
+
"delete": "Move to trash",
|
|
101
|
+
"restore": "Restore",
|
|
102
|
+
"purge": "Erase permanently",
|
|
103
|
+
"clear": "Clear selection",
|
|
104
|
+
"limit": {
|
|
105
|
+
"one": "At most {count} view can be selected at once.",
|
|
106
|
+
"other": "At most {count} views can be selected at once."
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
"table": {
|
|
110
|
+
"selectPage": "Select every view on this page",
|
|
111
|
+
"selectRow": "Select the view from {time}",
|
|
112
|
+
"empty": "No views match.",
|
|
113
|
+
"emptyTrash": "The trash is empty."
|
|
114
|
+
},
|
|
115
|
+
"columns": {
|
|
116
|
+
"timestamp": "Time",
|
|
117
|
+
"page": "Page",
|
|
118
|
+
"source": "Source",
|
|
119
|
+
"device": "Device",
|
|
120
|
+
"country": "Country",
|
|
121
|
+
"client": "Client",
|
|
122
|
+
"event": "Event",
|
|
123
|
+
"status": "Status",
|
|
124
|
+
"actions": "Actions",
|
|
125
|
+
"app": "App"
|
|
126
|
+
},
|
|
127
|
+
"status": {
|
|
128
|
+
"deleted": "In trash",
|
|
129
|
+
"modified": "Modified",
|
|
130
|
+
"note": "Note",
|
|
131
|
+
"repeat": "Repeat"
|
|
132
|
+
},
|
|
133
|
+
"rowActions": {
|
|
134
|
+
"details": "Show details",
|
|
135
|
+
"edit": "Edit fields",
|
|
136
|
+
"note": "Edit note",
|
|
137
|
+
"restore": "Restore",
|
|
138
|
+
"delete": "Move to trash",
|
|
139
|
+
"purge": "Erase permanently"
|
|
140
|
+
},
|
|
141
|
+
"sources": {
|
|
142
|
+
"direct": "Direct",
|
|
143
|
+
"search": "Search",
|
|
144
|
+
"social": "Social",
|
|
145
|
+
"email": "Email",
|
|
146
|
+
"campaign": "Campaign",
|
|
147
|
+
"referral": "Referral",
|
|
148
|
+
"unknown": "Unknown"
|
|
149
|
+
},
|
|
150
|
+
"deviceSizes": {
|
|
151
|
+
"small": "Small",
|
|
152
|
+
"medium": "Medium",
|
|
153
|
+
"large": "Large"
|
|
154
|
+
},
|
|
155
|
+
"views": {
|
|
156
|
+
"summary": {
|
|
157
|
+
"one": "{active} view · {modified} modified · {deleted} in trash",
|
|
158
|
+
"other": "{active} views · {modified} modified · {deleted} in trash"
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
"trash": {
|
|
162
|
+
"retention": {
|
|
163
|
+
"one": "Views in the trash no longer count in any statistic. They are erased permanently {count} day after being deleted, or sooner if you erase them here.",
|
|
164
|
+
"other": "Views in the trash no longer count in any statistic. They are erased permanently {count} days after being deleted, or sooner if you erase them here."
|
|
165
|
+
},
|
|
166
|
+
"retentionOff": "Views in the trash no longer count in any statistic. Automatic erasure is off (TRASH_RETENTION_DAYS=0), so they stay until erased here."
|
|
167
|
+
},
|
|
168
|
+
"toasts": {
|
|
169
|
+
"edited": {
|
|
170
|
+
"one": "Edited {count} view.",
|
|
171
|
+
"other": "Edited {count} views."
|
|
172
|
+
},
|
|
173
|
+
"editedPartial": {
|
|
174
|
+
"one": "Edited {count} view; {skipped} skipped because they are in the trash or gone.",
|
|
175
|
+
"other": "Edited {count} views; {skipped} skipped because they are in the trash or gone."
|
|
176
|
+
},
|
|
177
|
+
"noteSaved": {
|
|
178
|
+
"one": "Saved the note on {count} view.",
|
|
179
|
+
"other": "Saved the note on {count} views."
|
|
180
|
+
},
|
|
181
|
+
"noteSavedPartial": {
|
|
182
|
+
"one": "Saved the note on {count} view; {skipped} no longer exist.",
|
|
183
|
+
"other": "Saved the note on {count} views; {skipped} no longer exist."
|
|
184
|
+
},
|
|
185
|
+
"noteCleared": {
|
|
186
|
+
"one": "Cleared the note on {count} view.",
|
|
187
|
+
"other": "Cleared the note on {count} views."
|
|
188
|
+
},
|
|
189
|
+
"noteClearedPartial": {
|
|
190
|
+
"one": "Cleared the note on {count} view; {skipped} no longer exist.",
|
|
191
|
+
"other": "Cleared the note on {count} views; {skipped} no longer exist."
|
|
192
|
+
},
|
|
193
|
+
"deleted": {
|
|
194
|
+
"one": "Moved {count} view to the trash.",
|
|
195
|
+
"other": "Moved {count} views to the trash."
|
|
196
|
+
},
|
|
197
|
+
"deletedPartial": {
|
|
198
|
+
"one": "Moved {count} view to the trash; {skipped} were already there.",
|
|
199
|
+
"other": "Moved {count} views to the trash; {skipped} were already there."
|
|
200
|
+
},
|
|
201
|
+
"restored": {
|
|
202
|
+
"one": "Restored {count} view.",
|
|
203
|
+
"other": "Restored {count} views."
|
|
204
|
+
},
|
|
205
|
+
"restoredPartial": {
|
|
206
|
+
"one": "Restored {count} view; {skipped} were not in the trash.",
|
|
207
|
+
"other": "Restored {count} views; {skipped} were not in the trash."
|
|
208
|
+
},
|
|
209
|
+
"purged": {
|
|
210
|
+
"one": "Erased {count} view permanently.",
|
|
211
|
+
"other": "Erased {count} views permanently."
|
|
212
|
+
},
|
|
213
|
+
"purgedPartial": {
|
|
214
|
+
"one": "Erased {count} view permanently; {skipped} were not in the trash.",
|
|
215
|
+
"other": "Erased {count} views permanently; {skipped} were not in the trash."
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
"confirm": {
|
|
219
|
+
"deleteTitle": {
|
|
220
|
+
"one": "Move {count} view to the trash?",
|
|
221
|
+
"other": "Move {count} views to the trash?"
|
|
222
|
+
},
|
|
223
|
+
"deleteMessage": {
|
|
224
|
+
"one": "It stops counting in every statistic at once. You can restore it from the trash within {days} days, after which it is erased for good.",
|
|
225
|
+
"other": "They stop counting in every statistic at once. You can restore them from the trash within {days} days, after which they are erased for good."
|
|
226
|
+
},
|
|
227
|
+
"deleteMessageNoRetention": {
|
|
228
|
+
"one": "It stops counting in every statistic at once. You can restore it from the trash at any time.",
|
|
229
|
+
"other": "They stop counting in every statistic at once. You can restore them from the trash at any time."
|
|
230
|
+
},
|
|
231
|
+
"deleteAction": "Move to trash",
|
|
232
|
+
"purgeTitle": {
|
|
233
|
+
"one": "Erase {count} view permanently?",
|
|
234
|
+
"other": "Erase {count} views permanently?"
|
|
235
|
+
},
|
|
236
|
+
"purgeMessage": {
|
|
237
|
+
"one": "This removes the view from the database for good. It cannot be restored. The admin log keeps a record that it was erased, but none of its content.",
|
|
238
|
+
"other": "This removes the views from the database for good. They cannot be restored. The admin log keeps a record that they were erased, but none of their content."
|
|
239
|
+
},
|
|
240
|
+
"purgeAction": "Erase permanently"
|
|
241
|
+
},
|
|
242
|
+
"details": {
|
|
243
|
+
"title": "View details"
|
|
244
|
+
},
|
|
245
|
+
"fields": {
|
|
246
|
+
"id": "View ID",
|
|
247
|
+
"timestamp": "Recorded at",
|
|
248
|
+
"pagePath": "Page path",
|
|
249
|
+
"pageTitle": "Page title",
|
|
250
|
+
"referrer": "Referrer",
|
|
251
|
+
"referrerDomain": "Referrer domain",
|
|
252
|
+
"sourceType": "Source",
|
|
253
|
+
"deviceSize": "Device size",
|
|
254
|
+
"deviceType": "Device type",
|
|
255
|
+
"country": "Country",
|
|
256
|
+
"maskedIp": "Masked IP",
|
|
257
|
+
"browser": "Browser",
|
|
258
|
+
"browserVersion": "Browser version",
|
|
259
|
+
"os": "Operating system",
|
|
260
|
+
"osVersion": "OS version",
|
|
261
|
+
"sessionId": "Session ID",
|
|
262
|
+
"eventType": "Event type",
|
|
263
|
+
"eventData": "Event data",
|
|
264
|
+
"isUnique": "Unique view",
|
|
265
|
+
"note": "Note",
|
|
266
|
+
"adminModifiedAt": "Modified by admin",
|
|
267
|
+
"deletedAt": "Moved to trash"
|
|
268
|
+
},
|
|
269
|
+
"edit": {
|
|
270
|
+
"titleSingle": "Edit view",
|
|
271
|
+
"titleBatch": {
|
|
272
|
+
"one": "Edit {count} view",
|
|
273
|
+
"other": "Edit {count} views"
|
|
274
|
+
},
|
|
275
|
+
"introSingle": "Change a field and it is saved. The view is then marked as modified by an admin.",
|
|
276
|
+
"introBatch": {
|
|
277
|
+
"one": "Tick each field to overwrite on the selected view. Unticked fields are left alone.",
|
|
278
|
+
"other": "Tick each field to overwrite on all {count} selected views. Unticked fields are left alone."
|
|
279
|
+
},
|
|
280
|
+
"lockedNotice": "What was observed about the visitor (time, masked IP, country, browser, device type) can never be edited. The source is recalculated from the referrer.",
|
|
281
|
+
"include": "Change {field}",
|
|
282
|
+
"invalidJson": "This is not valid JSON.",
|
|
283
|
+
"nothingSelected": "Choose at least one field to change.",
|
|
284
|
+
"save": "Save changes"
|
|
285
|
+
},
|
|
286
|
+
"note": {
|
|
287
|
+
"titleSingle": "Note",
|
|
288
|
+
"titleBatch": {
|
|
289
|
+
"one": "Note for {count} view",
|
|
290
|
+
"other": "Note for {count} views"
|
|
291
|
+
},
|
|
292
|
+
"intro": "A private annotation for admins. Adding a note does not mark a view as modified.",
|
|
293
|
+
"clear": "Clear note",
|
|
294
|
+
"save": "Save note"
|
|
295
|
+
},
|
|
296
|
+
"logs": {
|
|
297
|
+
"filterAction": "Filter by action",
|
|
298
|
+
"allActions": "All actions",
|
|
299
|
+
"filterApp": "Filter by app",
|
|
300
|
+
"allApps": "All apps",
|
|
301
|
+
"filterSource": "Filter by source",
|
|
302
|
+
"allSources": "All sources",
|
|
303
|
+
"system": "system",
|
|
304
|
+
"emptyAdmin": "No admin operations recorded yet.",
|
|
305
|
+
"emptyViews": "No views recorded yet.",
|
|
306
|
+
"viewLogRetention": {
|
|
307
|
+
"one": "Entries are removed automatically {count} day after they are recorded. The views themselves are not affected.",
|
|
308
|
+
"other": "Entries are removed automatically {count} days after they are recorded. The views themselves are not affected."
|
|
309
|
+
},
|
|
310
|
+
"viewLogRetentionOff": "Entries are kept until removed by hand (VIEW_LOG_RETENTION_DAYS=0)."
|
|
311
|
+
},
|
|
312
|
+
"logColumns": {
|
|
313
|
+
"time": "Time",
|
|
314
|
+
"action": "Action",
|
|
315
|
+
"app": "App",
|
|
316
|
+
"rows": "Rows",
|
|
317
|
+
"fields": "Fields",
|
|
318
|
+
"session": "Session",
|
|
319
|
+
"ip": "IP (masked)",
|
|
320
|
+
"source": "Source",
|
|
321
|
+
"event": "Event",
|
|
322
|
+
"unique": "Unique",
|
|
323
|
+
"view": "View ID"
|
|
324
|
+
},
|
|
325
|
+
"actions": {
|
|
326
|
+
"login_succeeded": "Signed in",
|
|
327
|
+
"login_failed": "Failed sign-in",
|
|
328
|
+
"logout": "Signed out",
|
|
329
|
+
"views_edited": "Edited",
|
|
330
|
+
"note_set": "Note set",
|
|
331
|
+
"note_cleared": "Note cleared",
|
|
332
|
+
"views_deleted": "Moved to trash",
|
|
333
|
+
"views_restored": "Restored",
|
|
334
|
+
"views_purged": "Erased",
|
|
335
|
+
"trash_auto_purged": "Trash auto-erased",
|
|
336
|
+
"view_log_pruned": "View log pruned"
|
|
337
|
+
},
|
|
338
|
+
"logSources": {
|
|
339
|
+
"registerView": "Page view",
|
|
340
|
+
"event": "Custom event"
|
|
341
|
+
},
|
|
342
|
+
"ranges": {
|
|
343
|
+
"7d": "7 days",
|
|
344
|
+
"30d": "30 days",
|
|
345
|
+
"90d": "90 days",
|
|
346
|
+
"1y": "1 year",
|
|
347
|
+
"all": "All time"
|
|
348
|
+
},
|
|
349
|
+
"eventTypes": {
|
|
350
|
+
"pageview": "Page view"
|
|
351
|
+
},
|
|
352
|
+
"insights": {
|
|
353
|
+
"title": "insights",
|
|
354
|
+
"scopeAll": "All apps, filtered like the table below",
|
|
355
|
+
"scopeApp": "{app}, filtered like the table below",
|
|
356
|
+
"span": "From {from} to {to}",
|
|
357
|
+
"noData": "No views match these filters.",
|
|
358
|
+
"views": "Views",
|
|
359
|
+
"uniqueViews": "Unique views",
|
|
360
|
+
"visitors": "Visitors",
|
|
361
|
+
"uniqueShare": "Unique views",
|
|
362
|
+
"countries": "Countries",
|
|
363
|
+
"modified": "Modified by admin",
|
|
364
|
+
"trendTitle": "Views over time",
|
|
365
|
+
"mapTitle": "Where views come from",
|
|
366
|
+
"showTable": "Show as table",
|
|
367
|
+
"showChart": "Show as chart",
|
|
368
|
+
"period": "Period",
|
|
369
|
+
"weekOf": "Week of {date}",
|
|
370
|
+
"trendLabel": "Views over time from {from} to {to}, peaking at {peak}. Use the arrow keys to read each period.",
|
|
371
|
+
"share": "of views",
|
|
372
|
+
"other": "Other",
|
|
373
|
+
"otherTypes": "Other types",
|
|
374
|
+
"unknown": "Unknown",
|
|
375
|
+
"topCountries": "Countries by views",
|
|
376
|
+
"mapLabel": "World map of views by country, {countries} countries with views. The list beside it gives the numbers.",
|
|
377
|
+
"mapUnavailable": "The map could not be loaded. The list beside it has every number.",
|
|
378
|
+
"noViews": "No views",
|
|
379
|
+
"moreCountries": {
|
|
380
|
+
"one": "Show {count} more country",
|
|
381
|
+
"other": "Show {count} more countries"
|
|
382
|
+
},
|
|
383
|
+
"breakdown": {
|
|
384
|
+
"source": "Sources",
|
|
385
|
+
"deviceSize": "Device sizes",
|
|
386
|
+
"browser": "Browsers",
|
|
387
|
+
"os": "Operating systems",
|
|
388
|
+
"eventType": "Event types",
|
|
389
|
+
"app": "Apps"
|
|
390
|
+
},
|
|
391
|
+
"inProgress": "{period} (so far)",
|
|
392
|
+
"fewerCountries": "Show fewer"
|
|
393
|
+
}
|
|
394
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ViewCounter Admin",
|
|
3
|
+
"short_name": "ViewCounter",
|
|
4
|
+
"start_url": "./",
|
|
5
|
+
"icons": [
|
|
6
|
+
{
|
|
7
|
+
"src": "icon-192.png",
|
|
8
|
+
"sizes": "192x192",
|
|
9
|
+
"type": "image/png"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"src": "icon-512.png",
|
|
13
|
+
"sizes": "512x512",
|
|
14
|
+
"type": "image/png"
|
|
15
|
+
}
|
|
16
|
+
],
|
|
17
|
+
"theme_color": "#0a0f0c",
|
|
18
|
+
"background_color": "#0a0f0c",
|
|
19
|
+
"display": "standalone"
|
|
20
|
+
}
|
package/config/index.js
CHANGED
|
@@ -4,6 +4,7 @@ const path = require('path');
|
|
|
4
4
|
require('dotenv').config({ quiet: true });
|
|
5
5
|
|
|
6
6
|
const {
|
|
7
|
+
ADMIN,
|
|
7
8
|
DATABASE,
|
|
8
9
|
INSECURE_DEFAULTS,
|
|
9
10
|
NODE_ENV,
|
|
@@ -96,6 +97,7 @@ class Config {
|
|
|
96
97
|
this.allowed = this.loadAllowed();
|
|
97
98
|
this.auth = this.loadAuthConfig();
|
|
98
99
|
this.privacy = this.loadPrivacyConfig();
|
|
100
|
+
this.admin = this.loadAdminConfig();
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
/**
|
|
@@ -293,6 +295,51 @@ class Config {
|
|
|
293
295
|
};
|
|
294
296
|
}
|
|
295
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Admin UI credential and data-retention settings.
|
|
300
|
+
*
|
|
301
|
+
* `ADMIN_PASSWORD` is its own credential tier (SECURITY.md §3): it can
|
|
302
|
+
* read, edit, and delete every app's data, which neither a read key nor an
|
|
303
|
+
* ADMIN_API_KEYS provisioning key may do. It is read from the environment
|
|
304
|
+
* only, never from a config file, because it is a secret. The UI is
|
|
305
|
+
* disabled, failing closed, whenever it is absent.
|
|
306
|
+
*
|
|
307
|
+
* A password that is set but shorter than the minimum is kept (so
|
|
308
|
+
* validate() can refuse to boot on it in production) but never enables
|
|
309
|
+
* the UI.
|
|
310
|
+
*
|
|
311
|
+
* `TRASH_RETENTION_DAYS` bounds how long a soft-deleted view is kept before
|
|
312
|
+
* it is erased for good (GDPR Art. 5(1)(e), storage limitation). Zero keeps
|
|
313
|
+
* trash until an admin empties it by hand.
|
|
314
|
+
*
|
|
315
|
+
* `VIEW_LOG_RETENTION_DAYS` bounds the view register log, which gains a
|
|
316
|
+
* row for every accepted view. It holds no personal data, so this is about
|
|
317
|
+
* storage rather than law, but a log nobody bounds grows as large as all
|
|
318
|
+
* the app tables together. Zero keeps it forever. The admin log is never
|
|
319
|
+
* pruned: it is the record of who changed or erased what, and it grows only
|
|
320
|
+
* with admin activity.
|
|
321
|
+
*
|
|
322
|
+
* An out-of-range or unparseable value falls back to the default.
|
|
323
|
+
*/
|
|
324
|
+
loadAdminConfig() {
|
|
325
|
+
const password = this.env.ADMIN_PASSWORD || '';
|
|
326
|
+
const longEnough = password.length >= ADMIN.MIN_PASSWORD_LENGTH;
|
|
327
|
+
|
|
328
|
+
const retention = (raw, fallback, max) => {
|
|
329
|
+
const days = parseIntOr(raw, fallback);
|
|
330
|
+
return days >= 0 && days <= max ? days : fallback;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
password,
|
|
335
|
+
enabled: longEnough,
|
|
336
|
+
trashRetentionDays: retention(
|
|
337
|
+
this.env.TRASH_RETENTION_DAYS, ADMIN.DEFAULT_TRASH_RETENTION_DAYS, ADMIN.MAX_TRASH_RETENTION_DAYS),
|
|
338
|
+
viewLogRetentionDays: retention(
|
|
339
|
+
this.env.VIEW_LOG_RETENTION_DAYS, ADMIN.DEFAULT_VIEW_LOG_RETENTION_DAYS, ADMIN.MAX_VIEW_LOG_RETENTION_DAYS),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
296
343
|
/**
|
|
297
344
|
* Fail-fast startup validation (CONFIG.md §3, SECURITY.md §1).
|
|
298
345
|
*
|
|
@@ -308,6 +355,8 @@ class Config {
|
|
|
308
355
|
// persist it fails at startup rather than on its first request.
|
|
309
356
|
void this.privacy.visitorSecret;
|
|
310
357
|
|
|
358
|
+
this.validateAdmin();
|
|
359
|
+
|
|
311
360
|
if (!this.server.isProduction) {
|
|
312
361
|
this.warnAboutDevelopmentDefaults();
|
|
313
362
|
return this;
|
|
@@ -338,6 +387,40 @@ class Config {
|
|
|
338
387
|
return this;
|
|
339
388
|
}
|
|
340
389
|
|
|
390
|
+
/**
|
|
391
|
+
* Admin credential checks, shared by every environment.
|
|
392
|
+
*
|
|
393
|
+
* A password that is present but too short is a misconfiguration, not a
|
|
394
|
+
* choice, so production refuses to boot on it rather than silently leaving
|
|
395
|
+
* the UI off. Reusing an API key as the password collapses two tiers into
|
|
396
|
+
* one secret, which is worth a warning in any environment.
|
|
397
|
+
*/
|
|
398
|
+
validateAdmin() {
|
|
399
|
+
const { password, enabled } = this.admin;
|
|
400
|
+
|
|
401
|
+
if (!password) {
|
|
402
|
+
logWarning(WarningType.ADMIN_UI_DISABLED);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (!enabled && this.server.isProduction) {
|
|
407
|
+
throw getError(ErrorType.CONFIG_INVALID_VALUE, {
|
|
408
|
+
field: 'ADMIN_PASSWORD',
|
|
409
|
+
reason: `must be at least ${ADMIN.MIN_PASSWORD_LENGTH} characters`,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (!enabled) {
|
|
414
|
+
logWarning(WarningType.ADMIN_UI_DISABLED);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const apiKeys = [...Object.keys(this.auth.readKeyScopes), ...this.auth.adminApiKeys];
|
|
419
|
+
if (apiKeys.includes(password)) {
|
|
420
|
+
logWarning(WarningType.ADMIN_PASSWORD_REUSED);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
341
424
|
/** Surface the same problems as warnings outside production. */
|
|
342
425
|
warnAboutDevelopmentDefaults() {
|
|
343
426
|
if (Object.keys(this.auth.readKeyScopes).length === 0) {
|